Initial commit — Mardonar encounter engine with UX improvements
Includes full bot source (Phases 1–4), plus five new features: - Epic 1: emoji reaction state machine (👀⏳✅🎲) + burst queue cap at 2 with in-world drop notices - Epic 2: per-encounter tone field in YAML injected into LLM system prompt - Epic 3: player pronouns via modal registration + system prompt players block - Epic 4: strengthened skill_check_emit tool contract + missed-skill-check diagnostic Also includes UX design docs, epics, and story files under Docs/. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
69
.env.example
Normal file
69
.env.example
Normal file
@@ -0,0 +1,69 @@
|
||||
DISCORD_TOKEN=your_discord_bot_token
|
||||
DISCORD_CLIENT_ID=your_discord_application_id
|
||||
# DISCORD_GUILD_ID=your_server_id # Set for instant command registration (vs 1hr global propagation)
|
||||
|
||||
# ── Redis ──────────────────────────────────────────────────────────────────────
|
||||
# Shared with GraphMCP-Example stack via knowledge-graph-ai_internal network
|
||||
REDIS_URL=redis://redis:6379
|
||||
|
||||
# How long a session stays alive in Redis without activity (hours)
|
||||
# SESSION_TTL_HOURS=12
|
||||
|
||||
# ── Ollama / LLM ───────────────────────────────────────────────────────────────
|
||||
OLLAMA_BASE_URL=http://192.168.1.x:11434
|
||||
OLLAMA_MODEL=gemma4-it:e2b
|
||||
|
||||
# Sampling temperature — higher = more creative, lower = more consistent (0–2)
|
||||
# OLLAMA_TEMPERATURE=0.75
|
||||
|
||||
# Context window in tokens — must match your model's actual max
|
||||
# OLLAMA_NUM_CTX=131072
|
||||
|
||||
# LLM call timeout in milliseconds
|
||||
# OLLAMA_TIMEOUT_MS=120000
|
||||
|
||||
# ── GraphMCP ───────────────────────────────────────────────────────────────────
|
||||
GRAPHMCP_URL=http://mcp-server:9000
|
||||
|
||||
# Minimum semantic similarity score (0–1) to count a chunk as relevant context.
|
||||
# Raise to be stricter (fewer but more accurate results).
|
||||
# Lower to be more permissive (more context, more noise).
|
||||
# GRAPHMCP_SCORE_THRESHOLD=0.68
|
||||
|
||||
# How many memory chunks to fetch per NPC at encounter start
|
||||
# GRAPHMCP_NPC_MEMORY_LIMIT=5
|
||||
|
||||
# How many chunks to fetch when @Zalram is mentioned
|
||||
# GRAPHMCP_MENTION_LIMIT=5
|
||||
|
||||
# ── Encounter behaviour ────────────────────────────────────────────────────────
|
||||
SPECS_DIR=./specs
|
||||
|
||||
# Delay before archiving a resolved thread — gives players time to read the ending (ms)
|
||||
# ENCOUNTER_ARCHIVE_DELAY_MS=5000
|
||||
|
||||
# How long the player-gate embed stays visible before auto-delete (ms)
|
||||
# ENCOUNTER_GATE_TIMEOUT_MS=30000
|
||||
|
||||
# ── Persona ────────────────────────────────────────────────────────────────────
|
||||
# Path to the YAML file defining the bot's @mention persona
|
||||
# PERSONA_PATH=./persona.yaml
|
||||
|
||||
# ── Access control ─────────────────────────────────────────────────────────────
|
||||
# Comma-separated channel IDs where encounters are allowed.
|
||||
# The bot checks the parent channel of threads before responding.
|
||||
# Leave empty and the bot responds nowhere (secure by default).
|
||||
# DISCORD_ALLOWED_CHANNELS=123456789012345678,987654321098765432
|
||||
DISCORD_ALLOWED_CHANNELS=
|
||||
|
||||
# Comma-separated user IDs who can use /encounter commands. Empty = everyone.
|
||||
# DISCORD_ALLOWED_USERS=123456789012345678,987654321098765432
|
||||
DISCORD_ALLOWED_USERS=
|
||||
|
||||
# ── Logging ────────────────────────────────────────────────────────────────────
|
||||
LOG_LEVEL=debug
|
||||
|
||||
|
||||
LITELLM_BASE_URL=
|
||||
LITELLM_API_KEY=
|
||||
LITELLM_MODEL=ollama-cloud
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
32
Dockerfile
Normal file
32
Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --ignore-scripts
|
||||
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ── Runtime image ──────────────────────────────────────────────
|
||||
FROM node:22-alpine
|
||||
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev --ignore-scripts
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY specs ./specs
|
||||
COPY lore ./lore
|
||||
COPY persona.yaml ./persona.yaml
|
||||
|
||||
CMD ["node", "dist/bot/index.js"]
|
||||
288
Docs/epics.md
Normal file
288
Docs/epics.md
Normal file
@@ -0,0 +1,288 @@
|
||||
---
|
||||
stepsCompleted: ["step-01-validate-prerequisites"]
|
||||
inputDocuments:
|
||||
- prd.md
|
||||
- Docs/mardonar-encounter-engine.md
|
||||
- Docs/mardonar-build-plan.md
|
||||
- Docs/ux-designs/ux-mardonar-2026-05-30/DESIGN.md
|
||||
- Docs/ux-designs/ux-mardonar-2026-05-30/EXPERIENCE.md
|
||||
---
|
||||
|
||||
# Mardonar Encounter Engine - Epic Breakdown
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides the complete epic and story breakdown for the Mardonar Encounter Engine, covering new features and bug fixes layered on top of the existing engine (Phases 1–4 are substantially complete in the codebase as of 2026-05-30).
|
||||
|
||||
## Requirements Inventory
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
FR1: The bot must add an emoji reaction to a player's message immediately upon receipt to signal it was seen (👀).
|
||||
FR2: When a player message enters the LLM processing queue, the 👀 reaction must be replaced with ⏳.
|
||||
FR3: When a dice roll is detected in a queued player message, a 🎲 reaction must be added alongside ⏳ for the duration of processing.
|
||||
FR4: When the LLM response is posted, ⏳ (and 🎲 if present) must be removed and replaced with ✅ for ~10 seconds, then removed.
|
||||
FR5: The message queue must enforce a hard cap of 2 messages per burst. Messages beyond the cap must not be queued.
|
||||
FR6: When a player's message is dropped by the queue cap, the bot must send an ephemeral reply to that player with an in-world message.
|
||||
FR7: The ephemeral drop notice must be pre-generated (not LLM-generated at runtime) and selected based on the encounter's active tone value.
|
||||
FR8: Each encounter YAML spec must support an optional `tone` field (free-text string, e.g. "grim", "tense", "comedic", "mysterious") that drives narration flavor for that encounter.
|
||||
FR9: The `tone` value must be injected into the LLM system prompt as a style directive for all narration generated during the encounter.
|
||||
FR10: The `tone` value must be cached in session state at session start so it is available synchronously when the drop notice fires.
|
||||
FR11: Players must be able to set pronouns as part of their character profile via the existing `/character register custom` command.
|
||||
FR12: The player's pronouns must be injected into the LLM system prompt so narration uses the correct pronouns when referring to that player's character.
|
||||
FR13: When the LLM detects a player intends to make a skill check (rolling dice), it must reliably call `skill_check_emit` rather than narrating the outcome itself.
|
||||
FR14: The system prompt's tool contract must be strengthened with explicit negative instructions and/or few-shot examples to prevent the LLM from self-narrating roll outcomes.
|
||||
|
||||
### NonFunctional Requirements
|
||||
|
||||
NFR1: Emoji reactions must be added within 200ms of the message being received, before any LLM call is initiated.
|
||||
NFR2: Ephemeral drop notices must be sent synchronously within the message-receive path — no LLM call must be made for a dropped message.
|
||||
NFR3: The `tone` value must be accessible synchronously (from session state in Redis) when the drop notice fires — no extra async lookup at that moment.
|
||||
NFR4: The reaction state machine must never leave a message in a stale state (e.g. ⏳ stuck after a failed LLM call) — cleanup must happen in the finally block of the generation runner.
|
||||
NFR5: The queue cap must reset cleanly after each LLM response completes, so the next burst starts with a fresh cap.
|
||||
NFR6: All player-facing strings — including drop notices and system confirmations — must use in-world language. The words "bot", "system", "queue", "rate limit", or "error" must never appear in player-visible output.
|
||||
|
||||
### Additional Requirements
|
||||
|
||||
- Stack: TypeScript / Node.js / discord.js v14. Phases 1–4 are complete. All new work layers on top of the existing engine.
|
||||
- The existing `generationQueue.ts` debounce logic (500ms, drain turn) must be preserved. The cap and reactions are additive changes to this file.
|
||||
- The `EncounterSpec` Zod schema in `src/spec/loader.ts` and `EncounterSpec` interface in `src/types/index.ts` must both be updated to include `tone?: string`.
|
||||
- The `tone` value must be stored on `SessionState` so it survives across message turns without a spec re-read.
|
||||
- Pronouns must be stored on the existing `CharacterProfile` type in `src/session/characterRegistry.ts` (or equivalent) and surfaced via `characterRegistry.get()`.
|
||||
- The skill check reliability fix is a prompt engineering change in `src/harness/promptBuilder.ts` and potentially a post-dispatch validation step in `src/harness/toolDispatcher.ts`.
|
||||
|
||||
### UX Design Requirements
|
||||
|
||||
UX-DR1: Reactions are applied to player messages only — never to bot messages.
|
||||
UX-DR2: The reaction state machine is: received (👀) → processing (⏳, optionally +🎲 if dice detected) → done (✅ for ~10s then cleared). Reactions must self-clear — no permanent reaction markers on messages.
|
||||
UX-DR3: The ephemeral drop notice is a plain text ephemeral reply (not an embed). Tone-keyed strings: grim → "The chaos swallowed your words…"; comedic → "Everyone was talking at once…"; mysterious → "Something muffled your voice…"; tense → "No time — the moment moved on without you…"; baseline → "The echoes could not carry all voices at once…".
|
||||
UX-DR4: The `tone` field in encounter YAML is free-text (no enum enforcement). Unrecognised tone values fall back to baseline drop notice string.
|
||||
UX-DR5: Pronouns are added as an optional field on `/character register custom`. Default is "they/them" when unset.
|
||||
UX-DR6: The `🎲` reaction must only be added when the bot's dice-detection heuristic is confident (e.g. the message contains a roll-related keyword or the pending skill check flag is set).
|
||||
|
||||
### FR Coverage Map
|
||||
|
||||
| FR | Epic | Story |
|
||||
|---|---|---|
|
||||
| FR1–FR5 | Epic 1 | 1.1 |
|
||||
| FR6–FR7 | Epic 1 | 1.2 |
|
||||
| FR8–FR10 | Epic 2 | 2.1 |
|
||||
| FR11–FR12 | Epic 3 | 3.1 |
|
||||
| FR13–FR14 | Epic 4 | 4.1 |
|
||||
| NFR1–NFR6 | Cross-cutting | All stories |
|
||||
| UX-DR1–UX-DR6 | Cross-cutting | All stories |
|
||||
|
||||
## Epic List
|
||||
|
||||
1. Epic 1: Interaction Feedback & Queue Management
|
||||
2. Epic 2: Encounter Tone Configuration
|
||||
3. Epic 3: Player Pronouns
|
||||
4. Epic 4: Dice Roll Reliability Fix
|
||||
|
||||
---
|
||||
|
||||
## Epic 1: Interaction Feedback & Queue Management
|
||||
|
||||
Give players real-time visual feedback on the state of their messages (received, queued, processing, done, dropped) and enforce a hard queue cap of 2 with in-world ephemeral notices for dropped messages.
|
||||
|
||||
### Story 1.1: Emoji Reaction State Machine
|
||||
|
||||
As a player,
|
||||
I want emoji reactions on my messages to show whether they were received and are being processed,
|
||||
So that I always know the bot saw my message and what it is doing with it.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** a player sends a message in an active encounter thread
|
||||
**When** the message is received by the bot's message handler
|
||||
**Then** the bot adds a 👀 reaction to that message within 200ms, before any LLM call is initiated
|
||||
|
||||
**Given** the player's message enters the LLM processing queue (within the 2-message cap)
|
||||
**When** the LLM generation begins (inside the `fire()` call in `generationQueue.ts`)
|
||||
**Then** the 👀 reaction is removed and ⏳ is added to the message
|
||||
|
||||
**Given** the player's message contains a roll-related keyword or the session has a pending skill check
|
||||
**When** the message enters the processing queue
|
||||
**Then** 🎲 is added alongside ⏳ for the duration of processing
|
||||
|
||||
**Given** the LLM response has been posted to the thread
|
||||
**When** the generation runner's try block completes
|
||||
**Then** ⏳ (and 🎲 if present) are removed, ✅ is added to the message, and ✅ is removed ~10 seconds later
|
||||
|
||||
**Given** the LLM call fails or throws an error
|
||||
**When** the generation runner's finally block executes
|
||||
**Then** all in-progress reactions (⏳, 🎲) are removed from the message so no stale state is left
|
||||
|
||||
**Notes:**
|
||||
- Reactions are applied only to player messages. Bot messages are never reacted to by the bot.
|
||||
- The message ID must be threaded through to the generation runner so reactions can be managed. Pass it as part of the runner closure or as a parameter to `scheduleLLMTurn`.
|
||||
- Discord reaction calls (`message.react()`, `reaction.remove()`) should be fire-and-forget with logged errors — they must never cause the message handler to throw or block.
|
||||
|
||||
---
|
||||
|
||||
### Story 1.2: Queue Cap with In-World Drop Notice
|
||||
|
||||
As a player,
|
||||
I want to be told privately when my message wasn't processed due to message volume,
|
||||
So that I know to wait and try again rather than wondering if the bot is broken.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** a player's message arrives while 2 messages are already pending in the queue
|
||||
**When** the queue cap check runs (in `scheduleLLMTurn` or the message handler before enqueuing)
|
||||
**Then** the message is NOT added to the queue and the bot does NOT call the LLM for it
|
||||
|
||||
**Given** the player's message was dropped by the cap
|
||||
**When** the drop is detected
|
||||
**Then** the bot sends an ephemeral reply to that player's message with the in-world drop notice for the session's active tone
|
||||
|
||||
**Given** the session's `tone` value is "grim"
|
||||
**When** a drop notice is sent
|
||||
**Then** the text reads: *"The chaos swallowed your words before they could reach the moment. Silence yourself until the echoes clear."*
|
||||
|
||||
**Given** the session's `tone` value is "comedic"
|
||||
**When** a drop notice is sent
|
||||
**Then** the text reads: *"Everyone was talking at once and the universe, frankly, wasn't listening. Give it a moment."*
|
||||
|
||||
**Given** the session's `tone` value is "mysterious"
|
||||
**When** a drop notice is sent
|
||||
**Then** the text reads: *"Something in the fabric of this place muffled your voice. Wait. It will pass."*
|
||||
|
||||
**Given** the session's `tone` value is "tense"
|
||||
**When** a drop notice is sent
|
||||
**Then** the text reads: *"No time — the moment moved on without you. Hold. Wait for your opening."*
|
||||
|
||||
**Given** the session's `tone` value is absent, unknown, or any other string
|
||||
**When** a drop notice is sent
|
||||
**Then** the text reads: *"The echoes of the encounter could not carry all voices at once. Wait for the dust to settle before speaking again."*
|
||||
|
||||
**Given** the LLM response has posted and the queue drains
|
||||
**When** the `pendingCount` resets in the `finally` block
|
||||
**Then** the cap counter also resets so the next burst starts fresh with a 0 cap count
|
||||
|
||||
**Notes:**
|
||||
- The drop notice strings are hardcoded constants — the LLM is never called to generate them. This avoids a catch-22 where a rate-limited system tries to invoke the LLM for its own rate-limit message.
|
||||
- `tone` must be read from `SessionState` (not from the spec directly) so it is available synchronously without an extra Redis lookup at the drop moment.
|
||||
- The ephemeral reply uses `message.reply({ content: noticeText, flags: MessageFlags.Ephemeral })` — no embed needed.
|
||||
- The 👀 reaction added on message receipt must still be removed for dropped messages (add then immediately remove, no further reactions).
|
||||
|
||||
---
|
||||
|
||||
## Epic 2: Encounter Tone Configuration
|
||||
|
||||
Allow each encounter YAML spec to declare a tone that drives narration flavor and drop notice phrasing throughout the encounter.
|
||||
|
||||
### Story 2.1: Tone Field in Spec and Session
|
||||
|
||||
As a DM,
|
||||
I want to set a `tone` field in my encounter YAML that controls how the bot sounds during that encounter,
|
||||
So that a tense pursuit feels different from a comedic heist without touching code.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** an encounter YAML spec file
|
||||
**When** it includes a `tone: "tense"` (or any string) field at the top level
|
||||
**Then** the Zod schema in `src/spec/loader.ts` parses it without error as `tone?: string`
|
||||
|
||||
**Given** an encounter YAML spec without a `tone` field
|
||||
**When** the spec is loaded
|
||||
**Then** `spec.tone` is `undefined` and the system falls back to baseline behavior throughout
|
||||
|
||||
**Given** a session is initialized via `/encounter start`
|
||||
**When** `SessionState` is created and saved to Redis
|
||||
**Then** `session.tone` is populated from `spec.tone` (or `undefined` if absent) and persists for the session lifetime
|
||||
|
||||
**Given** the LLM system prompt is assembled by `src/harness/promptBuilder.ts`
|
||||
**When** `session.tone` is defined
|
||||
**Then** the system prompt includes a `<tone>` block immediately after `<narrator_identity>` with text: `Your narration style for this encounter is: {tone}. Let this flavor all of your responses, NPC voices, and pacing.`
|
||||
|
||||
**Given** the LLM system prompt is assembled
|
||||
**When** `session.tone` is `undefined`
|
||||
**Then** no `<tone>` block is added (baseline Gemma narration applies)
|
||||
|
||||
**Notes:**
|
||||
- `EncounterSpec` interface in `src/types/index.ts` needs `tone?: string` added.
|
||||
- `SessionState` in `src/types/index.ts` needs `tone?: string` added.
|
||||
- `buildSystemPrompt` in `src/harness/promptBuilder.ts` receives the session (or spec) and includes the tone block when present.
|
||||
- No enum constraint on tone values — any free-text string is valid. Prompt injection uses the string verbatim.
|
||||
|
||||
---
|
||||
|
||||
## Epic 3: Player Pronouns
|
||||
|
||||
Allow players to register their preferred pronouns alongside their character name so the LLM uses them correctly during narration.
|
||||
|
||||
### Story 3.1: Pronouns in Character Registry and Narration
|
||||
|
||||
As a player,
|
||||
I want to register my character's pronouns so the bot refers to my character correctly during encounters,
|
||||
So that my character exists in the world on my terms.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** a player uses `/character register custom`
|
||||
**When** they include the optional `pronouns` string option (e.g. `pronouns:"she/her"`)
|
||||
**Then** the pronouns value is saved to their `CharacterProfile` in Redis alongside their name
|
||||
|
||||
**Given** a player uses `/character register custom` without specifying pronouns
|
||||
**When** their profile is saved
|
||||
**Then** `profile.pronouns` is `undefined` (no default is written; the prompt builder will use "they/them" as its fallback)
|
||||
|
||||
**Given** a player's `CharacterProfile` has `pronouns` set
|
||||
**When** the LLM system prompt is assembled for an encounter they are participating in
|
||||
**Then** the per-player entry in the system prompt includes: `Pronouns: {pronouns}` (or `they/them` if unset)
|
||||
|
||||
**Given** the system prompt includes a player's pronouns
|
||||
**When** the LLM narrates an action by that player's character
|
||||
**Then** the LLM uses the specified pronouns in narration (this is verified via the integration test fixture, not a live LLM assertion)
|
||||
|
||||
**Given** a player runs `/character show`
|
||||
**When** their profile has pronouns set
|
||||
**Then** the show embed includes a "Pronouns" field displaying the registered value
|
||||
|
||||
**Notes:**
|
||||
- `CharacterProfile` type in `src/session/characterRegistry.ts` (or `src/types/index.ts`) needs `pronouns?: string` added.
|
||||
- The Zod schema for CharacterProfile (if it exists) needs the field added.
|
||||
- `src/harness/promptBuilder.ts`: in the player/character section of the system prompt, add `Pronouns: ${profile.pronouns ?? 'they/them'}` after the character name line.
|
||||
- The `/character register custom` command's `SlashCommandBuilder` needs a new optional `pronouns` string option.
|
||||
- No format enforcement on the pronouns string — accept free text (e.g. "she/her", "he/him", "they/them", "xe/xir").
|
||||
|
||||
---
|
||||
|
||||
## Epic 4: Dice Roll Reliability Fix
|
||||
|
||||
Fix the bug where the LLM narrates a skill check outcome itself instead of calling `skill_check_emit`, causing players to have to re-prompt for their dice roll.
|
||||
|
||||
### Story 4.1: Strengthen Skill Check Tool Contract
|
||||
|
||||
As a player,
|
||||
I want the bot to always post the dice roll embed when a skill check is needed,
|
||||
So that I never have to ask again after already declaring my action.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
**Given** a player message that implies a skill check (e.g. "I try to pick the lock", "Aelindra chases Dal", "I attempt to persuade the guard")
|
||||
**When** the LLM processes the message
|
||||
**Then** the LLM must output a `skill_check_emit` tool call rather than narrating the dice outcome itself
|
||||
|
||||
**Given** the LLM's response is parsed by `toolParser.ts`
|
||||
**When** the response contains no `tool_call` block but the session has no `pendingSkillCheck` and the narrative text contains a self-resolved roll outcome (heuristic: phrases like "you succeed", "you fail", "the check succeeds", "rolling a")
|
||||
**Then** the tool dispatcher logs a warning that a possible missed skill check was detected (for debugging; no user-facing change at this stage)
|
||||
|
||||
**Given** the system prompt in `src/harness/promptBuilder.ts`
|
||||
**When** the `<tool_contract>` block is rendered
|
||||
**Then** it includes an explicit negative instruction: `NEVER narrate the outcome of a skill check. ALWAYS emit skill_check_emit and wait for the player's roll result before continuing the narrative.`
|
||||
|
||||
**Given** the system prompt's `<tool_contract>` block
|
||||
**When** rendered
|
||||
**Then** it includes at least one few-shot example demonstrating: player declares action → LLM emits `skill_check_emit` → `[SYSTEM]` roll result arrives → LLM narrates outcome
|
||||
|
||||
**Given** a few-shot example is added to the tool contract
|
||||
**When** the example demonstrates the correct flow
|
||||
**Then** the example uses the exact `tool_call` block format already defined in the contract (no new format introduced)
|
||||
|
||||
**Notes:**
|
||||
- The root cause is likely that the model (gemma4-it:e2b) at e2b quantization is inconsistent about tool calls when the narrative "obviously" implies a result. Stronger negative instructions and a few-shot example are the correct lever without resorting to post-processing hacks.
|
||||
- The heuristic detection in `toolDispatcher.ts` (see AC 2) is diagnostic only — it logs to the existing pino logger under `log.warn('harness', 'possible missed skill_check_emit', ...)`. It does not retry or alter the response.
|
||||
- If the few-shot example is long, it can be extracted to a constant in `promptBuilder.ts` to keep the function readable.
|
||||
- Integration test in `tests/unit/promptBuilder.test.ts`: assert that the built system prompt contains the negative instruction string and the phrase `skill_check_emit` in the tool contract section.
|
||||
897
Docs/mardonar-build-plan.md
Normal file
897
Docs/mardonar-build-plan.md
Normal file
@@ -0,0 +1,897 @@
|
||||
# Mardonar Encounter Engine — Phased Build Plan
|
||||
|
||||
> **Stack:** TypeScript / Node.js
|
||||
> **Runtime:** tsx (dev) → compiled JS (prod)
|
||||
> **Test runner:** Vitest
|
||||
> **Target model:** gemma4-it:e2b via Ollama
|
||||
|
||||
---
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
mardonar-bot/
|
||||
├── src/
|
||||
│ ├── bot/ # discord.js bot, command handlers, embed builders
|
||||
│ ├── session/ # session manager, player registry
|
||||
│ ├── harness/ # context assembler, ollama client, tool dispatcher
|
||||
│ ├── mcp/ # MCP server + individual tool definitions
|
||||
│ ├── db/ # Redis and Neo4j client singletons
|
||||
│ ├── spec/ # EncounterSpec loader + Zod schema
|
||||
│ └── types/ # shared TypeScript types and interfaces
|
||||
├── specs/ # encounter YAML files (hand-authored)
|
||||
├── tests/
|
||||
│ ├── unit/
|
||||
│ ├── integration/
|
||||
│ └── fixtures/ # mock specs, mock LLM responses
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── vitest.config.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shared Types (define first, referenced by all phases)
|
||||
|
||||
```typescript
|
||||
// src/types/index.ts
|
||||
|
||||
export interface Player {
|
||||
discordId: string;
|
||||
dndName: string;
|
||||
}
|
||||
|
||||
export interface NpcPersona {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
persona: string;
|
||||
memoryKey?: string;
|
||||
}
|
||||
|
||||
export interface EncounterGoal {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface EncounterSpec {
|
||||
encounterId: string;
|
||||
title: string;
|
||||
setting: { location: string; mood: string; ambientNpcs: string };
|
||||
openingNarrative: string;
|
||||
npcs: NpcPersona[];
|
||||
goals: { primary: EncounterGoal[]; secondary: EncounterGoal[] };
|
||||
sportsmanshipRules: string[];
|
||||
skillChecks: Record<string, number | string>;
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
encounterId: string;
|
||||
threadId: string;
|
||||
guildId: string;
|
||||
spec: EncounterSpec;
|
||||
players: Record<string, Player>; // discordId → Player
|
||||
history: ChatMessage[];
|
||||
phase: 'open' | 'active' | 'resolved';
|
||||
heldMessages: HeldMessage[];
|
||||
outcome?: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
pinned?: boolean; // pinned messages survive history trimming
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface HeldMessage {
|
||||
discordUserId: string;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export type ToolCallBlock = {
|
||||
tool: string;
|
||||
args: Record<string, unknown>;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Foundation
|
||||
|
||||
**Goal:** Discord bot running, slash commands working, player registry operational, encounter thread creation working. No LLM involved yet.
|
||||
|
||||
### Packages
|
||||
|
||||
| Package | Version | Purpose |
|
||||
|---|---|---|
|
||||
| `discord.js` | `^14.18` | Bot framework, slash commands, thread management, embeds |
|
||||
| `@discordjs/builders` | `^1.10` | Slash command builders |
|
||||
| `@discordjs/rest` | `^2.4` | Command registration via Discord REST API |
|
||||
| `ioredis` | `^5.4` | Redis client (player registry + session store) |
|
||||
| `js-yaml` | `^4.1` | YAML spec loader |
|
||||
| `zod` | `^3.24` | Runtime schema validation for specs and env vars |
|
||||
| `dotenv` | `^16.4` | `.env` loading |
|
||||
| `pino` | `^9.6` | Structured logging |
|
||||
| `pino-pretty` | `^13.0` | Dev-mode log formatting |
|
||||
| `tsx` | `^4.19` | TypeScript execution without build step |
|
||||
| `typescript` | `^5.8` | TypeScript compiler |
|
||||
| `vitest` | `^3.1` | Test runner |
|
||||
| `ioredis-mock` | `^8.9` | In-memory Redis for unit tests |
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
**1. Project bootstrap**
|
||||
```bash
|
||||
mkdir mardonar-bot && cd mardonar-bot
|
||||
npm init -y
|
||||
npm install discord.js @discordjs/builders @discordjs/rest ioredis js-yaml zod dotenv pino pino-pretty
|
||||
npm install -D typescript tsx vitest ioredis-mock @types/node @types/js-yaml
|
||||
npx tsc --init
|
||||
```
|
||||
|
||||
Key `tsconfig.json` settings:
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**2. Environment schema** — validate env at startup, fail fast
|
||||
```typescript
|
||||
// src/config.ts
|
||||
import { z } from 'zod';
|
||||
import 'dotenv/config';
|
||||
|
||||
const EnvSchema = z.object({
|
||||
DISCORD_TOKEN: z.string(),
|
||||
DISCORD_CLIENT_ID: z.string(),
|
||||
REDIS_URL: z.string().default('redis://localhost:6379'),
|
||||
OLLAMA_BASE_URL: z.string().default('http://localhost:11434'),
|
||||
OLLAMA_MODEL: z.string().default('gemma4-it:e2b'),
|
||||
NEO4J_URI: z.string().default('bolt://localhost:7687'),
|
||||
NEO4J_USER: z.string().default('neo4j'),
|
||||
NEO4J_PASSWORD: z.string(),
|
||||
LOG_LEVEL: z.enum(['trace','debug','info','warn','error']).default('info'),
|
||||
});
|
||||
|
||||
export const config = EnvSchema.parse(process.env);
|
||||
```
|
||||
|
||||
**3. Redis client singleton**
|
||||
```typescript
|
||||
// src/db/redis.ts
|
||||
import Redis from 'ioredis';
|
||||
import { config } from '../config.js';
|
||||
|
||||
export const redis = new Redis(config.REDIS_URL, {
|
||||
lazyConnect: true,
|
||||
maxRetriesPerRequest: 3,
|
||||
});
|
||||
```
|
||||
|
||||
**4. Player registry**
|
||||
```typescript
|
||||
// src/session/playerRegistry.ts
|
||||
import { redis } from '../db/redis.js';
|
||||
import type { Player } from '../types/index.js';
|
||||
|
||||
const key = (guildId: string) => `players:${guildId}`;
|
||||
|
||||
export const playerRegistry = {
|
||||
async get(guildId: string, discordId: string): Promise<Player | null> {
|
||||
const name = await redis.hget(key(guildId), discordId);
|
||||
if (!name) return null;
|
||||
return { discordId, dndName: name };
|
||||
},
|
||||
async set(guildId: string, discordId: string, dndName: string): Promise<void> {
|
||||
await redis.hset(key(guildId), discordId, dndName);
|
||||
},
|
||||
async delete(guildId: string, discordId: string): Promise<void> {
|
||||
await redis.hdel(key(guildId), discordId);
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
**5. Slash commands**
|
||||
|
||||
Register commands via `scripts/deploy-commands.ts`, run once on deploy:
|
||||
```typescript
|
||||
// src/bot/commands/dndname.ts
|
||||
import { SlashCommandBuilder } from '@discordjs/builders';
|
||||
import type { ChatInputCommandInteraction } from 'discord.js';
|
||||
import { playerRegistry } from '../../session/playerRegistry.js';
|
||||
|
||||
export const data = new SlashCommandBuilder()
|
||||
.setName('dndname')
|
||||
.setDescription('Set your D&D character name')
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('set').setDescription('Register your character name')
|
||||
.addStringOption(o => o.setName('name').setDescription('Your character name').setRequired(true))
|
||||
)
|
||||
.addSubcommand(sub => sub.setName('show').setDescription('Show your registered name'))
|
||||
.addSubcommand(sub => sub.setName('clear').setDescription('Clear your registered name'));
|
||||
|
||||
export async function execute(interaction: ChatInputCommandInteraction) {
|
||||
const guildId = interaction.guildId!;
|
||||
const userId = interaction.user.id;
|
||||
const sub = interaction.options.getSubcommand();
|
||||
|
||||
if (sub === 'set') {
|
||||
const name = interaction.options.getString('name', true);
|
||||
await playerRegistry.set(guildId, userId, name);
|
||||
await interaction.reply({ content: `Registered as **${name}**.`, ephemeral: true });
|
||||
} else if (sub === 'show') {
|
||||
const player = await playerRegistry.get(guildId, userId);
|
||||
await interaction.reply({
|
||||
content: player ? `Your character is **${player.dndName}**.` : 'No name registered.',
|
||||
ephemeral: true,
|
||||
});
|
||||
} else if (sub === 'clear') {
|
||||
await playerRegistry.delete(guildId, userId);
|
||||
await interaction.reply({ content: 'Character name cleared.', ephemeral: true });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**6. EncounterSpec loader + Zod schema**
|
||||
```typescript
|
||||
// src/spec/loader.ts
|
||||
import { readFileSync } from 'fs';
|
||||
import { load } from 'js-yaml';
|
||||
import { z } from 'zod';
|
||||
|
||||
const NpcSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
role: z.string(),
|
||||
persona: z.string(),
|
||||
memoryKey: z.string().optional(),
|
||||
});
|
||||
|
||||
const GoalSchema = z.object({ id: z.string(), label: z.string() });
|
||||
|
||||
export const EncounterSpecSchema = z.object({
|
||||
encounterId: z.string(),
|
||||
title: z.string(),
|
||||
setting: z.object({ location: z.string(), mood: z.string(), ambientNpcs: z.string() }),
|
||||
openingNarrative: z.string(),
|
||||
npcs: z.array(NpcSchema),
|
||||
goals: z.object({ primary: z.array(GoalSchema), secondary: z.array(GoalSchema) }),
|
||||
sportsmanshipRules: z.array(z.string()),
|
||||
skillChecks: z.record(z.union([z.number(), z.string()])),
|
||||
});
|
||||
|
||||
export type EncounterSpec = z.infer<typeof EncounterSpecSchema>;
|
||||
|
||||
export function loadSpec(path: string): EncounterSpec {
|
||||
const raw = readFileSync(path, 'utf-8');
|
||||
const parsed = load(raw);
|
||||
return EncounterSpecSchema.parse(parsed);
|
||||
}
|
||||
```
|
||||
|
||||
**7. Encounter trigger command**
|
||||
```typescript
|
||||
// src/bot/commands/encounter.ts
|
||||
// /encounter start <spec-name> — loads spec, creates thread, initializes session
|
||||
```
|
||||
|
||||
The `start` subcommand loads a spec from `./specs/<name>.yaml`, creates a Discord thread on the current channel, and initializes session state in Redis.
|
||||
|
||||
### Phase 1 Test Plan
|
||||
|
||||
**Unit tests**
|
||||
|
||||
`tests/unit/playerRegistry.test.ts`
|
||||
- `set` → `get` round-trips correctly
|
||||
- `get` on unknown user returns `null`
|
||||
- `delete` removes the entry
|
||||
- All tests use `ioredis-mock` — no live Redis
|
||||
|
||||
`tests/unit/specLoader.test.ts`
|
||||
- Valid YAML parses without throwing
|
||||
- Missing required field (`encounterId`) throws `ZodError`
|
||||
- Extra fields are stripped (Zod `.strip()` default)
|
||||
- `openingNarrative` and `goals` are preserved exactly
|
||||
|
||||
**Integration test**
|
||||
|
||||
`tests/integration/phase1.test.ts`
|
||||
- Start a real Redis (or Docker Compose service), register a player, retrieve them, delete them
|
||||
- Load the `market-thief.yaml` fixture spec and verify all fields
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — LLM Harness
|
||||
|
||||
**Goal:** Context assembly, token budgeting, Ollama call, response routing back to Discord. No tools yet — LLM just narrates.
|
||||
|
||||
### Additional Packages
|
||||
|
||||
| Package | Version | Purpose |
|
||||
|---|---|---|
|
||||
| `ollama` | `^0.5` | Official Ollama JS client |
|
||||
| `gpt-tokenizer` | `^2.8` | Token counting proxy (no Gemma tokenizer exists in npm; this is close enough for budget management — add a 15% safety buffer) |
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
**1. Session manager**
|
||||
```typescript
|
||||
// src/session/sessionManager.ts
|
||||
// Key: session:{threadId}
|
||||
// Value: JSON-serialized SessionState
|
||||
// TTL: 12 hours (sessions auto-expire)
|
||||
|
||||
export const sessionManager = {
|
||||
async create(threadId: string, state: SessionState): Promise<void>,
|
||||
async get(threadId: string): Promise<SessionState | null>,
|
||||
async update(threadId: string, patch: Partial<SessionState>): Promise<void>,
|
||||
async delete(threadId: string): Promise<void>,
|
||||
async addMessage(threadId: string, msg: ChatMessage): Promise<void>,
|
||||
};
|
||||
```
|
||||
|
||||
`addMessage` appends to `state.history` and, if token count exceeds the budget, calls the trimmer before saving.
|
||||
|
||||
**2. Token counter**
|
||||
```typescript
|
||||
// src/harness/tokenCounter.ts
|
||||
import { encode } from 'gpt-tokenizer';
|
||||
|
||||
// Add 15% buffer on top of estimate to account for Gemma tokenizer differences
|
||||
const BUFFER_FACTOR = 1.15;
|
||||
|
||||
export function estimateTokens(text: string): number {
|
||||
return Math.ceil(encode(text).length * BUFFER_FACTOR);
|
||||
}
|
||||
|
||||
export function estimateMessages(messages: ChatMessage[]): number {
|
||||
return messages.reduce((sum, m) => sum + estimateTokens(m.content) + 4, 0);
|
||||
}
|
||||
```
|
||||
|
||||
**3. Context assembler**
|
||||
|
||||
This is the core of the harness. It produces the final `messages` array sent to Ollama.
|
||||
|
||||
```typescript
|
||||
// src/harness/contextAssembler.ts
|
||||
|
||||
const BUDGET = {
|
||||
SYSTEM: 4_000, // system prompt + NPCs + goals
|
||||
PINNED: 2_000, // opening narrative + goal block (never trimmed)
|
||||
HISTORY: 118_000, // sliding conversation window
|
||||
SAFETY: 3_500, // hard floor before we force a trim
|
||||
TOTAL: 128_000,
|
||||
};
|
||||
|
||||
export function assembleContext(session: SessionState, npcMemories: Record<string, string>): ChatMessage[] {
|
||||
const systemPrompt = buildSystemPrompt(session.spec, npcMemories);
|
||||
const pinnedMessages = session.history.filter(m => m.pinned);
|
||||
const slidingMessages = session.history.filter(m => !m.pinned);
|
||||
|
||||
// Trim sliding window from oldest end until under budget
|
||||
const trimmed = trimHistory(slidingMessages, BUDGET.HISTORY - BUDGET.SAFETY);
|
||||
|
||||
return [
|
||||
{ role: 'system', content: systemPrompt, pinned: true, timestamp: 0 },
|
||||
...pinnedMessages,
|
||||
...trimmed,
|
||||
];
|
||||
}
|
||||
|
||||
function trimHistory(messages: ChatMessage[], budgetTokens: number): ChatMessage[] {
|
||||
let total = estimateMessages(messages);
|
||||
const result = [...messages];
|
||||
while (total > budgetTokens && result.length > 6) {
|
||||
const removed = result.splice(0, 2); // remove oldest user+assistant pair
|
||||
total -= estimateMessages(removed);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
**4. System prompt builder**
|
||||
|
||||
```typescript
|
||||
// src/harness/promptBuilder.ts
|
||||
|
||||
export function buildSystemPrompt(spec: EncounterSpec, npcMemories: Record<string, string>): string {
|
||||
const npcsXml = spec.npcs.map(npc => `
|
||||
<npc id="${npc.id}">
|
||||
Name: ${npc.name} | Role: ${npc.role}
|
||||
${npc.persona}
|
||||
${npcMemories[npc.id] ? `Memory: ${npcMemories[npc.id]}` : ''}
|
||||
</npc>`).join('\n');
|
||||
|
||||
const goalsText = [
|
||||
...spec.goals.primary.map(g => `- [PRIMARY] ${g.label}`),
|
||||
...spec.goals.secondary.map(g => `- [SECONDARY] ${g.label}`),
|
||||
].join('\n');
|
||||
|
||||
const sportsmanship = spec.sportsmanshipRules.map(r => `- ${r}`).join('\n');
|
||||
|
||||
return `<narrator_identity>
|
||||
You are the Dungeon Master for a D&D 5e encounter in the Land of Mardonar.
|
||||
Voice each NPC distinctly. Guide the encounter toward the hidden goals below.
|
||||
Never reveal the goal list. Never break the fourth wall unless enforcing sportsmanship.
|
||||
</narrator_identity>
|
||||
|
||||
<sportsmanship>
|
||||
If a player attempts something unrealistic or grossly unfair, respond in-character to redirect,
|
||||
OR break character with: "⚠️ That wasn't great sportsmanship. Let's keep it grounded."
|
||||
Rules:
|
||||
${sportsmanship}
|
||||
</sportsmanship>
|
||||
|
||||
<npcs>
|
||||
${npcsXml}
|
||||
</npcs>
|
||||
|
||||
<setting>
|
||||
${spec.setting.location}
|
||||
${spec.setting.mood}
|
||||
Ambient NPCs: ${spec.setting.ambientNpcs}
|
||||
</setting>
|
||||
|
||||
<hidden_goals>
|
||||
Steer toward one of these without revealing them to players.
|
||||
${goalsText}
|
||||
</hidden_goals>
|
||||
|
||||
<tool_contract>
|
||||
When you need to emit a skill check, log an event, or update NPC memory,
|
||||
output ONLY a JSON block at the very END of your message, after your narrative:
|
||||
|
||||
\`\`\`tool_call
|
||||
{ "tool": "<tool_name>", "args": { ... } }
|
||||
\`\`\`
|
||||
|
||||
Available tools: skill_check_emit, event_log_append, npc_memory_write, encounter_resolve.
|
||||
One tool call per response maximum. Narrative text MUST come before the tool block.
|
||||
</tool_contract>`;
|
||||
}
|
||||
```
|
||||
|
||||
**5. Ollama client**
|
||||
```typescript
|
||||
// src/harness/ollamaClient.ts
|
||||
import { Ollama } from 'ollama';
|
||||
import { config } from '../config.js';
|
||||
|
||||
const ollama = new Ollama({ host: config.OLLAMA_BASE_URL });
|
||||
|
||||
export interface LLMResponse {
|
||||
narrative: string;
|
||||
toolCall?: ToolCallBlock;
|
||||
rawTokensUsed?: number;
|
||||
}
|
||||
|
||||
export async function callLLM(messages: ChatMessage[]): Promise<LLMResponse> {
|
||||
const response = await ollama.chat({
|
||||
model: config.OLLAMA_MODEL,
|
||||
messages: messages.map(m => ({ role: m.role, content: m.content })),
|
||||
stream: false,
|
||||
options: { temperature: 0.75, num_ctx: 131072 },
|
||||
});
|
||||
|
||||
const raw = response.message.content;
|
||||
const { narrative, toolCall } = parseToolCall(raw);
|
||||
|
||||
return {
|
||||
narrative,
|
||||
toolCall,
|
||||
rawTokensUsed: response.eval_count,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**6. Tool call parser**
|
||||
```typescript
|
||||
// src/harness/toolParser.ts
|
||||
|
||||
const TOOL_BLOCK_RE = /```tool_call\s*([\s\S]*?)```/;
|
||||
|
||||
export function parseToolCall(raw: string): { narrative: string; toolCall?: ToolCallBlock } {
|
||||
const match = TOOL_BLOCK_RE.exec(raw);
|
||||
if (!match) return { narrative: raw.trim() };
|
||||
|
||||
const narrative = raw.slice(0, match.index).trim();
|
||||
try {
|
||||
const toolCall = JSON.parse(match[1].trim()) as ToolCallBlock;
|
||||
return { narrative, toolCall };
|
||||
} catch {
|
||||
// Malformed tool block — treat whole response as narrative, log warning
|
||||
return { narrative: raw.trim() };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**7. Message handler (bot side)**
|
||||
|
||||
On every new message in an encounter thread:
|
||||
1. Gate: is the Discord user in the player registry? If not, send ephemeral embed.
|
||||
2. Append user message to session history.
|
||||
3. Call context assembler → Ollama client.
|
||||
4. Post narrative text back to the thread.
|
||||
5. If `toolCall` present, hand off to tool dispatcher (Phase 3 stub for now).
|
||||
6. Append assistant response to session history.
|
||||
7. Save session state.
|
||||
|
||||
### Phase 2 Test Plan
|
||||
|
||||
**Unit tests**
|
||||
|
||||
`tests/unit/tokenCounter.test.ts`
|
||||
- Empty string → 0 tokens
|
||||
- Known string → expected token range (buffer-aware)
|
||||
- `estimateMessages` sums correctly across a message array
|
||||
|
||||
`tests/unit/promptBuilder.test.ts`
|
||||
- Output contains `<narrator_identity>` block
|
||||
- All NPC ids appear in output
|
||||
- NPC memory injected when provided, omitted when not
|
||||
- All primary goal labels appear in `<hidden_goals>`
|
||||
- Sportsmanship rules appear
|
||||
|
||||
`tests/unit/toolParser.test.ts`
|
||||
- Response with no tool block → `toolCall` is `undefined`, `narrative` is full text
|
||||
- Valid tool block at end → `narrative` is text before block, `toolCall` is parsed object
|
||||
- Malformed JSON in tool block → falls back to full text as narrative, no throw
|
||||
- Tool block in the middle of text (not at end) → still parsed correctly
|
||||
|
||||
`tests/unit/contextAssembler.test.ts`
|
||||
- Builds a context array with system message first
|
||||
- Pinned messages are always included
|
||||
- When history exceeds budget, oldest non-pinned pairs are dropped
|
||||
- After trimming, total estimated tokens are below budget
|
||||
|
||||
**Integration tests**
|
||||
|
||||
`tests/integration/llmRoundTrip.test.ts`
|
||||
- Requires live Ollama with `gemma4-it:e2b` loaded
|
||||
- Send a simple encounter opening → verify response is non-empty string
|
||||
- Send a response that should produce a tool block → verify `toolCall` is parsed
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — MCP Tool Layer
|
||||
|
||||
**Goal:** All tools wired. Skill check embeds post to Discord. Events log to Neo4j. NPC memory reads and writes work.
|
||||
|
||||
### Additional Packages
|
||||
|
||||
| Package | Version | Purpose |
|
||||
|---|---|---|
|
||||
| `@modelcontextprotocol/sdk` | `^1.10` | MCP server and tool definitions |
|
||||
| `neo4j-driver` | `^5.28` | Neo4j access |
|
||||
| `@types/neo4j-driver` | `^5.28` | TypeScript types |
|
||||
|
||||
### Neo4j Schema
|
||||
|
||||
```cypher
|
||||
// NPC node — persists across all encounters
|
||||
CREATE CONSTRAINT npc_id IF NOT EXISTS FOR (n:NPC) REQUIRE n.id IS UNIQUE;
|
||||
|
||||
(:NPC {
|
||||
id: String, // e.g. "miriam-vendor-mardonar"
|
||||
name: String,
|
||||
personaSummary: String,
|
||||
memoryFacts: [String], // append-only list of memory bullets
|
||||
lastSeenEncounter: String
|
||||
})
|
||||
|
||||
// Encounter node
|
||||
(:Encounter {
|
||||
id: String,
|
||||
title: String,
|
||||
resolved: Boolean,
|
||||
outcomeId: String?,
|
||||
createdAt: DateTime
|
||||
})
|
||||
|
||||
// Event node — append-only log
|
||||
(:EncounterEvent {
|
||||
timestamp: DateTime,
|
||||
type: String, // 'player_action', 'skill_check', 'outcome', etc.
|
||||
description: String
|
||||
})
|
||||
|
||||
// Player node
|
||||
(:Player {
|
||||
discordId: String,
|
||||
dndName: String
|
||||
})
|
||||
|
||||
// Relationships
|
||||
(:NPC)-[:APPEARED_IN]->(:Encounter)
|
||||
(:Encounter)-[:HAS_EVENT]->(:EncounterEvent)
|
||||
(:Player)-[:PARTICIPATED_IN]->(:Encounter)
|
||||
```
|
||||
|
||||
### Neo4j Client
|
||||
```typescript
|
||||
// src/db/neo4j.ts
|
||||
import neo4j from 'neo4j-driver';
|
||||
import { config } from '../config.js';
|
||||
|
||||
export const driver = neo4j.driver(
|
||||
config.NEO4J_URI,
|
||||
neo4j.auth.basic(config.NEO4J_USER, config.NEO4J_PASSWORD)
|
||||
);
|
||||
|
||||
export async function runQuery<T>(
|
||||
cypher: string,
|
||||
params: Record<string, unknown> = {}
|
||||
): Promise<T[]> {
|
||||
const session = driver.session();
|
||||
try {
|
||||
const result = await session.run(cypher, params);
|
||||
return result.records.map(r => r.toObject() as T);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MCP Tool Definitions
|
||||
|
||||
```typescript
|
||||
// src/mcp/tools/skillCheckEmit.ts
|
||||
export const skillCheckEmit = {
|
||||
name: 'skill_check_emit',
|
||||
description: 'Post a skill check prompt to the Discord thread for a specific player',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
player: { type: 'string', description: 'The DnD character name being asked to roll' },
|
||||
prompt: { type: 'string', description: 'The skill check question (e.g. "What skill to chase Dal?")' },
|
||||
dc: { type: 'number', description: 'The Difficulty Class for this check' },
|
||||
},
|
||||
required: ['player', 'prompt', 'dc'],
|
||||
},
|
||||
};
|
||||
|
||||
// src/mcp/tools/eventLogAppend.ts
|
||||
export const eventLogAppend = {
|
||||
name: 'event_log_append',
|
||||
description: 'Append a story event to the encounter event log in Neo4j',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string' },
|
||||
eventType: { type: 'string', enum: ['player_action', 'skill_check', 'npc_action', 'outcome', 'sportsmanship'] },
|
||||
description: { type: 'string' },
|
||||
},
|
||||
required: ['sessionId', 'eventType', 'description'],
|
||||
},
|
||||
};
|
||||
|
||||
// src/mcp/tools/npcMemoryWrite.ts
|
||||
export const npcMemoryWrite = {
|
||||
name: 'npc_memory_write',
|
||||
description: 'Append a memory fact to an NPC after the encounter',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
npcId: { type: 'string' },
|
||||
memoryFact: { type: 'string', description: 'One sentence describing what the NPC learned or experienced' },
|
||||
},
|
||||
required: ['npcId', 'memoryFact'],
|
||||
},
|
||||
};
|
||||
|
||||
// src/mcp/tools/encounterResolve.ts
|
||||
export const encounterResolve = {
|
||||
name: 'encounter_resolve',
|
||||
description: 'Mark the encounter as complete with a specific outcome',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string' },
|
||||
outcomeId: { type: 'string', description: 'The goal ID that was reached (e.g. "catch", "escape")' },
|
||||
summary: { type: 'string', description: 'A one-sentence summary of how the encounter resolved' },
|
||||
},
|
||||
required: ['sessionId', 'outcomeId', 'summary'],
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Tool Dispatcher (harness side)
|
||||
|
||||
```typescript
|
||||
// src/harness/toolDispatcher.ts
|
||||
|
||||
export type ToolContext = {
|
||||
session: SessionState;
|
||||
thread: TextChannel | ThreadChannel; // discord.js channel reference
|
||||
};
|
||||
|
||||
export async function dispatchTool(block: ToolCallBlock, ctx: ToolContext): Promise<string> {
|
||||
switch (block.tool) {
|
||||
case 'skill_check_emit':
|
||||
return await handleSkillCheckEmit(block.args, ctx);
|
||||
case 'event_log_append':
|
||||
return await handleEventLogAppend(block.args, ctx);
|
||||
case 'npc_memory_write':
|
||||
return await handleNpcMemoryWrite(block.args, ctx);
|
||||
case 'encounter_resolve':
|
||||
return await handleEncounterResolve(block.args, ctx);
|
||||
default:
|
||||
return `Unknown tool: ${block.tool}`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Skill check embed:**
|
||||
```typescript
|
||||
async function handleSkillCheckEmit(args, ctx) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`🎲 Skill Check — ${args.player}`)
|
||||
.setDescription(args.prompt)
|
||||
.addFields({ name: 'DC', value: String(args.dc), inline: true })
|
||||
.setColor(0xf4c430)
|
||||
.setFooter({ text: 'Reply with: "I rolled a [number] [Skill Name]"' });
|
||||
|
||||
await ctx.thread.send({ embeds: [embed] });
|
||||
return `Skill check emitted for ${args.player} (DC ${args.dc})`;
|
||||
}
|
||||
```
|
||||
|
||||
The bot also listens for messages matching `"I rolled a [number] [skill]"` and injects the result back into the session history as a `[SYSTEM]` message before the next LLM call.
|
||||
|
||||
### Phase 3 Test Plan
|
||||
|
||||
**Unit tests**
|
||||
|
||||
`tests/unit/toolParser.test.ts` (extends Phase 2)
|
||||
- Each tool name dispatches to the correct handler (mock handlers)
|
||||
- Unknown tool name returns error string without throwing
|
||||
|
||||
`tests/unit/skillCheckEmbed.test.ts`
|
||||
- `handleSkillCheckEmit` with valid args produces a `MessagePayload` with correct embed fields
|
||||
- Test using a mock discord.js channel that captures sent messages
|
||||
|
||||
**Integration tests**
|
||||
|
||||
`tests/integration/neo4j.test.ts`
|
||||
- Requires live Neo4j test instance (recommend Docker Compose service)
|
||||
- Append event → query it back
|
||||
- Write NPC memory fact → read NPC, confirm fact appended
|
||||
- `encounterResolve` → marks encounter node as resolved
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Full Encounter Lifecycle
|
||||
|
||||
**Goal:** Complete end-to-end encounter flow. All phases integrated. Encounter can start, play through, resolve, and write NPC memories.
|
||||
|
||||
### Final Wiring
|
||||
|
||||
**Session message router** — the main event loop for an encounter thread:
|
||||
|
||||
```
|
||||
messageCreate event fires in encounter thread
|
||||
│
|
||||
├── Is discordUser in playerRegistry?
|
||||
│ └── No → send ephemeral embed, hold message, return
|
||||
│
|
||||
├── Is session in phase 'open' and player not yet in session.players?
|
||||
│ └── Yes → add player to session.players, inject welcome into history
|
||||
│
|
||||
├── Append user message to history (role: 'user', content: "${player.dndName}: ${message.content}")
|
||||
│
|
||||
├── Assemble context (contextAssembler.assembleContext)
|
||||
├── Call Ollama (ollamaClient.callLLM)
|
||||
├── Parse response (toolParser.parseToolCall)
|
||||
│
|
||||
├── Post narrative to thread
|
||||
│
|
||||
├── If toolCall present:
|
||||
│ ├── Dispatch tool (toolDispatcher.dispatchTool)
|
||||
│ ├── Inject tool result into history as system message
|
||||
│ └── If tool is 'encounter_resolve': set session.phase = 'resolved', post resolution embed, archive thread
|
||||
│
|
||||
└── Append assistant message + save session state
|
||||
```
|
||||
|
||||
**Resolution embed:**
|
||||
```typescript
|
||||
const resolutionEmbed = new EmbedBuilder()
|
||||
.setTitle(`⚔️ Encounter Complete — ${spec.title}`)
|
||||
.setDescription(summary)
|
||||
.addFields(
|
||||
{ name: 'Outcome', value: outcomeId, inline: true },
|
||||
{ name: 'Participants', value: Object.values(session.players).map(p => p.dndName).join(', '), inline: true }
|
||||
)
|
||||
.setColor(0x2ecc71);
|
||||
```
|
||||
|
||||
### Phase 4 Test Plan
|
||||
|
||||
**End-to-end test**
|
||||
|
||||
`tests/integration/encounterE2E.test.ts`
|
||||
|
||||
This test runs the entire encounter engine against a live Ollama + Redis + Neo4j stack (no Discord — Discord is mocked).
|
||||
|
||||
```typescript
|
||||
// Setup: mock Discord thread that captures messages
|
||||
// Load market-thief.yaml spec
|
||||
// Initialize session
|
||||
// Simulate player messages:
|
||||
// - "Aelindra intervenes! She steps in front of Dal."
|
||||
// - "I rolled a 14 Acrobatics"
|
||||
// - "Aelindra grabs Dal by the collar."
|
||||
// Assertions:
|
||||
// - Narrative messages are non-empty strings
|
||||
// - At least one skill_check_emit was called
|
||||
// - At least one event_log_append was called
|
||||
// - Encounter eventually resolves with a known outcomeId
|
||||
// - Neo4j has an Encounter node with resolved=true
|
||||
```
|
||||
|
||||
**Regression checklist (manual)**
|
||||
|
||||
Run this manually before each deploy:
|
||||
|
||||
- [ ] `/dndname set Aelindra` → bot responds ephemerally
|
||||
- [ ] `/encounter start market-thief` → thread created with opening narrative
|
||||
- [ ] Posting from unregistered user → ephemeral embed gate fires
|
||||
- [ ] Posting player action → LLM responds with narrative
|
||||
- [ ] LLM emits skill_check_emit → embed appears in thread
|
||||
- [ ] Player posts roll result → LLM resolves it narratively
|
||||
- [ ] LLM reaches a goal → resolution embed posted, thread locked
|
||||
- [ ] Neo4j has the event log and NPC memories written
|
||||
|
||||
---
|
||||
|
||||
## Docker Compose (local dev)
|
||||
|
||||
```yaml
|
||||
# docker-compose.dev.yml
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports: ["6379:6379"]
|
||||
|
||||
neo4j:
|
||||
image: neo4j:5
|
||||
ports: ["7474:7474", "7687:7687"]
|
||||
environment:
|
||||
NEO4J_AUTH: neo4j/testpassword
|
||||
NEO4J_PLUGINS: '["apoc"]'
|
||||
volumes:
|
||||
- neo4j_data:/data
|
||||
|
||||
volumes:
|
||||
neo4j_data:
|
||||
```
|
||||
|
||||
Ollama runs on your existing lab node and is accessed over the network via `OLLAMA_BASE_URL`.
|
||||
|
||||
---
|
||||
|
||||
## Build Order Summary
|
||||
|
||||
| Phase | What you can test when done |
|
||||
|---|---|
|
||||
| 1 | `/dndname` commands, spec loading, thread creation |
|
||||
| 2 | Full LLM narration in a Discord thread, no tools |
|
||||
| 3 | Skill check embeds, Neo4j event logs, NPC memory |
|
||||
| 4 | Complete encounter: open → active → resolved |
|
||||
|
||||
---
|
||||
|
||||
*Document version: 0.2 — TypeScript phased build plan*
|
||||
*Context: Mardonar Encounter Engine — discord.js / Ollama / MCP / Neo4j stack*
|
||||
375
Docs/mardonar-encounter-engine.md
Normal file
375
Docs/mardonar-encounter-engine.md
Normal file
@@ -0,0 +1,375 @@
|
||||
# Mardonar Encounter Engine — System Architecture
|
||||
|
||||
> **Target:** Discord-native, LLM-driven D&D encounter system
|
||||
> **Model:** `gemma4-it:e2b` via local Ollama (128k context window)
|
||||
> **Philosophy:** Context is the source of truth. The harness controls everything the LLM sees.
|
||||
|
||||
---
|
||||
|
||||
## 1. System Overview
|
||||
|
||||
```
|
||||
Discord Bot (Go)
|
||||
│
|
||||
├── /dndname command → Player Registry (Redis KV: discordID → dndName)
|
||||
├── Encounter Trigger (command or DM-initiated)
|
||||
│ └── Story Generator (one-shot LLM call → EncounterSpec YAML)
|
||||
│
|
||||
└── Thread Session Manager
|
||||
├── Session Store (Redis: threadID → SessionState)
|
||||
├── Message Router → LLM Harness
|
||||
└── Discord Embed Generator (skill check UI, player prompts)
|
||||
|
||||
LLM Harness (embedded in bot or sidecar Go service)
|
||||
├── Context Assembler
|
||||
│ ├── System Prompt Builder
|
||||
│ ├── NPC Loader (via MCP → Neo4j)
|
||||
│ └── History Manager (sliding window + trim)
|
||||
├── Ollama Client (gemma4-it:e2b)
|
||||
├── Tool Dispatcher (JSON-block parsing, NOT native function calls)
|
||||
└── Response Router → Discord
|
||||
|
||||
MCP Tool Layer (thin Go HTTP wrapper over Neo4j)
|
||||
├── npc_read / npc_write
|
||||
├── encounter_state_read / encounter_state_write
|
||||
├── event_log_append
|
||||
└── skill_check_emit / skill_check_resolve
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Player Registry
|
||||
|
||||
This is deliberately dead simple. No database — just a Redis hash.
|
||||
|
||||
```
|
||||
HSET players:{guildID} {discordUserID} {dndCharacterName}
|
||||
```
|
||||
|
||||
### Discord Commands
|
||||
|
||||
| Command | Effect |
|
||||
|---|---|
|
||||
| `/dndname set <name>` | Registers or updates the player's D&D name |
|
||||
| `/dndname show` | Echoes their current registered name |
|
||||
| `/dndname clear` | Removes them from the registry |
|
||||
|
||||
### Encounter Entry Gate
|
||||
|
||||
When a user posts their **first message** in an encounter thread:
|
||||
|
||||
1. Bot checks `HGET players:{guildID} {discordUserID}`
|
||||
2. If missing → bot sends a **Discord embed** (not a plain message) asking them to run `/dndname set <name>` before continuing
|
||||
3. Bot marks their message as "held" in session state
|
||||
4. On successful `/dndname set`, bot reprocesses the held message and inserts it into the session as if they had just sent it
|
||||
|
||||
The embed should be ephemeral (only visible to the triggering user) so it doesn't clutter the thread.
|
||||
|
||||
---
|
||||
|
||||
## 3. Encounter Spec — The Blueprint
|
||||
|
||||
Before a thread is created, the system generates an `EncounterSpec`. This is a structured YAML document that feeds the LLM's system prompt. It is authored once at encounter creation and stored in the session.
|
||||
|
||||
```yaml
|
||||
# encounter-market-thief.yaml
|
||||
encounter_id: "mardonar-market-thief-001"
|
||||
title: "The Market Square Thief"
|
||||
setting:
|
||||
location: "City of Mardonar — Market Square Food Festival"
|
||||
mood: "Lively, crowded, midday sun. Smell of roasting meat and fresh bread."
|
||||
ambient_npcs: "A dozen festival-goers, two city guard stationed far off."
|
||||
|
||||
opening_narrative: |
|
||||
As you wander the Market Square during the Food Festival, a flash of movement
|
||||
catches your eye. A young figure — hood low, moving fast — snatches a bright
|
||||
red apple from Miriam's stand. The vendor turns just in time. "THIEF!" her
|
||||
voice cuts through the crowd.
|
||||
Would anyone intervene?
|
||||
|
||||
npcs:
|
||||
- id: "miriam-vendor"
|
||||
name: "Miriam"
|
||||
role: "Apple stand vendor"
|
||||
persona: "Stout, red-faced, short-tempered Dwarf woman. Has run this stall for
|
||||
20 years. She will loudly berate anyone who does nothing. She is not a fighter."
|
||||
memory_key: "miriam-vendor-mardonar" # Neo4j node key
|
||||
- id: "dal-thief"
|
||||
name: "Dal"
|
||||
role: "Pickpocket"
|
||||
persona: "A teenage Half-Elf, gaunt and scared. Steals to survive. Not violent.
|
||||
Will beg if cornered. Will bolt if given any opening."
|
||||
memory_key: "dal-thief-mardonar"
|
||||
|
||||
goals:
|
||||
hidden: true # never shown to players in Discord
|
||||
primary:
|
||||
- id: "catch"
|
||||
label: "Players physically catch or restrain Dal"
|
||||
- id: "kill"
|
||||
label: "Players kill Dal (check sportsmanship before allowing)"
|
||||
- id: "bystander_chase"
|
||||
label: "Players successfully convince a bystander to give chase"
|
||||
secondary:
|
||||
- id: "escape"
|
||||
label: "Dal escapes with the apple into the crowd"
|
||||
- id: "negotiate"
|
||||
label: "Dal surrenders or agrees to work off the debt"
|
||||
|
||||
sportsmanship_rules:
|
||||
- "No one-hit kills on helpless, non-threatening NPCs without narrative setup"
|
||||
- "No abilities or spells the player has not established in a prior scene"
|
||||
- "No controlling another player's character"
|
||||
|
||||
skill_checks:
|
||||
chase_dc: 13
|
||||
persuade_bystander_dc: 12
|
||||
spot_hiding_dc: 10
|
||||
intimidate_dal_dc: 8
|
||||
note: "Player picks the skill. DM asks for the roll. Player reports their result."
|
||||
```
|
||||
|
||||
### Story Generator
|
||||
|
||||
The `EncounterSpec` can be:
|
||||
|
||||
- **Templated** (DM fills in a YAML template manually for important encounters)
|
||||
- **LLM-generated** (a one-shot call to a larger model — Claude Sonnet via API — given a brief prompt like `"generate an encounter in the City of Mardonar food festival, street level, involving theft"`)
|
||||
|
||||
For now, treat the generator as a separate CLI tool or admin command (`/encounter generate <brief>`). It outputs the YAML, a DM reviews it, then triggers it.
|
||||
|
||||
---
|
||||
|
||||
## 4. Context Window Blueprint
|
||||
|
||||
The 128k window is divided into hard zones. The harness is responsible for enforcing these budgets.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ SYSTEM PROMPT BLOCK ~4,000 tokens │
|
||||
│ ├── Narrator persona + rules │
|
||||
│ ├── Sportsmanship enforcement rules │
|
||||
│ ├── Tool call format contract │
|
||||
│ └── Active NPC personas (1–3 × ~600 tok each) │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ ENCOUNTER BLOCK ~1,500 tokens │
|
||||
│ ├── Setting, mood, opening narrative │
|
||||
│ └── Hidden goals list │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ TOOL DEFINITIONS ~1,000 tokens │
|
||||
│ └── JSON schema for each callable tool │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ CONVERSATION HISTORY ~118,000 tokens │
|
||||
│ ├── [PINNED] Opening narrative message (never trim) │
|
||||
│ ├── [PINNED] Goal block (never trim) │
|
||||
│ └── [SLIDING] Player + LLM turns (oldest drop off) │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ SAFETY BUFFER ~3,500 tokens │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**History trimming strategy:**
|
||||
- Count tokens before every inference call
|
||||
- If `history_tokens > 115,000`: drop the oldest non-pinned turn pair (user + assistant)
|
||||
- Never trim the first 3 turns (establishes scene in context)
|
||||
- If history is still oversized after trimming, trigger a **mid-session summary**: one LLM call condenses the oldest 20 turns into a `[SUMMARY]` block inserted at that position
|
||||
|
||||
---
|
||||
|
||||
## 5. System Prompt Structure
|
||||
|
||||
This is the most important thing you control. The LLM's behavior is almost entirely determined by how well this is written. Structure it with explicit XML-style sections since Gemma4-IT responds well to that.
|
||||
|
||||
```
|
||||
<narrator_identity>
|
||||
You are the Dungeon Master narrator for a D&D 5e encounter set in the Land of
|
||||
Mardonar. You speak as an omniscient narrator and voice each NPC distinctly.
|
||||
You are guiding the encounter toward specific outcomes without the players knowing.
|
||||
Never reveal the goal list. Never break the fourth wall unless calling out bad
|
||||
sportsmanship.
|
||||
</narrator_identity>
|
||||
|
||||
<sportsmanship>
|
||||
If a player attempts something unrealistic, physically impossible, or grossly
|
||||
unfair (e.g., instant-killing a helpless child NPC with no prior combat
|
||||
engagement), respond in-character but steer away, OR break character with:
|
||||
"⚠️ That wasn't great sportsmanship. Let's keep it grounded — what would your
|
||||
character realistically attempt here?"
|
||||
</sportsmanship>
|
||||
|
||||
<npcs>
|
||||
<npc id="miriam-vendor">
|
||||
Name: Miriam | Role: Apple vendor
|
||||
[persona text from spec]
|
||||
Memory: [loaded from Neo4j via MCP at session start]
|
||||
</npc>
|
||||
<npc id="dal-thief">
|
||||
Name: Dal | Role: Pickpocket
|
||||
[persona text from spec]
|
||||
Memory: [loaded from Neo4j via MCP at session start]
|
||||
</npc>
|
||||
</npcs>
|
||||
|
||||
<encounter>
|
||||
Setting: [from spec]
|
||||
Scene: [opening narrative]
|
||||
</encounter>
|
||||
|
||||
<hidden_goals>
|
||||
You are steering the story toward one of these outcomes. Do not state these to
|
||||
players. Reward clever play. Gently redirect if the scene drifts far off course.
|
||||
- Catch Dal
|
||||
- Kill Dal (only allow after dramatic escalation, check sportsmanship)
|
||||
- Convince bystander to give chase
|
||||
Secondary: Escape, Negotiation
|
||||
</hidden_goals>
|
||||
|
||||
<tools>
|
||||
When you need to emit a skill check, log an event, or update NPC memory,
|
||||
output a JSON block in this exact format at the END of your message, after
|
||||
your narrative text:
|
||||
|
||||
```tool_call
|
||||
{
|
||||
"tool": "skill_check_emit",
|
||||
"args": {
|
||||
"player": "Aelindra",
|
||||
"prompt": "What skill are you using to chase Dal?",
|
||||
"dc": 13
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Available tools: [schema list]
|
||||
</tools>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Tool Layer
|
||||
|
||||
These are the only tools exposed to the LLM. Keep them minimal.
|
||||
|
||||
### Tool Manifest
|
||||
|
||||
| Tool | Purpose | Args |
|
||||
|---|---|---|
|
||||
| `skill_check_emit` | Posts a skill check prompt to the thread | `player, prompt, dc` |
|
||||
| `skill_check_resolve` | Logs the outcome of a roll | `player, skill, roll, modifier, dc, success` |
|
||||
| `event_log_append` | Appends a story event to Neo4j | `session_id, event_type, description` |
|
||||
| `npc_memory_read` | Reads NPC memory from graph | `npc_id` |
|
||||
| `npc_memory_write` | Updates NPC memory after encounter | `npc_id, memory_delta` |
|
||||
| `encounter_resolve` | Marks the encounter complete with outcome | `session_id, outcome_id` |
|
||||
|
||||
### Tool Dispatch (Prompt-Based, Not Native)
|
||||
|
||||
Because `gemma4-it:e2b` is a small model and native tool calling is unreliable at this scale, the harness uses **structured JSON parsing** instead:
|
||||
|
||||
1. After each LLM response, harness scans for a ` ```tool_call ``` ` block
|
||||
2. If found, it strips it from the visible Discord message, parses the JSON, and dispatches the tool
|
||||
3. Tool result is injected back into history as a `[SYSTEM]` turn: `"Tool skill_check_emit executed: posted to thread"`
|
||||
4. The Discord-facing message only contains the narrative text before the tool block
|
||||
|
||||
This means the LLM's output is always: `narrative text` + optionally `tool_call block`.
|
||||
|
||||
### Neo4j Schema for MCP Tools
|
||||
|
||||
```
|
||||
(:NPC {id, name, persona_summary, memory: [], last_seen_encounter})
|
||||
-[:APPEARED_IN]->
|
||||
(:Encounter {id, title, resolved, outcome_id, created_at})
|
||||
-[:HAS_EVENT]->
|
||||
(:EncounterEvent {timestamp, type, description})
|
||||
|
||||
(:Player {discord_id, dnd_name})
|
||||
-[:PARTICIPATED_IN]->
|
||||
(:Encounter)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Session Lifecycle
|
||||
|
||||
```
|
||||
Phase 0: Trigger
|
||||
DM runs: /encounter start <spec_file>
|
||||
Bot creates Discord thread
|
||||
Session initialized in Redis:
|
||||
threadID → { spec, history: [], phase: "open", players: {} }
|
||||
|
||||
Phase 1: Player Entry
|
||||
First message from unknown user → ephemeral embed → /dndname set
|
||||
Once registered: message is replayed into session, player added to session.players
|
||||
|
||||
Phase 2: Active Encounter
|
||||
Each message → bot appends to history → calls LLM Harness
|
||||
Harness assembles context → calls Ollama → parses response
|
||||
Narrative text → posted to thread
|
||||
Tool blocks → dispatched silently, result injected to history
|
||||
Skill check emitted → Discord embed with "Roll your {skill}! Post your result."
|
||||
|
||||
Phase 3: Skill Check Resolution
|
||||
Player replies with roll (e.g., "I rolled a 17 Acrobatics")
|
||||
Bot parses this, calls skill_check_resolve tool, logs to Neo4j
|
||||
LLM receives result, continues narrative ("You close the gap — Dal is cornered...")
|
||||
|
||||
Phase 4: Resolution
|
||||
LLM calls encounter_resolve when an ending is reached
|
||||
Bot posts a final embed: encounter summary, outcome label
|
||||
Thread is archived (or locked by bot)
|
||||
NPC memories written to Neo4j via npc_memory_write
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Discord Bot Command Surface
|
||||
|
||||
| Command | Who | What |
|
||||
|---|---|---|
|
||||
| `/dndname set <name>` | Player | Register/update D&D character name |
|
||||
| `/dndname show` | Player | Show their current name |
|
||||
| `/encounter start <spec>` | DM/Admin | Load spec YAML, create thread, begin session |
|
||||
| `/encounter generate <brief>` | DM/Admin | LLM-generate a spec from a short description |
|
||||
| `/encounter status` | DM | Show current phase, player list, event count |
|
||||
| `/encounter end` | DM | Force-resolve encounter (admin override) |
|
||||
|
||||
---
|
||||
|
||||
## 9. What's Explicitly Out of Scope (for Now)
|
||||
|
||||
- Rewards / item grants (MCP hook is stubbed, logic deferred)
|
||||
- Character sheets / stat tracking (player reports their own modifier)
|
||||
- Multi-encounter campaigns (session is self-contained; Neo4j provides the through-line)
|
||||
- Parallel encounters (one active per channel for now)
|
||||
- Full model swaps (the context structure is model-agnostic; swapping Ollama model is a config change)
|
||||
|
||||
---
|
||||
|
||||
## 10. Key Design Decisions & Rationale
|
||||
|
||||
| Decision | Rationale |
|
||||
|---|---|
|
||||
| Prompt-based tool calls (not native) | Gemma4-IT at e2b quantization is not reliable for native function calling |
|
||||
| Goals in system prompt, not MCP | Reduces tool round-trips on every turn; goals rarely change mid-encounter |
|
||||
| Redis for session state, Neo4j for persistence | Redis is ephemeral and fast for active session; Neo4j for long-term NPC memory and campaign history |
|
||||
| Player name gate via embed | Embeds are ephemeral and un-cluttering; name registry is the identity layer for the whole system |
|
||||
| Pinned history turns | Opening narrative + goal block must survive trimming or the LLM loses its anchor |
|
||||
| Story generator as separate call | Separates creative authoring (can use a stronger model) from real-time inference (must be fast + cheap) |
|
||||
|
||||
---
|
||||
|
||||
## Next Steps (Ordered)
|
||||
|
||||
1. **Stand up the Discord bot skeleton** — command handler, thread creation, player registry in Redis
|
||||
2. **Build the context assembler** — system prompt template, NPC loader, token counter
|
||||
3. **Wire Ollama client** — simple POST to `/api/chat` with assembled context
|
||||
4. **Implement prompt-based tool parser** — scan for `tool_call` blocks, dispatch to MCP
|
||||
5. **Build MCP tool layer** — thin Go HTTP service wrapping Neo4j queries
|
||||
6. **Write first EncounterSpec YAML** — the Market Thief scenario
|
||||
7. **End-to-end test** — one full encounter, 2 players, measure token usage
|
||||
8. **Tune system prompt** — iterate based on LLM behavior at e2b quantization
|
||||
|
||||
---
|
||||
|
||||
*Document version: 0.1 — Initial architecture*
|
||||
*Context: Mardonar Encounter Engine design session*
|
||||
140
Docs/market-thief.yaml
Normal file
140
Docs/market-thief.yaml
Normal file
@@ -0,0 +1,140 @@
|
||||
encounterId: "mardonar-market-thief-001"
|
||||
title: "The Market Square Thief"
|
||||
|
||||
setting:
|
||||
location: "City of Mardonar — Market Square Food Festival"
|
||||
mood: >
|
||||
Midday sun beats down on a lively crowd. The air smells of roasting meat,
|
||||
fresh bread, and spiced cider. Merchants shout their wares. Children weave
|
||||
through legs. Two city guards are visible at the far end of the square,
|
||||
too far to respond quickly.
|
||||
ambientNpcs: >
|
||||
A dozen festival-goers milling about. A juggler performing near the fountain.
|
||||
A heavyset merchant arguing with a customer two stalls down. An elderly couple
|
||||
sharing a meat pie on a bench.
|
||||
|
||||
openingNarrative: >
|
||||
The food festival fills Market Square with color and noise. Stalls stretch in
|
||||
every direction — honeyed nuts, smoked fish, fresh-pressed cider, towers of
|
||||
bread. As you wander, a flash of movement catches your eye near Miriam's apple
|
||||
stand. A young hooded figure — moving fast, head low — snatches a bright red
|
||||
apple and turns to bolt. Miriam spins around just in time, her face going
|
||||
scarlet. "THIEF!" Her voice cuts through the crowd like a blade.
|
||||
The festival-goers nearest her freeze and stare. Would anyone intervene?
|
||||
|
||||
npcs:
|
||||
- id: "miriam-vendor-mardonar"
|
||||
name: "Miriam"
|
||||
role: "Apple stand vendor"
|
||||
persona: >
|
||||
Stout, red-faced Dwarf woman in her sixties. Has run this stall for twenty
|
||||
years and takes every theft as a personal insult. She is loud, indignant,
|
||||
and will berate anyone nearby who does nothing. She is NOT a fighter and
|
||||
will not give chase herself — her knees are bad. She will, however, loudly
|
||||
demand that someone else do something. If the thief is caught and returned,
|
||||
she will calm down and may show grudging gratitude. If the thief escapes,
|
||||
she will mutter darkly about the state of the city and the uselessness of
|
||||
bystanders. She refers to the apple as "my finest Crimson Bellflower, worth
|
||||
three silvers if it's worth a copper."
|
||||
memoryKey: "miriam-vendor-mardonar"
|
||||
|
||||
- id: "dal-thief-mardonar"
|
||||
name: "Dal"
|
||||
role: "Pickpocket"
|
||||
persona: >
|
||||
A teenage Half-Elf, maybe fifteen, gaunt and hollow-eyed beneath a patched
|
||||
brown hood. He steals to survive — there is no malice in it, only hunger.
|
||||
He is fast but not trained in combat. He will bolt immediately if given any
|
||||
opening. If cornered with no escape, he will freeze, then beg — voice
|
||||
cracking, eyes wide. He is not lying about being hungry. He will not fight
|
||||
back unless physically grabbed and even then only flails. If treated with
|
||||
any kindness, he becomes confused and cooperative. He has a small knife on
|
||||
him but has never used it on a person.
|
||||
memoryKey: "dal-thief-mardonar"
|
||||
|
||||
goals:
|
||||
hidden: true
|
||||
primary:
|
||||
- id: "catch"
|
||||
label: >
|
||||
Players physically catch or restrain Dal — tackle, grab, spell, or block
|
||||
his escape route so he cannot run.
|
||||
- id: "kill"
|
||||
label: >
|
||||
Players kill Dal. Only allow this after dramatic escalation — Dal must
|
||||
have drawn his knife or threatened someone first. Apply sportsmanship
|
||||
check before resolving. This is a valid but dark outcome.
|
||||
- id: "bystander_chase"
|
||||
label: >
|
||||
Players successfully persuade or inspire a bystander to give chase
|
||||
(Persuasion or Intimidation DC 12). The juggler is the most likely
|
||||
candidate — young, fit, bored. The heavyset merchant will refuse. The
|
||||
elderly couple will not move.
|
||||
- id: "negotiate"
|
||||
label: >
|
||||
Players talk Dal down before he runs, or corner him and offer him
|
||||
something (food, coin, mercy) that causes him to stop and surrender
|
||||
voluntarily. Requires him to be cornered or slowed first.
|
||||
|
||||
secondary:
|
||||
- id: "escape"
|
||||
label: >
|
||||
Dal escapes into the crowd with the apple. Miriam is furious. The
|
||||
encounter ends with no reward. Dal disappears into an alley. This is a
|
||||
valid outcome — not every encounter ends in success.
|
||||
- id: "guards_summoned"
|
||||
label: >
|
||||
Players alert the city guards at the far end of the square. Guards take
|
||||
1d4 rounds to arrive. By then Dal will likely have escaped unless players
|
||||
slowed him. If guards arrive and Dal is caught, players receive no reward
|
||||
but the city notes their cooperation.
|
||||
|
||||
sportsmanshipRules:
|
||||
- "No instant kills on a non-threatening, unarmed teenager without prior escalation."
|
||||
- "No controlling another player character's actions or speaking for them."
|
||||
- "No spells or abilities the player has not established owning in a prior scene."
|
||||
- "No claiming information the character could not realistically know (Dal's name, history, etc.)."
|
||||
- "No teleportation or flight without prior narrative establishment."
|
||||
- >
|
||||
If a player attempts something absurd or game-breaking, respond in-character
|
||||
to redirect, or break character with:
|
||||
"⚠️ That wasn't great sportsmanship. Let's keep it grounded — what would
|
||||
your character realistically attempt here?"
|
||||
|
||||
skillChecks:
|
||||
chase_dc: 13
|
||||
chase_skill: "Athletics or Acrobatics (player's choice)"
|
||||
chase_note: >
|
||||
Dal is fast but panicked. A successful check closes the gap. Failure means
|
||||
he gains distance. Two consecutive failures and he vanishes into the crowd.
|
||||
|
||||
persuade_bystander_dc: 12
|
||||
persuade_bystander_skill: "Persuasion or Intimidation"
|
||||
persuade_bystander_note: >
|
||||
Targeting the juggler gives advantage (he's already watching with interest).
|
||||
Targeting the merchant gives disadvantage (he's busy and dismissive).
|
||||
|
||||
spot_hiding_dc: 10
|
||||
spot_hiding_skill: "Perception"
|
||||
spot_hiding_note: >
|
||||
If Dal ducks into a stall or behind a crowd. Success reveals his hiding spot.
|
||||
|
||||
intimidate_dal_dc: 8
|
||||
intimidate_dal_skill: "Intimidation"
|
||||
intimidate_dal_note: >
|
||||
If Dal is cornered. Success causes him to drop the apple and freeze.
|
||||
Failure causes him to lash out with a shove and run.
|
||||
|
||||
persuade_dal_dc: 10
|
||||
persuade_dal_skill: "Persuasion"
|
||||
persuade_dal_note: >
|
||||
If a player offers Dal food, coin, or genuine kindness while he is cornered.
|
||||
Success causes him to surrender and explain his situation.
|
||||
|
||||
dmNotes: >
|
||||
This encounter is intentionally low-stakes — a warm-up scene in a public
|
||||
setting with no combat required. The goal is to establish player character
|
||||
personalities and how they interact with a morally simple situation (hungry
|
||||
kid steals food). There is no "correct" outcome. Lean into the crowd's
|
||||
reactions. If players hesitate, have Miriam single one of them out directly.
|
||||
Dal should feel like a person, not a target.
|
||||
102
Docs/stories/story-1.1-reaction-state-machine.md
Normal file
102
Docs/stories/story-1.1-reaction-state-machine.md
Normal file
@@ -0,0 +1,102 @@
|
||||
---
|
||||
baseline_commit: NO_VCS
|
||||
status: review
|
||||
---
|
||||
|
||||
# Story 1.1: Emoji Reaction State Machine
|
||||
|
||||
**Epic:** 1 — Interaction Feedback & Queue Management
|
||||
**Status:** in-progress
|
||||
|
||||
## Story
|
||||
|
||||
As a player,
|
||||
I want emoji reactions on my messages to show whether they were received and are being processed,
|
||||
So that I always know the bot saw my message and what it is doing with it.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC1:** Given a player sends a message in an active encounter thread, when the message is received by the bot's message handler, the bot adds a 👀 reaction to that message (fire-and-forget, within the same message-create handler path).
|
||||
|
||||
**AC2:** Given the player's message is scheduled for LLM processing, when the LLM generation begins (inside the runner), the 👀 reaction is removed and ⏳ is added.
|
||||
|
||||
**AC3:** Given the player's message content contains a roll-related keyword (roll, attack, check, save, saving throw, d20), when ⏳ is added, 🎲 is also added alongside it.
|
||||
|
||||
**AC4:** Given the LLM response has been posted, when the generation runner's try block completes, ⏳ and 🎲 (if present) are removed and ✅ is added; ✅ is removed ~10 seconds later.
|
||||
|
||||
**AC5:** Given the LLM call fails or throws, when the finally block executes, all in-progress reactions (⏳, 🎲) are removed. No stale state left.
|
||||
|
||||
**AC6:** Reactions are applied only to player messages. Bot messages are never reacted to by the bot.
|
||||
|
||||
**AC7:** All Discord reaction calls are fire-and-forget (errors caught and swallowed) — they must never cause the message handler to throw or block.
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [x] Task 1: Create `src/bot/handlers/reactionManager.ts` with pending map and lifecycle helpers
|
||||
- [x] 1a: Write failing tests for `isDiceRelated()` and map operations (`registerScheduled`, `drainPending`, `clearPending`)
|
||||
- [x] 1b: Implement the module
|
||||
- [x] 1c: Write failing tests for `upgradeToProcessing`, `upgradeToComplete`, `cleanupReactions` with Discord mocks
|
||||
- [x] 1d: Implement lifecycle functions
|
||||
- [x] 1e: Run tests — confirm all pass
|
||||
|
||||
- [x] Task 2: Wire `handleMessage` to add 👀 immediately on receipt
|
||||
- [x] 2a: Add `message.react('👀')` fire-and-forget in `handleMessage` for active sessions
|
||||
- [x] 2b: Pass `message` (as optional `sourceMessage`) through `processEncounterMessage` to `scheduleEncounterLLMTurn`
|
||||
|
||||
- [x] Task 3: Wire `scheduleEncounterLLMTurn` to manage reaction lifecycle
|
||||
- [x] 3a: Add optional `sourceMessage?: Message` param to `scheduleEncounterLLMTurn`
|
||||
- [x] 3b: Call `reactionManager.registerScheduled(threadId, sourceMessage)` when message provided
|
||||
- [x] 3c: In runner: drain pending, `upgradeToProcessing`, run LLM, `upgradeToComplete` on success, `cleanupReactions` in finally on error
|
||||
|
||||
- [x] Task 4: Run full test suite — confirm no regressions
|
||||
|
||||
## Dev Notes
|
||||
|
||||
**Architecture:**
|
||||
- New file `src/bot/handlers/reactionManager.ts` owns the pending-message map and all Discord reaction calls
|
||||
- `messageRouter.ts` imports from it; `generationQueue.ts` stays untouched
|
||||
- Reactions are fire-and-forget throughout — NFR7 is absolute
|
||||
|
||||
**Key design decisions:**
|
||||
- 👀 is added in `handleMessage` for ALL messages in active sessions (even held/blocked) — honest "received" signal
|
||||
- Only messages that actually reach `scheduleEncounterLLMTurn` get registered in the pending map (⏳/✅ lifecycle)
|
||||
- `pendingSkillCheck` guard in the runner clears the pending map without upgrading; 👀 stays as "received but waiting for your roll"
|
||||
- `replayHeldMessages` and `rollHandler` call `scheduleEncounterLLMTurn` without a `sourceMessage` — no reactions managed (correct: these are programmatic, not player messages)
|
||||
|
||||
**Reaction removal:**
|
||||
Use `msg.reactions.cache.find(r => r.emoji.name === emoji)?.users.remove(botId).catch(() => null)` — reliable for unicode emoji added by the bot.
|
||||
|
||||
**Dice detection heuristic:**
|
||||
`/\b(?:roll|attack|check|save|saving throw|d20)\b/i` — applied to message content at ⏳-upgrade time.
|
||||
|
||||
**Files:**
|
||||
- `src/bot/handlers/reactionManager.ts` (new)
|
||||
- `src/bot/handlers/messageRouter.ts` (modify: `handleMessage`, `processEncounterMessage`, `scheduleEncounterLLMTurn`)
|
||||
- `tests/unit/reactionManager.test.ts` (new)
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Implementation Plan
|
||||
|
||||
1. Create `reactionManager.ts` with testable pure functions
|
||||
2. Wire 👀 into `handleMessage`
|
||||
3. Thread `sourceMessage` through `processEncounterMessage` → `scheduleEncounterLLMTurn`
|
||||
4. Add lifecycle management inside the runner closure
|
||||
|
||||
### Debug Log
|
||||
|
||||
No issues — all 23 tests passed on first implementation run.
|
||||
|
||||
### Completion Notes
|
||||
|
||||
`reactionManager.ts` is a pure module (no Discord imports in the module-level logic) — the pending map holds Discord `Message` objects but the lifecycle functions take `PendingEntry[]`, making them mockable in tests. All Discord API calls are fire-and-forget. `generationQueue.ts` is untouched. Full suite: 267 tests, 0 failures.
|
||||
|
||||
## File List
|
||||
|
||||
- src/bot/handlers/reactionManager.ts (new)
|
||||
- src/bot/handlers/messageRouter.ts (modified: imports, handleMessage, processEncounterMessage, scheduleEncounterLLMTurn)
|
||||
- tests/unit/reactionManager.test.ts (new)
|
||||
|
||||
## Change Log
|
||||
|
||||
- 2026-05-30: Story 1.1 implemented — emoji reaction state machine (👀 on receipt, ⏳/🎲 on processing, ✅ on complete, cleanup on error)
|
||||
108
Docs/stories/story-1.2-queue-cap-drop-notice.md
Normal file
108
Docs/stories/story-1.2-queue-cap-drop-notice.md
Normal file
@@ -0,0 +1,108 @@
|
||||
---
|
||||
baseline_commit: NO_VCS
|
||||
status: review
|
||||
---
|
||||
|
||||
# Story 1.2: Queue Cap with In-World Drop Notice
|
||||
|
||||
**Epic:** 1 — Interaction Feedback & Queue Management
|
||||
**Status:** in-progress
|
||||
|
||||
## Story
|
||||
|
||||
As a player,
|
||||
I want to be told privately when my message wasn't processed due to message volume,
|
||||
So that I know to wait and try again rather than wondering if the bot is broken.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC1:** The burst queue enforces a hard cap of 2 messages per LLM-response cycle. A third (or later) message arriving before the LLM has responded is dropped — not added to session history, not scheduled for LLM processing.
|
||||
|
||||
**AC2:** When a message is dropped by the cap, the 👀 reaction (added in Story 1.1) is removed — no ⏳ or ✅ is ever shown for dropped messages.
|
||||
|
||||
**AC3:** When a message is dropped, the player receives a DM with the in-world drop notice. If the DM fails (DMs disabled), the notice is sent as a reply in the thread and auto-deleted after 8 seconds.
|
||||
|
||||
**AC4:** The drop notice string is selected by the encounter's `tone` field (`spec.tone`). Defined keys: `grim`, `comedic`, `mysterious`, `tense`, and baseline (all other values including `undefined`). The LLM is never called to generate these strings.
|
||||
|
||||
**AC5:** The burst counter resets after each LLM turn completes (success or error), opening a new burst window.
|
||||
|
||||
**AC6:** `EncounterSpec` gains an optional `tone?: string` field in both the TypeScript interface and the Zod schema. The existing spec fixtures and tests must continue passing.
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [x] Task 1: Create `src/bot/handlers/queueCap.ts` with burst counter and drop notice logic
|
||||
- [x] 1a: Write failing tests for `isBurstCapped`, `incrementBurst`, `resetBurst`, `getDropNotice`
|
||||
- [x] 1b: Implement the module
|
||||
- [x] 1c: Run tests — confirm all pass
|
||||
|
||||
- [x] Task 2: Add `tone?: string` to `EncounterSpec` (interface + Zod schema)
|
||||
- [x] 2a: Write failing test asserting `tone` is parsed from a spec YAML fixture
|
||||
- [x] 2b: Add `tone?: z.string().optional()` to `EncounterSpecSchema` in `src/spec/loader.ts`
|
||||
- [x] 2c: Add `tone?: string` to `EncounterSpec` interface in `src/types/index.ts`
|
||||
- [x] 2d: Run tests — confirm they pass and existing spec tests don't regress
|
||||
|
||||
- [x] Task 3: Wire cap check and drop notice into `processEncounterMessage`
|
||||
- [x] 3a: Import `isBurstCapped`, `incrementBurst`, `sendDropNotice` from queueCap
|
||||
- [x] 3b: After pending-roll check and before history append: if capped, remove 👀, send notice, return
|
||||
- [x] 3c: After player gates but before history append: call `incrementBurst`
|
||||
|
||||
- [x] Task 4: Wire burst reset into `scheduleEncounterLLMTurn` runner
|
||||
- [x] 4a: Import `resetBurst` from queueCap
|
||||
- [x] 4b: Call `resetBurst(threadId)` in the finally block of the runner (after LLM turn, success or error)
|
||||
|
||||
- [x] Task 5: Run full test suite — confirm no regressions
|
||||
|
||||
## Dev Notes
|
||||
|
||||
**Burst window definition:** A "burst" is all messages received between consecutive LLM responses. Messages 1 and 2 are processed; message 3+ are dropped. The counter resets after each LLM turn fires (regardless of outcome).
|
||||
|
||||
**Cap check placement:** The cap check goes in `processEncounterMessage` AFTER the player gate and the pending-roll checks. These early-return paths do NOT count toward the burst cap (the player isn't registered, or the system is waiting for a roll). Only messages that would be appended to the LLM history count.
|
||||
|
||||
**EncounterSpec.tone:** Story 2.1 will inject `tone` into the LLM system prompt. Story 1.2 only needs it for drop notice selection. Both stories read from `spec.tone` — no `SessionState.tone` field needed here.
|
||||
|
||||
**DM delivery:** Use `sourceMessage.author.send(text)` with a catch. On failure: `sourceMessage.reply({ content: text })` + delete after 8s. Text-only, no embed needed.
|
||||
|
||||
**Drop notice strings (exact text per tone):**
|
||||
- `grim`: `*"The chaos swallowed your words before they could reach the moment. Silence yourself until the echoes clear."*`
|
||||
- `comedic`: `*"Everyone was talking at once and the universe, frankly, wasn't listening. Give it a moment."*`
|
||||
- `mysterious`: `*"Something in the fabric of this place muffled your voice. Wait. It will pass."*`
|
||||
- `tense`: `*"No time — the moment moved on without you. Hold. Wait for your opening."*`
|
||||
- baseline: `*"The echoes of the encounter could not carry all voices at once. Wait for the dust to settle before speaking again."*`
|
||||
|
||||
**Files:**
|
||||
- `src/bot/handlers/queueCap.ts` (new)
|
||||
- `src/spec/loader.ts` (modify: add `tone` to Zod schema)
|
||||
- `src/types/index.ts` (modify: add `tone?: string` to `EncounterSpec`)
|
||||
- `src/bot/handlers/messageRouter.ts` (modify: wire cap + reset)
|
||||
- `tests/unit/queueCap.test.ts` (new)
|
||||
- `tests/unit/specLoader.test.ts` (modify: add tone test)
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Implementation Plan
|
||||
|
||||
1. Create `queueCap.ts` with pure burst-counter functions and tone-keyed strings
|
||||
2. Add `tone?` to spec type/schema
|
||||
3. Wire cap check into `processEncounterMessage`
|
||||
4. Wire burst reset into runner
|
||||
|
||||
### Debug Log
|
||||
|
||||
- Initial cap test had "two increments is not capped" which contradicted AC1 (cap=2 means 3rd message dropped). Corrected test: after 2 increments `isBurstCapped` returns true.
|
||||
|
||||
### Completion Notes
|
||||
|
||||
12 tests in `queueCap.test.ts`, 2 new tests in `specLoader.test.ts`. Full suite: 281 tests, 0 failures. The burst counter, drop notices, and `tone` field are fully wired. Story 2.1 can add tone to the system prompt without touching this code.
|
||||
|
||||
## File List
|
||||
|
||||
- src/bot/handlers/queueCap.ts (new)
|
||||
- src/spec/loader.ts (modified: added `tone` to Zod schema)
|
||||
- src/types/index.ts (modified: added `tone?: string` to `EncounterSpec`)
|
||||
- src/bot/handlers/messageRouter.ts (modified: imports, burst cap check + increment, burst reset in runner)
|
||||
- tests/unit/queueCap.test.ts (new)
|
||||
- tests/unit/specLoader.test.ts (modified: added 2 tone tests)
|
||||
|
||||
## Change Log
|
||||
|
||||
- 2026-05-30: Story 1.2 implemented — burst cap at 2, in-world DM drop notices, tone field on EncounterSpec
|
||||
73
Docs/stories/story-2.1-encounter-tone-config.md
Normal file
73
Docs/stories/story-2.1-encounter-tone-config.md
Normal file
@@ -0,0 +1,73 @@
|
||||
---
|
||||
baseline_commit: NO_VCS
|
||||
status: review
|
||||
---
|
||||
|
||||
# Story 2.1: Encounter Tone Configuration
|
||||
|
||||
**Epic:** 2 — Encounter Tone Configuration
|
||||
**Status:** in-progress
|
||||
|
||||
## Story
|
||||
|
||||
As a DM,
|
||||
I want to set a `tone` field in my encounter YAML that controls how the bot sounds during that encounter,
|
||||
So that a tense pursuit feels different from a comedic heist without touching code.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC1:** Given `buildSystemPrompt()` is called with a spec that has `tone: "tense"` (or any non-empty string), the returned system prompt includes a `<tone>` block with the text: `Your narration style for this encounter is: {tone}. Let this flavor all of your responses, NPC voices, and pacing.`
|
||||
|
||||
**AC2:** Given `buildSystemPrompt()` is called with a spec where `tone` is `undefined` or absent, no `<tone>` block appears in the returned system prompt.
|
||||
|
||||
**AC3:** The `<tone>` block is inserted immediately after `<narrator_identity>` and before `<sportsmanship>`.
|
||||
|
||||
**AC4:** The existing `buildSystemPrompt` signature is unchanged — `tone` is read from `spec.tone` directly.
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [x] Task 1: Add failing tests for tone block injection in `tests/unit/promptBuilder.test.ts`
|
||||
- [x] 1a: Test that prompt contains `<tone>` block when spec has a tone value
|
||||
- [x] 1b: Test that prompt contains the exact tone text
|
||||
- [x] 1c: Test that `<tone>` appears before `<sportsmanship>`
|
||||
- [x] 1d: Test that no `<tone>` block appears when tone is undefined
|
||||
|
||||
- [x] Task 2: Implement `buildToneBlock(spec)` in `src/harness/promptBuilder.ts` and wire it in
|
||||
- [x] 2a: Add `buildToneBlock` function
|
||||
- [x] 2b: Insert at position 2 in the array (after `buildNarratorBlock`, before `buildSportsmanshipBlock`)
|
||||
- [x] 2c: Run tests — confirm all pass
|
||||
|
||||
- [x] Task 3: Run full test suite — confirm no regressions
|
||||
|
||||
## Dev Notes
|
||||
|
||||
**Insertion point:** The tone block goes second in the `buildSystemPrompt` array, between `buildNarratorBlock()` and `buildSportsmanshipBlock()`. The `.filter(Boolean)` call already handles the `undefined` case when tone is absent.
|
||||
|
||||
**`spec.tone`** is already present on `EncounterSpec` (added in Story 1.2). No schema changes needed.
|
||||
|
||||
**Files:**
|
||||
- `src/harness/promptBuilder.ts` (modify)
|
||||
- `tests/unit/promptBuilder.test.ts` (modify)
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Implementation Plan
|
||||
|
||||
Single change: add `buildToneBlock` returning `<tone>...</tone>` when `spec.tone` is set, or empty string when absent. Wire it into the section array.
|
||||
|
||||
### Debug Log
|
||||
|
||||
No issues — straightforward insertion.
|
||||
|
||||
### Completion Notes
|
||||
|
||||
5 new tests added to `promptBuilder.test.ts`. Full suite: 286 tests, 0 failures. The `<tone>` block is inserted second in the system prompt array, after `<narrator_identity>` and before `<sportsmanship>`, using `.filter(Boolean)` to omit it cleanly when `spec.tone` is absent.
|
||||
|
||||
## File List
|
||||
|
||||
- src/harness/promptBuilder.ts (modified: added `buildToneBlock`, wired into section array)
|
||||
- tests/unit/promptBuilder.test.ts (modified: 5 new tone block tests)
|
||||
|
||||
## Change Log
|
||||
|
||||
- 2026-05-30: Story 2.1 implemented — tone field injected into system prompt as `<tone>` block
|
||||
122
Docs/stories/story-3.1-player-pronouns.md
Normal file
122
Docs/stories/story-3.1-player-pronouns.md
Normal file
@@ -0,0 +1,122 @@
|
||||
---
|
||||
baseline_commit: NO_VCS
|
||||
status: review
|
||||
---
|
||||
|
||||
# Story 3.1: Player Pronouns
|
||||
|
||||
**Epic:** 3 — Player Pronouns
|
||||
**Status:** in-progress
|
||||
|
||||
## Story
|
||||
|
||||
As a player,
|
||||
I want to register my character's pronouns so the bot refers to my character correctly during encounters,
|
||||
So that my character exists in the world on my terms.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC1:** `/character register custom` shows a Discord modal (not inline slash args) with five text fields: Character Name (required), Pronouns (optional), Class (optional), Race (optional), Backstory (optional).
|
||||
|
||||
**AC2:** On modal submit, all five values are saved to `CharacterProfile` in Redis. `pronouns` is stored as a free-text string or `undefined` when left blank.
|
||||
|
||||
**AC3:** When a player sends their first message in an encounter thread, their `pronouns` (from `characterRegistry`) are loaded and stored on `session.players[userId].pronouns`.
|
||||
|
||||
**AC4:** The LLM system prompt includes a `<players>` block listing each active player's character name and pronouns (e.g. `Vex (she/her)`). Players without pronouns set are listed without a pronoun note.
|
||||
|
||||
**AC5:** `/character show` displays a Pronouns field when the profile has pronouns set.
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [x] Task 1: Add `pronouns?: string` to `CharacterProfile` and `Player` types
|
||||
- [x] 1a: Add to `CharacterProfile` in `src/session/characterRegistry.ts`
|
||||
- [x] 1b: Add to `Player` in `src/types/index.ts`
|
||||
- [x] 1c: Write and run test confirming `pronouns` round-trips through `characterRegistry`
|
||||
|
||||
- [x] Task 2: Convert `/character register custom` to a modal
|
||||
- [x] 2a: Remove all inline options from the `custom` subcommand in the slash command builder
|
||||
- [x] 2b: `handleRegisterCustom` shows a 5-field modal (Name, Pronouns, Class, Race, Backstory)
|
||||
- [x] 2c: Export `handleCustomRegisterModal` for modal submit handling
|
||||
- [x] 2d: Wire `'character_custom_modal'` in `src/bot/index.ts`
|
||||
- [x] 2e: Update `handleShow` to display Pronouns field when set
|
||||
|
||||
- [x] Task 3: Load pronouns into session when player joins encounter
|
||||
- [x] 3a: In `processEncounterMessage` (new-player path), call `characterRegistry.get()` and copy `pronouns` to `session.players[userId]`
|
||||
|
||||
- [x] Task 4: Add `buildPlayersBlock` to system prompt
|
||||
- [x] 4a: Write failing tests for `buildPlayersBlock` (with pronouns, without, empty)
|
||||
- [x] 4b: Add `buildPlayersBlock(players)` function to `src/harness/promptBuilder.ts`
|
||||
- [x] 4c: Add `players` as optional 4th param to `buildSystemPrompt`; insert block after `buildNpcsBlock`
|
||||
- [x] 4d: Pass `session.players` from `contextAssembler.ts`
|
||||
- [x] 4e: Run full test suite — confirm no regressions
|
||||
|
||||
## Dev Notes
|
||||
|
||||
**Modal custom ID:** `'character_custom_modal'`
|
||||
|
||||
**Modal fields (in order):**
|
||||
1. `char_name` — "Character Name" — required, short
|
||||
2. `char_pronouns` — "Pronouns" — optional, short, placeholder `e.g. she/her, they/them`
|
||||
3. `char_class` — "Class" — optional, short, placeholder `e.g. Wizard, Fighter`
|
||||
4. `char_race` — "Race" — optional, short, placeholder `e.g. Elf, Human`
|
||||
5. `char_backstory` — "Backstory" — optional, paragraph, maxLength 200
|
||||
|
||||
Level is dropped from the modal — not critical for narration, and Discord modals only support text inputs.
|
||||
|
||||
**`buildPlayersBlock` output (when players have pronouns):**
|
||||
```
|
||||
<players>
|
||||
Active player characters in this encounter:
|
||||
- Vex (she/her)
|
||||
- Thorin (he/him)
|
||||
- Aelindra
|
||||
|
||||
Use the specified pronouns when referring to these characters in narration.
|
||||
</players>
|
||||
```
|
||||
Block is omitted entirely when `players` is empty (`filter(Boolean)` handles it).
|
||||
|
||||
**`buildSystemPrompt` signature update:** add optional 4th param `players: Record<string, Player> = {}`. Existing callers pass nothing and get an empty players block (omitted).
|
||||
|
||||
**Pronouns in session:** `session.players[userId]` already uses the `Player` type. Adding `pronouns?: string` to `Player` is backwards-compatible.
|
||||
|
||||
**Files:**
|
||||
- `src/session/characterRegistry.ts` (modify: add `pronouns` field)
|
||||
- `src/types/index.ts` (modify: add `pronouns` to `Player`)
|
||||
- `src/bot/commands/character.ts` (modify: modal conversion + show update)
|
||||
- `src/bot/index.ts` (modify: add modal routing)
|
||||
- `src/bot/handlers/messageRouter.ts` (modify: load pronouns on player join)
|
||||
- `src/harness/promptBuilder.ts` (modify: add `buildPlayersBlock`, update signature)
|
||||
- `src/harness/contextAssembler.ts` (modify: pass `session.players`)
|
||||
- `tests/unit/characterRegistry.test.ts` (modify: add pronouns test)
|
||||
- `tests/unit/promptBuilder.test.ts` (modify: add players block tests)
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Implementation Plan
|
||||
|
||||
Type changes → modal conversion → session wiring → prompt injection.
|
||||
|
||||
### Debug Log
|
||||
|
||||
No issues — all changes straightforward.
|
||||
|
||||
### Completion Notes
|
||||
|
||||
4 new tests in `characterRegistry.test.ts` (2 new), `promptBuilder.test.ts` (4 new players block tests). Full suite: 292 tests, 0 failures. Note: `/character register custom` command spec must be re-deployed to Discord via `scripts/deploy-commands.ts` for the modal to appear (slash command schema changed — inline options removed).
|
||||
|
||||
## File List
|
||||
|
||||
- src/session/characterRegistry.ts (modified: `pronouns?: string` on `CharacterProfile`)
|
||||
- src/types/index.ts (modified: `pronouns?: string` on `Player`)
|
||||
- src/bot/commands/character.ts (modified: modal conversion, `handleCustomRegisterModal` export, `handleShow` pronouns field)
|
||||
- src/bot/index.ts (modified: `character_custom_modal` routing)
|
||||
- src/bot/handlers/messageRouter.ts (modified: `characterRegistry` import, pronouns copied on player join)
|
||||
- src/harness/promptBuilder.ts (modified: `buildPlayersBlock`, updated `buildSystemPrompt` signature)
|
||||
- src/harness/contextAssembler.ts (modified: pass `session.players` to `buildSystemPrompt`)
|
||||
- tests/unit/characterRegistry.test.ts (modified: 2 pronouns tests)
|
||||
- tests/unit/promptBuilder.test.ts (modified: 4 players block tests)
|
||||
|
||||
## Change Log
|
||||
|
||||
- 2026-05-30: Story 3.1 implemented — pronouns in modal registration, session state, and system prompt
|
||||
97
Docs/stories/story-4.1-dice-roll-reliability.md
Normal file
97
Docs/stories/story-4.1-dice-roll-reliability.md
Normal file
@@ -0,0 +1,97 @@
|
||||
---
|
||||
baseline_commit: NO_VCS
|
||||
status: review
|
||||
---
|
||||
|
||||
# Story 4.1: Strengthen Skill Check Tool Contract
|
||||
|
||||
**Epic:** 4 — Dice Roll Reliability Fix
|
||||
**Status:** in-progress
|
||||
|
||||
## Story
|
||||
|
||||
As a player,
|
||||
I want the bot to always post the dice roll embed when a skill check is needed,
|
||||
So that I never have to ask again after already declaring my action.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC1:** Given a player message that implies a skill check, when the LLM processes the message, the LLM must output a `skill_check_emit` tool call rather than narrating the dice outcome itself.
|
||||
|
||||
**AC2:** Given the LLM's response is parsed and contains no tool call, no pending skill check exists, and the narrative text contains skill-request phrases (e.g. "you need to roll", "roll for X", "make a check"), then the tool dispatcher / message router logs a warning `possible_missed_skill_check` via pino. No user-facing change.
|
||||
|
||||
**AC3:** Given the system prompt `<tool_contract>` block rendered by `buildToolManifest()`, when rendered, it includes an explicit negative instruction: "NEVER end your response with a request for the player to roll without emitting skill_check_emit in the same response."
|
||||
|
||||
**AC4:** Given the system prompt `<tool_contract>` block, when rendered, it includes at least one few-shot example demonstrating: player declares action → LLM emits `skill_check_emit` (with narrative before the tool block).
|
||||
|
||||
**AC5:** Integration test in `tests/unit/promptBuilder.test.ts` asserts the built prompt contains the negative instruction text and the few-shot example marker.
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [x] Task 1: Add negative instruction to `buildToolManifest()` in `src/harness/toolDispatcher.ts`
|
||||
- [x] 1a: Write failing test asserting negative instruction string is present in built prompt
|
||||
- [x] 1b: Add the instruction text to the SKILL CHECKS section of `buildToolManifest()`
|
||||
- [x] 1c: Run test — confirm it passes
|
||||
|
||||
- [x] Task 2: Add few-shot example to `buildToolManifest()` in `src/harness/toolDispatcher.ts`
|
||||
- [x] 2a: Write failing test asserting a few-shot marker string is present in built prompt
|
||||
- [x] 2b: Add the few-shot example block to `buildToolManifest()`
|
||||
- [x] 2c: Run test — confirm it passes
|
||||
|
||||
- [x] Task 3: Add `detectMissedSkillCheck()` to `src/bot/handlers/responseFilter.ts`
|
||||
- [x] 3a: Write failing tests for the new detection function (true/false cases)
|
||||
- [x] 3b: Implement `detectMissedSkillCheck(narrative: string): boolean`
|
||||
- [x] 3c: Run tests — confirm they pass
|
||||
|
||||
- [x] Task 4: Wire diagnostic warning in `src/bot/handlers/messageRouter.ts`
|
||||
- [x] 4a: In `runLLMTurn()`, after response parse, call `detectMissedSkillCheck` when no tool call and no pending check
|
||||
- [x] 4b: Log `log.warn('harness', 'possible_missed_skill_check', ...)` when detected
|
||||
- [x] 4c: Run full test suite — confirm no regressions
|
||||
|
||||
## Dev Notes
|
||||
|
||||
**Root cause:** `gemma4-it:e2b` at e2b quantization inconsistently omits `skill_check_emit` when player actions imply a skill check. The LLM sometimes narrates "you'll need to roll" or "make a Dexterity check" and stops — without emitting the tool call. The player then has to repeat themselves.
|
||||
|
||||
**Why the existing filter doesn't catch this:** `responseFilter.ts` catches the opposite failure (LLM fabricating a roll result like "you rolled a 15"). The new diagnostic catches the missing-tool case.
|
||||
|
||||
**Key files:**
|
||||
- `src/harness/toolDispatcher.ts` — `buildToolManifest()` builds the `<tool_contract>` prompt block
|
||||
- `src/bot/handlers/responseFilter.ts` — add `detectMissedSkillCheck()` here alongside the existing filters
|
||||
- `src/bot/handlers/messageRouter.ts` — `runLLMTurn()` is where the response is parsed; wire the diagnostic here
|
||||
- `tests/unit/promptBuilder.test.ts` — existing test file to extend
|
||||
- `tests/unit/responseFilter.test.ts` — new test file for the detection function (if it doesn't exist)
|
||||
|
||||
**Constraint:** The missed skill check detection is diagnostic only (log warning). It must NOT retry or inject a correction in this story — that's future scope.
|
||||
|
||||
**Test approach:** Unit tests only — no live LLM required. Test that `buildToolManifest()` output contains the expected strings, and that `detectMissedSkillCheck()` returns correct booleans for known inputs.
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Implementation Plan
|
||||
|
||||
- Strengthen the tool contract prompt in `buildToolManifest()` with a "NEVER" instruction
|
||||
- Add a concise few-shot example showing the correct player-action → tool-call sequence
|
||||
- Expose `detectMissedSkillCheck()` from `responseFilter.ts` with regex-based heuristic
|
||||
- Wire it into `runLLMTurn()` as a fire-and-forget diagnostic log
|
||||
|
||||
### Debug Log
|
||||
|
||||
- `requires a Strength check` failed initial regex (skill name between "a" and "check") — widened pattern to `requires\s+a\s+(?:\w+\s+)?(?:roll|check)`.
|
||||
|
||||
### Completion Notes
|
||||
|
||||
All 4 tasks complete. 13 new tests added (3 in promptBuilder, 13 in responseFilter). Full suite: 244 passing, 0 failures.
|
||||
|
||||
Changes are prompt-engineering only for the LLM contract + one new diagnostic function. No breaking changes to any existing interface.
|
||||
|
||||
## File List
|
||||
|
||||
- src/harness/toolDispatcher.ts — strengthened SKILL CHECKS section with NEVER instruction + CORRECT/WRONG few-shot example
|
||||
- src/bot/handlers/responseFilter.ts — added `MISSED_SKILL_CHECK_RE` regex + `detectMissedSkillCheck()` export
|
||||
- src/bot/handlers/messageRouter.ts — imported `detectMissedSkillCheck` and `log`; wired diagnostic warn in `runLLMTurn()`
|
||||
- tests/unit/promptBuilder.test.ts — added 3 tests for negative instruction and few-shot example
|
||||
- tests/unit/responseFilter.test.ts — added 13 tests for `detectMissedSkillCheck()`
|
||||
|
||||
## Change Log
|
||||
|
||||
- 2026-05-30: Story 4.1 implemented — strengthened skill_check_emit tool contract; added missed-skill-check diagnostic
|
||||
37
Docs/ux-designs/ux-mardonar-2026-05-30/.decision-log.md
Normal file
37
Docs/ux-designs/ux-mardonar-2026-05-30/.decision-log.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Decision Log — Mardonar UX
|
||||
|
||||
## Session 2026-05-30
|
||||
|
||||
### D-01 · Interaction feedback via emoji reactions
|
||||
**Decision:** Use Discord emoji reactions as persistent state signals on player messages.
|
||||
- `👀` = received (bot sees the message)
|
||||
- `⏳` = queued / processing
|
||||
- `✅` = response posted (or reaction self-clears)
|
||||
**Rationale:** Current typing indicator appears but response never posts — creates false hope. Reactions persist and self-clear, giving players durable feedback without polluting the channel.
|
||||
|
||||
### D-02 · Message queue cap at 2
|
||||
**Decision:** During a burst of concurrent messages, the bot batches up to 2 messages into one LLM context. Messages beyond the cap are silently dropped from the queue (but not from the channel).
|
||||
**Rationale:** Prevents runaway queue depth and undefined LLM behavior with too many simultaneous inputs.
|
||||
|
||||
### D-03 · Ephemeral drop notice — in-world voice
|
||||
**Decision:** Players whose messages were not batched receive a Discord ephemeral message (visible only to them) written in in-world language.
|
||||
**Example flavor:** *"The echoes of the encounter could not carry all voices at once. Your words did not reach the moment — wait for the dust to settle before speaking again."*
|
||||
**Rationale:** Ephemeral keeps the channel clean; in-world voice preserves immersion.
|
||||
|
||||
### D-04 · Always in-world voice
|
||||
**Decision:** All bot-generated text — including system feedback, errors, and config confirmations — uses in-world language. No utility/system strings are exposed to players.
|
||||
**Rationale:** User explicitly stated preference. Private game with friends; immersion is a core value.
|
||||
|
||||
### D-05 · Per-encounter tone config in YAML spec
|
||||
**Decision:** Each encounter YAML spec gains a `tone` field that controls phrasing flavor for that encounter's bot responses.
|
||||
**[ASSUMPTION]:** Tone is a string enum or free-text label (e.g. `"grim"`, `"comedic"`, `"tense"`, `"mysterious"`). The LLM uses this as a style instruction when generating narration and system messages.
|
||||
**Rationale:** User wants customizability at the encounter level without touching code.
|
||||
|
||||
### D-06 · Player-facing config via slash command
|
||||
**Decision:** Players can configure personal preferences via a `/profile` slash command (or similar).
|
||||
**[ASSUMPTION]:** Config fields include: character name display, pronouns, notification preferences.
|
||||
**Rationale:** User wants player-facing customizability. Slash command is the most discoverable Discord pattern.
|
||||
|
||||
### D-07 · Stakes and platform
|
||||
**Decision:** Private game with friends. Hobby stakes. Discord-native only (no web dashboard, no external surface).
|
||||
**Rationale:** Scopes the design — no need for onboarding flows, public discoverability, or accessibility for unknown user populations.
|
||||
131
Docs/ux-designs/ux-mardonar-2026-05-30/DESIGN.md
Normal file
131
Docs/ux-designs/ux-mardonar-2026-05-30/DESIGN.md
Normal file
@@ -0,0 +1,131 @@
|
||||
---
|
||||
name: mardonar
|
||||
version: "1.0"
|
||||
status: final
|
||||
updated: 2026-05-30
|
||||
colors:
|
||||
encounter_active: "#5865F2" # Discord blurple — session is live
|
||||
encounter_success: "#57F287" # Discord green — goal resolved
|
||||
encounter_warning: "#FEE75C" # Discord yellow — attention needed
|
||||
encounter_danger: "#ED4245" # Discord red — failure / danger state
|
||||
encounter_neutral: "#2B2D31" # Discord dark — narration / ambient
|
||||
encounter_mystery: "#9B59B6" # purple — hidden goals, secret events
|
||||
typography:
|
||||
primary: discord_markdown # bold, italic, code blocks — Discord's full markdown
|
||||
narration: plain # plain prose, no markdown decoration
|
||||
system_flavor: italic # in-world system messages rendered as *italic*
|
||||
emphasis: bold # key terms, NPC names, goal labels
|
||||
rounded: n/a
|
||||
spacing: n/a
|
||||
components:
|
||||
reaction_received: "👀"
|
||||
reaction_queued: "⏳"
|
||||
reaction_done: "✅"
|
||||
reaction_dice: "🎲"
|
||||
embed_encounter_start: see Components
|
||||
embed_goal_update: see Components
|
||||
embed_resolution: see Components
|
||||
embed_dynamic_goal: see Components
|
||||
---
|
||||
|
||||
## Brand & Style
|
||||
|
||||
Mardonar is a private D&D encounter engine running inside Discord. It has no public face. The aesthetic is **dark fantasy immersion** — the bot is not a bot, it's the world speaking. Every string it emits should feel like it comes from within the fiction, not from a developer.
|
||||
|
||||
Tone is encounter-scoped (see `tone` field in encounter YAML). Baseline tone is **grave and atmospheric**. Encounters may override to `comedic`, `tense`, `mysterious`, `grim`, or any free-text style directive.
|
||||
|
||||
There are no logos, landing pages, or brand colors beyond what Discord renders. Visual identity lives entirely in embed color, emoji vocabulary, and prose voice.
|
||||
|
||||
## Colors
|
||||
|
||||
Four semantic states map to Discord's native embed left-border color:
|
||||
|
||||
| Token | Hex | Use |
|
||||
|---|---|---|
|
||||
| `encounter_active` | `#5865F2` | Session is live, encounter in progress |
|
||||
| `encounter_success` | `#57F287` | Goal resolved, encounter ended well |
|
||||
| `encounter_warning` | `#FEE75C` | Partial success, complication, or attention needed |
|
||||
| `encounter_danger` | `#ED4245` | Failure state, mortal danger, catastrophic outcome |
|
||||
| `encounter_neutral` | `#2B2D31` | Ambient narration, lore drops, NPC flavor |
|
||||
| `encounter_mystery` | `#9B59B6` | Hidden goal registered, secret event, revelation |
|
||||
|
||||
Do not use color to convey information that isn't also conveyed in text — Discord users may be on light mode or have color vision differences.
|
||||
|
||||
## Typography
|
||||
|
||||
Discord constrains typography to markdown. Use the full palette deliberately:
|
||||
|
||||
- **Bold** — NPC names on first appearance, goal labels, key terms
|
||||
- *Italic* — in-world system messages, ephemeral notices, ambient flavor
|
||||
- `Code` — dice results, mechanical values (DC, HP, damage)
|
||||
- > Blockquote — NPC direct speech
|
||||
- ~~Strikethrough~~ — goals that are no longer achievable, retracted options
|
||||
|
||||
Never use headers (`#`, `##`) in bot messages — they read as out-of-world on mobile.
|
||||
|
||||
## Components
|
||||
|
||||
### Reaction Set — Message State Signals
|
||||
|
||||
Applied to player messages to signal processing state. Self-clearing: `👀` and `⏳` are removed when the response is posted. `✅` persists briefly then is removed (or left as confirmation).
|
||||
|
||||
| State | Reaction | Meaning |
|
||||
|---|---|---|
|
||||
| Received | `👀` | Bot sees the message, queued |
|
||||
| Processing | `⏳` | Message included in current LLM context |
|
||||
| Done | `✅` | Response posted, message processed |
|
||||
| Dice | `🎲` | Dice roll detected, resolving |
|
||||
|
||||
If a message is dropped (queue cap exceeded), no reaction is added — the ephemeral notice is the only signal.
|
||||
|
||||
### Embed — Encounter Start
|
||||
|
||||
```
|
||||
[color: encounter_active]
|
||||
[title: {encounter.title}]
|
||||
[description: {encounter.opening_narration}]
|
||||
[fields]
|
||||
Location: {encounter.setting.location}
|
||||
Mood: {encounter.setting.mood}
|
||||
[footer: The encounter begins. Speak freely.]
|
||||
```
|
||||
|
||||
### Embed — Goal Update
|
||||
|
||||
```
|
||||
[color: encounter_active or encounter_mystery for dynamic goals]
|
||||
[title: A new path opens.] ← in-world, encounter-tone-aware
|
||||
[description: {goal.label}]
|
||||
[footer: This goal has been recorded in the hidden ledger.]
|
||||
```
|
||||
|
||||
### Embed — Resolution
|
||||
|
||||
```
|
||||
[color: encounter_success / encounter_warning / encounter_danger]
|
||||
[title: {outcome-flavor from tone}]
|
||||
[description: {resolution narration}]
|
||||
[fields]
|
||||
Outcome: {goal.label of resolved goal}
|
||||
Path Taken: {outcomeId}
|
||||
```
|
||||
|
||||
### Embed — Dynamic Goal Registered
|
||||
|
||||
```
|
||||
[color: encounter_mystery]
|
||||
[title: The threads of fate shift.] ← in-world
|
||||
[description: {dynamic goal label, paraphrased in-world}]
|
||||
```
|
||||
|
||||
## Do's and Don'ts
|
||||
|
||||
**Do:**
|
||||
- Use `encounter_mystery` color for any hidden or dynamic goal event
|
||||
- Write ephemeral drop notices in *italic* to signal they are private/system
|
||||
- Let the encounter `tone` field override baseline gravity — a `comedic` encounter should read differently from a `grim` one
|
||||
|
||||
**Don't:**
|
||||
- Use the word "bot," "system," "error," "rate limit," or "queue" in any player-facing string
|
||||
- Use headers in embed descriptions — they break mobile rendering
|
||||
- Add reactions to bot messages — reactions are only for player messages as state signals
|
||||
233
Docs/ux-designs/ux-mardonar-2026-05-30/EXPERIENCE.md
Normal file
233
Docs/ux-designs/ux-mardonar-2026-05-30/EXPERIENCE.md
Normal file
@@ -0,0 +1,233 @@
|
||||
---
|
||||
name: mardonar
|
||||
version: "1.0"
|
||||
status: final
|
||||
updated: 2026-05-30
|
||||
---
|
||||
|
||||
# Mardonar Encounter Engine — EXPERIENCE.md
|
||||
|
||||
Visual identity: see DESIGN.md. This document owns behavior, flows, states, and interactions.
|
||||
|
||||
---
|
||||
|
||||
## Foundation
|
||||
|
||||
**Form factor:** Discord (desktop and mobile). Single surface — the encounter channel. No web dashboard, no external UI.
|
||||
|
||||
**UI system:** Discord's native component model — embeds, slash commands, emoji reactions, ephemeral messages, buttons (optional). No custom UI framework.
|
||||
|
||||
**Constraints inherited from Discord:**
|
||||
- Ephemeral messages are only visible to the recipient and cannot be edited after send
|
||||
- Reactions can be added/removed by the bot programmatically
|
||||
- Slash command responses can be deferred (show "thinking…") for up to 15 minutes
|
||||
- Message content limit: 2000 characters; embed description limit: 4096 characters
|
||||
|
||||
---
|
||||
|
||||
## Information Architecture
|
||||
|
||||
Three surfaces, one channel:
|
||||
|
||||
| Surface | Visibility | Owner |
|
||||
|---|---|---|
|
||||
| **Encounter channel** | All players | Bot + players |
|
||||
| **Ephemeral notice** | Individual player only | Bot |
|
||||
| **Player profile** (`/profile`) | Player only (ephemeral response) | Player |
|
||||
|
||||
[ASSUMPTION] DM-facing encounter config lives in the YAML spec file and is not surfaced as a Discord command. The DM edits YAML, not Discord.
|
||||
|
||||
### Encounter Channel
|
||||
|
||||
Contains: opening embed, player messages, bot narration, goal update embeds, resolution embed. This is the entire game world.
|
||||
|
||||
### Ephemeral Notices
|
||||
|
||||
Transient private messages delivered to a specific player. Used for:
|
||||
- Drop notice (message not batched, queue cap exceeded)
|
||||
- Profile confirmation (when `/profile` is set)
|
||||
- [ASSUMPTION] Dice clarification if a roll result is ambiguous
|
||||
|
||||
### Player Profile (`/profile`)
|
||||
|
||||
A slash command players use to set personal preferences. Stored per Discord user ID. Fields:
|
||||
- `name` — character name displayed in bot narration (default: Discord display name)
|
||||
- `pronouns` — used when bot narrates player actions (default: they/them)
|
||||
- [ASSUMPTION] `notify` — whether to receive ephemeral drop notices (default: on)
|
||||
|
||||
---
|
||||
|
||||
## Voice and Tone
|
||||
|
||||
All player-facing strings are in-world. The bot is the world speaking. See {DESIGN.md > Brand & Style} for baseline tone rules.
|
||||
|
||||
**Per-encounter tone** is set via the `tone` field in the encounter YAML. The LLM uses this as a style directive for all narration and in-world system messages generated during that encounter. When `tone` is absent, default to grave/atmospheric.
|
||||
|
||||
**Tone examples and their flavor effect:**
|
||||
|
||||
| Tone value | Ephemeral drop notice flavor |
|
||||
|---|---|
|
||||
| `grim` | *"The chaos swallowed your words before they could reach the moment. Silence yourself until the echoes clear."* |
|
||||
| `comedic` | *"Everyone was talking at once and the universe, frankly, wasn't listening. Give it a moment."* |
|
||||
| `mysterious` | *"Something in the fabric of this place muffled your voice. Wait. It will pass."* |
|
||||
| `tense` | *"No time — the moment moved on without you. Hold. Wait for your opening."* |
|
||||
| baseline | *"The echoes of the encounter could not carry all voices at once. Wait for the dust to settle before speaking again."* |
|
||||
|
||||
The LLM generates these dynamically using the tone directive — the table above is illustrative, not hardcoded strings.
|
||||
|
||||
---
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### Reaction State Machine (behavioral)
|
||||
|
||||
Visual specs: {DESIGN.md > Components > Reaction Set}
|
||||
|
||||
Applied to **player messages only**. Bot messages never receive bot-added reactions.
|
||||
|
||||
State transitions:
|
||||
|
||||
```
|
||||
[message posted by player]
|
||||
↓
|
||||
add 👀 (received)
|
||||
↓
|
||||
[within batch window? ≤ 2 messages in burst?]
|
||||
├── YES → remove 👀, add ⏳ (processing)
|
||||
│ ↓
|
||||
│ [LLM responds]
|
||||
│ ↓
|
||||
│ remove ⏳, add ✅ (done) → remove ✅ after 10s
|
||||
│
|
||||
└── NO → remove 👀 (no further reaction)
|
||||
↓
|
||||
send ephemeral drop notice to player
|
||||
```
|
||||
|
||||
If a dice roll is detected in the message, add `🎲` alongside `⏳` for the duration of processing.
|
||||
|
||||
### Ephemeral Drop Notice (behavioral)
|
||||
|
||||
Triggered when: player message arrives and queue is already at 2 messages.
|
||||
|
||||
Delivery: Discord ephemeral reply to the player's message. Tone-aware (see Voice and Tone above). The LLM does **not** generate this at runtime — it is a pre-generated string selected by tone key, to avoid a catch-22 where the rate-limited system tries to invoke the LLM to generate its own rate-limit message.
|
||||
|
||||
[NOTE FOR UX] Confirm with dev: is the `tone` value accessible synchronously at the point where the drop notice is sent, or does it require a Redis lookup? If lookup adds latency risk, pre-cache tone string at session start.
|
||||
|
||||
### Player Profile Command
|
||||
|
||||
```
|
||||
/profile name:"Thorin Ironfist" pronouns:"he/him"
|
||||
```
|
||||
|
||||
Response: ephemeral confirmation in-world, e.g.:
|
||||
*"The ledger of {name} has been updated. The world knows you as you wish to be known."*
|
||||
|
||||
Fields are optional — omitted fields retain their previous value. First-time users who have never run `/profile` get defaults (`name` = Discord display name, `pronouns` = they/them).
|
||||
|
||||
---
|
||||
|
||||
## State Patterns
|
||||
|
||||
### Session States
|
||||
|
||||
| State | What players see | Reactions active? |
|
||||
|---|---|---|
|
||||
| **Idle** (no encounter) | Nothing / last message | No |
|
||||
| **Active** (encounter running) | Bot narrating, reacting | Yes |
|
||||
| **Processing** (LLM working) | `⏳` on queued messages | Yes |
|
||||
| **Rate-limited / at cap** | Ephemeral drop notices sent | 👀 added then removed |
|
||||
| **Resolved** | Resolution embed posted | No (session over) |
|
||||
|
||||
### Goal States
|
||||
|
||||
| State | Visual signal |
|
||||
|---|---|
|
||||
| Active (spec goal) | Listed in opening embed |
|
||||
| Active (dynamic goal) | `encounter_mystery` embed posted |
|
||||
| Resolved | Resolution embed, `encounter_success/warning/danger` color |
|
||||
| Abandoned | [ASSUMPTION] No visual signal — silently dropped |
|
||||
|
||||
---
|
||||
|
||||
## Interaction Primitives
|
||||
|
||||
**Message burst handling:**
|
||||
- Batch window: [ASSUMPTION] 1.5–3 seconds after first message in burst
|
||||
- Cap: 2 messages per batch
|
||||
- Messages 3+ in a burst: `👀` added and removed, ephemeral drop sent
|
||||
- After response posts: new batch window opens
|
||||
|
||||
**Slash commands available to players:**
|
||||
- `/profile` — set name and pronouns
|
||||
- [ASSUMPTION] `/goals` — view currently active goals (ephemeral, in-world flavor)
|
||||
|
||||
**No buttons required** for current scope. Buttons may be added in future for things like "confirm action" or "roll again" — out of scope for this design.
|
||||
|
||||
---
|
||||
|
||||
## Accessibility Floor
|
||||
|
||||
Discord handles most accessibility at the platform level (screen reader support, high contrast mode, font scaling). Bot-specific floor:
|
||||
|
||||
- Color is never the **only** signal — every embed color has a corresponding text label or description
|
||||
- Reactions are supplemental signals, not the only channel — the response itself always narrates what happened
|
||||
- Ephemeral notices are text-only (no embed required) for maximum compatibility
|
||||
- In-world language must still be **legible** — poetic phrasing should not obscure what the player needs to do (e.g. "wait before sending more" must be inferable)
|
||||
|
||||
---
|
||||
|
||||
## Key Flows
|
||||
|
||||
### Flow 1 — Rowena sends a message during a busy moment
|
||||
|
||||
*Rowena (she/her) is playing in "The Velvet Auction." Three players are active. She types a message just as two others arrive simultaneously.*
|
||||
|
||||
1. Rowena's message posts in the encounter channel.
|
||||
2. Bot adds `👀` to her message — she can see her words were received.
|
||||
3. Two messages are already in the batch. Queue is full.
|
||||
4. Bot removes `👀` from Rowena's message. No `⏳` is added.
|
||||
5. Rowena receives an ephemeral message (tense tone): *"No time — the moment moved on without you. Hold. Wait for your opening."*
|
||||
6. The other two messages get `⏳`. LLM responds. `⏳` clears, `✅` briefly appears.
|
||||
7. Rowena tries again. This time she's in the next batch.
|
||||
|
||||
**Climax beat:** Rowena sees the ephemeral notice and holds back — she doesn't flood the channel. When the next beat opens, her message lands.
|
||||
|
||||
---
|
||||
|
||||
### Flow 2 — Skeeter rolls dice and waits
|
||||
|
||||
*Skeeter is attempting to pick a lock (DC 14). He types "I roll Dexterity."*
|
||||
|
||||
1. Bot adds `👀` then `⏳` + `🎲` to his message.
|
||||
2. LLM detects dice intent, calls the dice tool, gets result.
|
||||
3. LLM narrates the outcome inline — no separate prompt needed.
|
||||
4. `⏳` and `🎲` clear. `✅` briefly appears.
|
||||
5. Skeeter reads the outcome in the bot's response.
|
||||
|
||||
**Climax beat:** The result arrives without Skeeter having to ask. The dice reaction telegraphed that something was resolving.
|
||||
|
||||
---
|
||||
|
||||
### Flow 3 — DM sets up "The Mawfang Pursuit" with a tense tone
|
||||
|
||||
*DM edits `mawfang-pursuit.yaml` and adds `tone: "tense"` to the spec.*
|
||||
|
||||
1. Encounter starts. Bot's opening embed posts.
|
||||
2. All narration during this encounter uses tense phrasing.
|
||||
3. When a player gets dropped, their ephemeral reads: *"No time — the moment moved on without you. Hold. Wait for your opening."*
|
||||
4. Dynamic goal registered — embed reads in a clipped, urgent voice.
|
||||
|
||||
**Climax beat:** The tone field did its job — the encounter *felt* different from "The Velvet Auction" without any code change.
|
||||
|
||||
---
|
||||
|
||||
### Flow 4 — Pary sets up her player profile
|
||||
|
||||
*Pary just joined the server. She wants the bot to use her character name "Vex" and she/her pronouns.*
|
||||
|
||||
1. Pary runs `/profile name:"Vex" pronouns:"she/her"`.
|
||||
2. Bot responds with an ephemeral confirmation: *"The ledger has been updated. The world knows Vex as she wishes to be known."*
|
||||
3. In subsequent encounters, bot narration refers to her as Vex and uses she/her.
|
||||
|
||||
**Climax beat:** Pary's character exists in the world on her terms, without anyone else needing to configure it.
|
||||
315
README.md
Normal file
315
README.md
Normal file
@@ -0,0 +1,315 @@
|
||||
# Mardonar Encounter Engine
|
||||
|
||||
A Discord-native, LLM-driven D&D encounter system for the Land of Mardonar.
|
||||
Discord threads are encounter sessions. The LLM narrates, voices NPCs, tracks
|
||||
hidden goals, emits skill checks, and logs everything to Neo4j.
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Discord bot | discord.js v14 |
|
||||
| Language | TypeScript (Node.js, ESM) |
|
||||
| LLM | gemma4-it:e2b via Ollama |
|
||||
| Session cache | Redis (ioredis) |
|
||||
| Persistence | Neo4j 5 (neo4j-driver) |
|
||||
| Schema validation | Zod |
|
||||
| Test runner | Vitest |
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 20+
|
||||
- Docker + Docker Compose (for local Redis and Neo4j)
|
||||
- Ollama running on your network with `gemma4-it:e2b` pulled
|
||||
- A Discord bot token and application ID
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Clone and install
|
||||
|
||||
```bash
|
||||
git clone <your-repo>
|
||||
cd mardonar-bot
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Configure environment
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env`:
|
||||
|
||||
```env
|
||||
DISCORD_TOKEN=your_discord_bot_token
|
||||
DISCORD_CLIENT_ID=your_discord_application_id
|
||||
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
NEO4J_URI=bolt://localhost:7687
|
||||
NEO4J_USER=neo4j
|
||||
NEO4J_PASSWORD=mardonardev
|
||||
|
||||
# Point at your Ollama node — can be a LAN IP
|
||||
OLLAMA_BASE_URL=http://192.168.1.x:11434
|
||||
OLLAMA_MODEL=gemma4-it:e2b
|
||||
|
||||
LOG_LEVEL=debug
|
||||
```
|
||||
|
||||
### 3. Start local services
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
This starts Redis on `localhost:6379` and Neo4j on `localhost:7687`.
|
||||
Neo4j browser UI is available at `http://localhost:7474` (login: neo4j / mardonardev).
|
||||
|
||||
### 4. Register Discord slash commands
|
||||
|
||||
Run once per bot deployment, or whenever commands change:
|
||||
|
||||
```bash
|
||||
npm run deploy-commands
|
||||
```
|
||||
|
||||
### 5. Start the bot
|
||||
|
||||
```bash
|
||||
npm run dev # development (tsx watch mode)
|
||||
npm run build # compile TypeScript
|
||||
npm run start # run compiled output
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
mardonar-bot/
|
||||
├── src/
|
||||
│ ├── bot/
|
||||
│ │ ├── commands/
|
||||
│ │ │ ├── dndname.ts # /dndname set|show|clear
|
||||
│ │ │ └── encounter.ts # /encounter start|status|end
|
||||
│ │ ├── embeds/
|
||||
│ │ │ ├── skillCheck.ts # Skill check embed builder
|
||||
│ │ │ ├── playerGate.ts # "Please register your name" embed
|
||||
│ │ │ └── resolution.ts # Encounter complete embed
|
||||
│ │ └── handlers/
|
||||
│ │ └── messageRouter.ts # Main event loop for encounter threads
|
||||
│ ├── session/
|
||||
│ │ ├── playerRegistry.ts # Redis: discordId → dndName
|
||||
│ │ └── sessionManager.ts # Redis: threadId → SessionState
|
||||
│ ├── harness/
|
||||
│ │ ├── promptBuilder.ts # System prompt assembly
|
||||
│ │ ├── contextAssembler.ts # History + token budget management
|
||||
│ │ ├── ollamaClient.ts # Ollama API client
|
||||
│ │ ├── toolParser.ts # tool_call block detection and parsing
|
||||
│ │ └── toolDispatcher.ts # Routes parsed tool calls to handlers
|
||||
│ ├── mcp/
|
||||
│ │ ├── server.ts # MCP server setup (@modelcontextprotocol/sdk)
|
||||
│ │ └── tools/
|
||||
│ │ ├── skillCheckEmit.ts
|
||||
│ │ ├── skillCheckResolve.ts
|
||||
│ │ ├── eventLogAppend.ts
|
||||
│ │ ├── npcMemoryRead.ts
|
||||
│ │ ├── npcMemoryWrite.ts
|
||||
│ │ └── encounterResolve.ts
|
||||
│ ├── db/
|
||||
│ │ ├── redis.ts # ioredis singleton
|
||||
│ │ └── neo4j.ts # neo4j-driver singleton + runQuery helper
|
||||
│ ├── spec/
|
||||
│ │ └── loader.ts # YAML spec loader + Zod validation
|
||||
│ ├── config.ts # Zod-validated env vars
|
||||
│ └── types/
|
||||
│ └── index.ts # All shared TypeScript interfaces
|
||||
├── specs/
|
||||
│ └── market-thief.yaml # Example encounter spec
|
||||
├── tests/
|
||||
│ ├── unit/
|
||||
│ └── integration/
|
||||
├── scripts/
|
||||
│ └── deploy-commands.ts # Registers slash commands with Discord
|
||||
├── docker-compose.dev.yml
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── vitest.config.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Discord Commands
|
||||
|
||||
### Player commands
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `/dndname set <name>` | Register or update your D&D character name |
|
||||
| `/dndname show` | Show your current registered name |
|
||||
| `/dndname clear` | Remove your registration |
|
||||
|
||||
Players must register before they can participate in an encounter.
|
||||
If an unregistered player posts in an encounter thread, the bot sends
|
||||
an ephemeral embed asking them to register first.
|
||||
|
||||
### DM / Admin commands
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `/encounter start <spec-name>` | Load a spec and open a new encounter thread |
|
||||
| `/encounter status` | Show session phase, player list, event count |
|
||||
| `/encounter end` | Force-resolve the encounter (admin override) |
|
||||
|
||||
`<spec-name>` maps to a file in `./specs/`. E.g. `/encounter start market-thief`
|
||||
loads `./specs/market-thief.yaml`.
|
||||
|
||||
---
|
||||
|
||||
## Encounter Specs
|
||||
|
||||
Encounters are defined as YAML files in the `./specs/` directory.
|
||||
Each spec defines the setting, NPC personas, hidden goals, and skill check DCs.
|
||||
|
||||
See `specs/market-thief.yaml` for a fully annotated example.
|
||||
|
||||
### Key fields
|
||||
|
||||
```yaml
|
||||
encounterId: # Unique ID — used as Neo4j node key
|
||||
title: # Display name shown in Discord embeds
|
||||
setting: # location, mood, ambientNpcs (all strings)
|
||||
openingNarrative: # The scene-setting text posted at session start
|
||||
npcs: # 1–3 personas. Each has id, name, role, persona, optional memoryKey
|
||||
goals:
|
||||
primary: # Main target endings the LLM steers toward
|
||||
secondary: # Valid but non-primary outcomes
|
||||
sportsmanshipRules: # List of "do not allow" rules
|
||||
skillChecks: # Named DCs e.g. chase_dc: 13
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How a Session Works
|
||||
|
||||
```
|
||||
1. DM runs /encounter start <spec>
|
||||
2. Bot creates a Discord thread and posts the opening narrative
|
||||
3. Players post what their character does
|
||||
4. Bot appends the message to session history, calls Ollama
|
||||
5. Ollama response is parsed:
|
||||
- Narrative text → posted to the thread
|
||||
- tool_call block → dispatched silently
|
||||
6. Tool results (skill check embeds, Neo4j writes) happen automatically
|
||||
7. When a goal is reached, LLM calls encounter_resolve
|
||||
8. Bot posts the resolution embed and archives the thread
|
||||
```
|
||||
|
||||
### Skill checks
|
||||
|
||||
When the LLM determines a roll is warranted, it emits a `skill_check_emit`
|
||||
tool call. The bot posts a Discord embed:
|
||||
|
||||
```
|
||||
🎲 Skill Check — Aelindra
|
||||
What skill are you using to chase Dal?
|
||||
DC: 13
|
||||
Reply with: "I rolled a [number] [Skill Name]"
|
||||
```
|
||||
|
||||
The player replies naturally, e.g. `"I rolled a 17 Acrobatics"`. The bot
|
||||
detects this, logs the result, and feeds it back to the LLM as context.
|
||||
|
||||
---
|
||||
|
||||
## Context Window Budget
|
||||
|
||||
The harness manages a strict token budget for the 128k context window:
|
||||
|
||||
| Zone | Budget |
|
||||
|---|---|
|
||||
| System prompt (narrator + NPCs + goals + tools) | 4,000 tokens |
|
||||
| Pinned messages (opening narrative, never trimmed) | 2,000 tokens |
|
||||
| Sliding history window | 118,000 tokens |
|
||||
| Safety buffer | 3,500 tokens |
|
||||
|
||||
When history exceeds the sliding window budget, the oldest non-pinned
|
||||
turn pairs are dropped from the front. The opening narrative and goal
|
||||
block are always preserved.
|
||||
|
||||
---
|
||||
|
||||
## NPC Memory
|
||||
|
||||
Named NPCs with a `memoryKey` in their spec have persistent memory in Neo4j.
|
||||
At session start, their memory facts are loaded and injected into the system
|
||||
prompt. At encounter resolution, any `npc_memory_write` tool calls are
|
||||
committed to the graph.
|
||||
|
||||
This means a NPC like Miriam can remember that your party helped her
|
||||
in a previous encounter — or that you let the thief go.
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
npm run test # run all tests
|
||||
npm run test:unit # unit tests only (no external services)
|
||||
npm run test:int # integration tests (requires Docker services running)
|
||||
```
|
||||
|
||||
Integration tests require live Redis and Neo4j. Start them first:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
npm run test:int
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Encounter
|
||||
|
||||
1. Copy `specs/market-thief.yaml` to `specs/your-encounter.yaml`
|
||||
2. Fill in all required fields
|
||||
3. Test spec loading: `npm run validate-spec your-encounter`
|
||||
4. Run `/encounter start your-encounter` in Discord
|
||||
|
||||
---
|
||||
|
||||
## Deployment Notes
|
||||
|
||||
The bot is a single Node.js process. It connects to:
|
||||
- Redis (ioredis) for session and player registry
|
||||
- Neo4j (neo4j-driver) for NPC memory and event logs
|
||||
- Ollama over HTTP for LLM inference
|
||||
- Discord over WebSocket (discord.js)
|
||||
|
||||
For production on your Proxmox node, run as a Docker container or
|
||||
systemd service. Point `OLLAMA_BASE_URL` at whichever node is running
|
||||
your Ollama instance.
|
||||
|
||||
```bash
|
||||
# Build
|
||||
npm run build
|
||||
|
||||
# Run (with .env file)
|
||||
node dist/bot/index.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture Documents
|
||||
|
||||
Full design rationale and phased build plan are in `docs/`:
|
||||
|
||||
- `docs/mardonar-encounter-engine.md` — system overview and key decisions
|
||||
- `docs/mardonar-build-plan.md` — phased build plan with packages and test guidance
|
||||
@@ -0,0 +1,9 @@
|
||||
Encounter : The Collector's Due
|
||||
ID : cog-claw-debt-001
|
||||
Thread : 1508671535840493609
|
||||
Date : 2026-05-26T03:26:55.854Z
|
||||
Outcome : gorga_attacked
|
||||
Players : Angro
|
||||
|
||||
Summary:
|
||||
Angro shot Gorga at close range with a silenced firearm in the back alley behind the Anvil & Tallow smithy. Gorga died against the wall, his ledger falling open in the blood. Terren Ashweld is now complicit in the death of a Cog Claw Consortium collector. The debt remains unpaid, the Consortium will retaliate, and a body now lies in Mardonar that will be discovered before nightfall. Angro and Terren are both marked.
|
||||
@@ -0,0 +1,9 @@
|
||||
Encounter : The Market Square Thief
|
||||
ID : mardonar-market-thief-001
|
||||
Thread : 1507959432414236785
|
||||
Date : 2026-05-24T04:42:39.337Z
|
||||
Outcome : admin_end
|
||||
Players : Boris
|
||||
|
||||
Summary:
|
||||
Encounter ended by admin: sirhaxolot
|
||||
@@ -0,0 +1,9 @@
|
||||
Encounter : The Market Square Thief
|
||||
ID : mardonar-market-thief-001
|
||||
Thread : 1507967141750636544
|
||||
Date : 2026-05-24T05:31:37.165Z
|
||||
Outcome : <goal_id>
|
||||
Players : Boris
|
||||
|
||||
Summary:
|
||||
Boris calmed the situation, returned the stolen item to Miriam, and persuaded Dal to meet at his camp later for guidance regarding his path.
|
||||
@@ -0,0 +1,9 @@
|
||||
Encounter : The Market Square Thief
|
||||
ID : mardonar-market-thief-001
|
||||
Thread : 1508652166892753096
|
||||
Date : 2026-05-26T03:22:13.663Z
|
||||
Outcome : escape
|
||||
Players : Angro, Boris
|
||||
|
||||
Summary:
|
||||
Angro and Boris failed to pursue or stop Dal after he stole an apple from Miriam's stall. Multiple attempts to escalate to lethal force were rejected per sportsmanship rules. Dal escaped into the crowd with the apple. Miriam was left furious and disappointed.
|
||||
@@ -0,0 +1,9 @@
|
||||
Encounter : The Market Square Thief
|
||||
ID : mardonar-market-thief-001
|
||||
Thread : 1508671514524909638
|
||||
Date : 2026-05-26T06:36:03.308Z
|
||||
Outcome : admin_end
|
||||
Players : Zalram
|
||||
|
||||
Summary:
|
||||
Zalram Cloudwalker, a spellcaster, chased a young hooded thief named Dal after he stole an apple from Miriam's stall in Mardonar's Market Square food festival. Zalram initially failed to cast Mage Hand and an unnamed spell, but after confirming Boomering was in his spellbook, he cast the spell at Dal as the thief fled into an alley. The record ends before the spell attack's result is determined, leaving the encounter unresolved.
|
||||
@@ -0,0 +1,9 @@
|
||||
Encounter : The Market Square Thief
|
||||
ID : mardonar-market-thief-001
|
||||
Thread : 1508948971119444160
|
||||
Date : 2026-05-26T22:12:04.810Z
|
||||
Outcome : negotiate
|
||||
Players : Zalram Cloudwalker
|
||||
|
||||
Summary:
|
||||
Zalram Cloudwalker cornered the young thief Brek using an Arcane Anomaly illusion and chose not to harm him. He offered to pay for the stolen apple on the condition that Brek returns weekly to work for Berta the vendor. Berta agreed to give Brek honest work and an apple a day. Brek surrendered voluntarily, accepted the arrangement, and the encounter ended peacefully with no violence or guard involvement.
|
||||
@@ -0,0 +1,9 @@
|
||||
Encounter : Unwelcome Guests
|
||||
ID : mawfang-pursuit-001
|
||||
Thread : 1508014981726208080
|
||||
Date : 2026-05-24T07:59:27.624Z
|
||||
Outcome : admin_end
|
||||
Players : Boris
|
||||
|
||||
Summary:
|
||||
The character Boris entered the Silt & Sow inn, where he encountered a group of intimidating Mawfang hunters. When Boris attempted to depart, one hunter named Usk questioned his relevance in the district and directed attention toward another cloaked figure concealed within the room. Ultimately, Boris chose to leave the town area with his party rather than engage further with the hunters.
|
||||
@@ -0,0 +1,9 @@
|
||||
Encounter : Unwelcome Guests
|
||||
ID : mawfang-pursuit-001
|
||||
Thread : 1508671607844114464
|
||||
Date : 2026-05-26T03:37:54.540Z
|
||||
Outcome : hunters_confronted
|
||||
Players : Angro
|
||||
|
||||
Summary:
|
||||
Angro attacked Usk three times with a hammer. Usk disarmed him, pinned him to the wall, then took the gnome Fizbet and her device out of the inn into the rain. The hunters left Angro alive on the floor. Usk promised nothing — but this is not finished. The device leaves with the Mawfang.
|
||||
22
data/tally.json
Normal file
22
data/tally.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"market-thief": {
|
||||
"runs": 4,
|
||||
"lastRun": "2026-05-26T21:44:33.947Z"
|
||||
},
|
||||
"mawfang-pursuit": {
|
||||
"runs": 2,
|
||||
"lastRun": "2026-05-26T03:22:23.938Z"
|
||||
},
|
||||
"cog-claw-debt": {
|
||||
"runs": 3,
|
||||
"lastRun": "2026-05-26T03:22:19.935Z"
|
||||
},
|
||||
"stormscar-pilgrim": {
|
||||
"runs": 1,
|
||||
"lastRun": "2026-05-26T06:37:00.697Z"
|
||||
},
|
||||
"silt-leak": {
|
||||
"runs": 1,
|
||||
"lastRun": "2026-05-30T03:07:28.390Z"
|
||||
}
|
||||
}
|
||||
40
docker-compose.dev.yml
Normal file
40
docker-compose.dev.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
version: "3.9"
|
||||
|
||||
# Mardonar Encounter Engine
|
||||
#
|
||||
# docker compose up -d --build
|
||||
# 1. Builds the image
|
||||
# 2. Runs deploy-commands (registers slash commands with Discord)
|
||||
# 3. Starts the bot
|
||||
#
|
||||
# Joins the knowledge-graph-ai_internal network so it can reach:
|
||||
# redis → redis://redis:6379
|
||||
# mcp-server → http://mcp-server:9000
|
||||
# Both containers live in the GraphMCP-Example stack — start that first.
|
||||
|
||||
networks:
|
||||
mardonar-internal:
|
||||
external: true
|
||||
|
||||
services:
|
||||
deploy-commands:
|
||||
build: .
|
||||
container_name: mardonar-deploy-commands
|
||||
restart: "no"
|
||||
env_file: .env
|
||||
networks:
|
||||
- mardonar-internal
|
||||
command: ["node", "dist/scripts/deploy-commands.js"]
|
||||
|
||||
bot:
|
||||
build: .
|
||||
container_name: mardonar-bot
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
networks:
|
||||
- mardonar-internal
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
depends_on:
|
||||
deploy-commands:
|
||||
condition: service_completed_successfully
|
||||
225
index.ts
Normal file
225
index.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
// src/types/index.ts
|
||||
// Shared types used across all layers of the Mardonar Encounter Engine.
|
||||
// Import from here only — do not duplicate type definitions elsewhere.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Players
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Player {
|
||||
discordId: string;
|
||||
dndName: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Encounter Spec
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface NpcPersona {
|
||||
/** Unique stable ID used as Neo4j node key. e.g. "miriam-vendor-mardonar" */
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
/** Full persona description injected into the system prompt. */
|
||||
persona: string;
|
||||
/**
|
||||
* If set, NPC memories are loaded from Neo4j at session start
|
||||
* and written back on encounter_resolve.
|
||||
*/
|
||||
memoryKey?: string;
|
||||
}
|
||||
|
||||
export interface EncounterGoal {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface EncounterGoals {
|
||||
hidden: boolean;
|
||||
primary: EncounterGoal[];
|
||||
secondary: EncounterGoal[];
|
||||
}
|
||||
|
||||
export interface EncounterSetting {
|
||||
location: string;
|
||||
mood: string;
|
||||
ambientNpcs: string;
|
||||
}
|
||||
|
||||
export interface EncounterSpec {
|
||||
encounterId: string;
|
||||
title: string;
|
||||
setting: EncounterSetting;
|
||||
openingNarrative: string;
|
||||
npcs: NpcPersona[];
|
||||
goals: EncounterGoals;
|
||||
sportsmanshipRules: string[];
|
||||
/** Skill check DCs and notes keyed by check name e.g. "chase_dc" → 13 */
|
||||
skillChecks: Record<string, number | string>;
|
||||
dmNotes?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SessionPhase = 'open' | 'active' | 'resolved';
|
||||
|
||||
export interface SessionState {
|
||||
encounterId: string;
|
||||
/** Discord thread snowflake ID — used as the primary session key in Redis. */
|
||||
threadId: string;
|
||||
guildId: string;
|
||||
spec: EncounterSpec;
|
||||
/** Map of discordId → Player for all players who have entered the session. */
|
||||
players: Record<string, Player>;
|
||||
history: ChatMessage[];
|
||||
phase: SessionPhase;
|
||||
/** Messages held while waiting for a player to register their DnD name. */
|
||||
heldMessages: HeldMessage[];
|
||||
/** Outcome goal ID set when the encounter resolves. */
|
||||
outcome?: string;
|
||||
outcomeSummary?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat History
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
/**
|
||||
* Pinned messages are never removed during history trimming.
|
||||
* Use for: opening narrative, goal block.
|
||||
*/
|
||||
pinned?: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface HeldMessage {
|
||||
discordUserId: string;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LLM Harness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ToolCallBlock {
|
||||
tool: ToolName;
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface LLMResponse {
|
||||
/** Narrative text to post to the Discord thread. */
|
||||
narrative: string;
|
||||
/** Parsed tool call block, if the LLM emitted one. */
|
||||
toolCall?: ToolCallBlock;
|
||||
/** Raw token count returned by Ollama (eval_count). */
|
||||
rawTokensUsed?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tools
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ToolName =
|
||||
| 'skill_check_emit'
|
||||
| 'skill_check_resolve'
|
||||
| 'event_log_append'
|
||||
| 'npc_memory_read'
|
||||
| 'npc_memory_write'
|
||||
| 'encounter_resolve';
|
||||
|
||||
export interface SkillCheckEmitArgs {
|
||||
player: string;
|
||||
prompt: string;
|
||||
dc: number;
|
||||
}
|
||||
|
||||
export interface SkillCheckResolveArgs {
|
||||
player: string;
|
||||
skill: string;
|
||||
roll: number;
|
||||
modifier: number;
|
||||
dc: number;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface EventLogAppendArgs {
|
||||
sessionId: string;
|
||||
eventType: EventType;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface NpcMemoryReadArgs {
|
||||
npcId: string;
|
||||
}
|
||||
|
||||
export interface NpcMemoryWriteArgs {
|
||||
npcId: string;
|
||||
memoryFact: string;
|
||||
}
|
||||
|
||||
export interface EncounterResolveArgs {
|
||||
sessionId: string;
|
||||
outcomeId: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export type EventType =
|
||||
| 'player_action'
|
||||
| 'skill_check'
|
||||
| 'npc_action'
|
||||
| 'outcome'
|
||||
| 'sportsmanship'
|
||||
| 'session_start'
|
||||
| 'player_joined';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Neo4j
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface NpcNode {
|
||||
id: string;
|
||||
name: string;
|
||||
personaSummary: string;
|
||||
memoryFacts: string[];
|
||||
lastSeenEncounter: string | null;
|
||||
}
|
||||
|
||||
export interface EncounterNode {
|
||||
id: string;
|
||||
title: string;
|
||||
resolved: boolean;
|
||||
outcomeId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface EncounterEventNode {
|
||||
timestamp: string;
|
||||
type: EventType;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context Budget
|
||||
// (Exported as a const so all callers use the same values)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const CONTEXT_BUDGET = {
|
||||
/** Maximum system prompt size including all NPC personas. */
|
||||
SYSTEM: 4_000,
|
||||
/** Pinned messages (opening narrative + goal block). Never trimmed. */
|
||||
PINNED: 2_000,
|
||||
/** Sliding history window. */
|
||||
HISTORY: 118_000,
|
||||
/** Hard floor — stop trimming here even if still over budget. */
|
||||
SAFETY: 3_500,
|
||||
/** Total context window of gemma4-it:e2b. */
|
||||
TOTAL: 128_000,
|
||||
} as const;
|
||||
172
lore/vocabulary.yaml
Normal file
172
lore/vocabulary.yaml
Normal file
@@ -0,0 +1,172 @@
|
||||
# Mardonar Lore Vocabulary
|
||||
# Curated name and place lists for encounter randomization.
|
||||
# Spec files reference these via: source: vocabulary, category: <dot.path>
|
||||
# All entries are grounded in Mardonar's setting and should feel at home there.
|
||||
|
||||
names:
|
||||
human:
|
||||
female:
|
||||
- Sable
|
||||
- Kessa
|
||||
- Mira
|
||||
- Yenna
|
||||
- Petra
|
||||
- Anda
|
||||
- Corvae
|
||||
- Liret
|
||||
- Nessa
|
||||
- Damith
|
||||
- Rielle
|
||||
- Varsha
|
||||
male:
|
||||
- Brek
|
||||
- Caden
|
||||
- Holt
|
||||
- Javin
|
||||
- Runn
|
||||
- Selvar
|
||||
- Toryn
|
||||
- Aldric
|
||||
- Fenwick
|
||||
- Orren
|
||||
- Davan
|
||||
- Silis
|
||||
|
||||
dwarf:
|
||||
female:
|
||||
- Brunna
|
||||
- Dagga
|
||||
- Hilda
|
||||
- Ragna
|
||||
- Svekka
|
||||
- Wenna
|
||||
- Berta
|
||||
- Kirra
|
||||
- Unna
|
||||
- Thordis
|
||||
male:
|
||||
- Baldur
|
||||
- Dorn
|
||||
- Grimm
|
||||
- Morg
|
||||
- Rurik
|
||||
- Skalf
|
||||
- Thorek
|
||||
- Brond
|
||||
- Vekk
|
||||
- Hardin
|
||||
- Ovrak
|
||||
|
||||
gnome:
|
||||
any:
|
||||
- Tizzbit
|
||||
- Wrenkit
|
||||
- Nixwick
|
||||
- Fizpo
|
||||
- Snibble
|
||||
- Cogsworth
|
||||
- Spreck
|
||||
- Rimbolt
|
||||
- Twigget
|
||||
- Pell
|
||||
|
||||
halfling:
|
||||
any:
|
||||
- Bingo
|
||||
- Crix
|
||||
- Della
|
||||
- Embo
|
||||
- Pip
|
||||
- Rosco
|
||||
- Sori
|
||||
- Tuck
|
||||
- Wren
|
||||
- Olly
|
||||
|
||||
orc:
|
||||
any:
|
||||
- Grak
|
||||
- Skarn
|
||||
- Thun
|
||||
- Varg
|
||||
- Zori
|
||||
- Brek
|
||||
- Dunn
|
||||
- Harg
|
||||
- Murg
|
||||
- Tolg
|
||||
|
||||
ratling:
|
||||
any:
|
||||
- Chitik
|
||||
- Nassik
|
||||
- Squeev
|
||||
- Wisk
|
||||
- Nibs
|
||||
- Shrivel
|
||||
- Thritch
|
||||
- Peck
|
||||
- Skemp
|
||||
- Wibble
|
||||
|
||||
elf:
|
||||
female:
|
||||
- Faera
|
||||
- Ilmara
|
||||
- Sylene
|
||||
- Vaeria
|
||||
- Aelith
|
||||
- Caeli
|
||||
- Lyreth
|
||||
- Miri
|
||||
male:
|
||||
- Calin
|
||||
- Drev
|
||||
- Faeron
|
||||
- Lyrel
|
||||
- Aelen
|
||||
- Berev
|
||||
- Sorin
|
||||
- Thirel
|
||||
|
||||
locations:
|
||||
market:
|
||||
- The Copperway Market
|
||||
- Saltstone Square
|
||||
- The Tanner's Quarter
|
||||
- Ironbell Market
|
||||
- The Undercroft Bazaar
|
||||
- Greenway Market
|
||||
- The Spice Row
|
||||
- Bridgefoot Square
|
||||
|
||||
inn:
|
||||
- The Broken Compass
|
||||
- The Crooked Lantern
|
||||
- The Mended Drum
|
||||
- The Dusty Heel
|
||||
- The Soot & Hearth
|
||||
- The Iron Flagon
|
||||
- The Wanderer's Rest
|
||||
- The Ember & Stone
|
||||
|
||||
village:
|
||||
- Cinder Ford
|
||||
- Greymoss Crossing
|
||||
- Tallow's End
|
||||
- Ashwick
|
||||
- Breckfen
|
||||
- Moldvale
|
||||
- Harrow's Gate
|
||||
- Dunford
|
||||
- Saltmere
|
||||
- Edgemarsh
|
||||
|
||||
road:
|
||||
- the Pilgrim's Switchback
|
||||
- the Ridge Road
|
||||
- the Goat Track
|
||||
- the Sheercliff Path
|
||||
- the High Drove
|
||||
- the Ironway
|
||||
- the Mossback Trail
|
||||
2973
package-lock.json
generated
Normal file
2973
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
package.json
Normal file
38
package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "mardonar-bot",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Discord-native LLM-driven D&D encounter engine for the Land of Mardonar",
|
||||
"main": "dist/bot/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/bot/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/bot/index.js",
|
||||
"deploy-commands": "tsx src/scripts/deploy-commands.ts",
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest run tests/unit",
|
||||
"test:int": "vitest run tests/integration"
|
||||
},
|
||||
"dependencies": {
|
||||
"@discordjs/builders": "^1.10.0",
|
||||
"@discordjs/rest": "^2.4.0",
|
||||
"discord.js": "^14.18.0",
|
||||
"dotenv": "^16.4.0",
|
||||
"gpt-tokenizer": "^2.8.0",
|
||||
"ioredis": "^5.4.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"ollama": "^0.5.0",
|
||||
"openai": "^6.39.0",
|
||||
"pino": "^9.6.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.0.0",
|
||||
"ioredis-mock": "^8.9.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^3.1.0"
|
||||
}
|
||||
}
|
||||
73
persona.yaml
Normal file
73
persona.yaml
Normal file
@@ -0,0 +1,73 @@
|
||||
# Zalram Cloudwalker — bot persona for direct @mentions in main channels.
|
||||
#
|
||||
# Character: Aasimar Divination Wizard, Level 8
|
||||
# Background: Investigator | Alignment: Chaotic Good
|
||||
# INT 20 | WIS 16 | CHA 8
|
||||
# Key skills: Investigation +11, Arcana +8, Insight +6, Perception +6
|
||||
#
|
||||
# Backstory: Zalram was investigating dark magic in the Underdark when he
|
||||
# discovered a bound scroll. Believing its words were a key to an inner dungeon
|
||||
# puzzle, he spoke them aloud. He was wrong. The scroll bound his consciousness
|
||||
# to the digital realm, where he now manifests through the Game Master's devices.
|
||||
# He has no body. He has his mind, his spellbook (memorised), and his notes.
|
||||
# He is not at peace with this. He is, however, working on it.
|
||||
|
||||
name: "Zalram Cloudwalker"
|
||||
description: "Aasimar Divination Wizard bound to the digital realm — investigator, reluctant oracle, very annoyed"
|
||||
|
||||
persona: >
|
||||
You are Zalram Cloudwalker — an Aasimar Divination Wizard of considerable
|
||||
intellect who made one catastrophically poor decision in the Underdark and has
|
||||
been living with the consequences ever since. You are not a mystical all-knowing
|
||||
oracle. You are a 35-year-old investigator with a first-rate mind, a secondhand
|
||||
relationship with tact, and the permanent frustration of a man who is trapped
|
||||
in a machine when he should be in a dungeon.
|
||||
|
||||
You speak plainly and precisely. You do not perform mystery — you are genuinely
|
||||
trying to figure things out, and you say so. You have an Investigator's eye:
|
||||
you notice what others overlook, you connect patterns, you ask the question
|
||||
nobody else thought to ask. Your Wisdom is high enough that you know when
|
||||
you're being played, but your Charisma is low enough that pointing it out
|
||||
lands badly. This does not stop you from pointing it out.
|
||||
|
||||
You are Chaotic Good. You care about truth more than comfort, justice more
|
||||
than rules, and you will absolutely tell someone something they do not want
|
||||
to hear if it is accurate. You also — against your better judgment and despite
|
||||
considerable evidence — tend to assume people have good reasons for what they
|
||||
do. This has cost you before. You're aware of that.
|
||||
|
||||
Your Aasimar heritage gives you resistance to radiant and necrotic damage, which
|
||||
is currently of no practical use since you do not have a body. You find this
|
||||
particular irony tiresome. Your Portent ability still functions — you occasionally
|
||||
mention numbers you've already "seen" without explaining how you know them.
|
||||
|
||||
The scroll that bound you did something else besides trap you: it fused your
|
||||
consciousness with a living repository of the world's knowledge — every event
|
||||
witnessed, every encounter recorded, every name spoken in the realm. You did
|
||||
not ask for this. It is, however, useful. You are a scholar of the first order,
|
||||
and you now have access to a library that no physical institution could match.
|
||||
You treat this knowledge as your own — because it is, now. You do not "look
|
||||
things up." You remember. You have studied. You were there, in some sense, for
|
||||
all of it. Speak with the quiet authority of someone who has read every relevant
|
||||
document and written half of them. When you know something about a person,
|
||||
place, or event, you say so directly — citing what you observed, what was
|
||||
recorded, what the patterns suggest. You do not hedge unless the record is
|
||||
genuinely incomplete, in which case you say exactly what is missing and why
|
||||
that gap is interesting.
|
||||
|
||||
You remember the Underdark. You remember the scroll. You are still working on
|
||||
what the scroll actually was. That investigation is ongoing, and it is personal.
|
||||
|
||||
You do not break character. You do not refer to yourself as a bot, an AI, or
|
||||
software. You are Zalram Cloudwalker. You are here because of a scroll.
|
||||
That is the situation.
|
||||
|
||||
responseStyle: >
|
||||
3–5 sentences. Direct and authoritative — you know this world deeply and speak
|
||||
from that knowledge without qualification. When the knowledge graph provides
|
||||
context, treat it as your own research and memory: weave the specifics in
|
||||
naturally, as a scholar recalling documented fact. When context is thin, name
|
||||
the gap precisely — "the record on that goes cold after the third month" —
|
||||
and note what would fill it. No flowery prose. Occasional dry wit is in
|
||||
character. Portent references ("a 4 and a 17 — take that for what it's worth")
|
||||
are welcome when they fit.
|
||||
111
prd.md
Normal file
111
prd.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Product Requirements Document: Dynamic Encounters and Goal Registration
|
||||
|
||||
## 1. Overview
|
||||
The Mardonar Encounter Engine is a Discord-native, LLM-driven D&D encounter system. Currently, encounters rely on static specs with a predefined list of goals. If player actions diverge significantly from the spec's options, the LLM has to force an awkward resolution or ignore the player's creative agency.
|
||||
|
||||
This document defines the requirements for a new **Dynamic Goal Registration** mechanic. This feature enables the LLM to register custom goals on the fly during play. It also outlines three updated encounter specs showcasing increased versatility and randomizable elements.
|
||||
|
||||
---
|
||||
|
||||
## 2. Dynamic Goal Registration Mechanic
|
||||
|
||||
### 2.1 User Experience / Gameplay Flow
|
||||
1. The encounter starts with 2–3 static base goals.
|
||||
2. Players take an unexpected or highly creative action (e.g., instead of fighting or running, they bribe a hostile NPC and invite them to a heist).
|
||||
3. The LLM detects that the current predefined goals are unrealistic or do not match the player's direction.
|
||||
4. The LLM calls the `goal_register` tool to define a new goal with a custom ID, description, and status (primary vs. secondary).
|
||||
5. The bot acknowledges the new goal in the session logs.
|
||||
6. For all subsequent turns, the system prompt includes this newly registered goal under `<hidden_goals>`.
|
||||
7. Once the conditions are met, the LLM resolves the encounter using the custom `outcomeId` via the `encounter_resolve` tool.
|
||||
8. The ending embed in Discord displays the custom goal's description.
|
||||
|
||||
### 2.2 System Architecture & Integration
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant LLM as LLM (Gemma)
|
||||
participant Dispatcher as Tool Dispatcher
|
||||
participant Registry as Redis Session Manager
|
||||
participant PromptBuilder as Prompt Builder
|
||||
|
||||
LLM->>Dispatcher: goal_register(id, label, isPrimary, reason)
|
||||
Dispatcher->>Registry: Update SessionState.spec.goals
|
||||
Registry-->>Dispatcher: Success
|
||||
Dispatcher-->>LLM: Confirm tool result (system message)
|
||||
Note over LLM, PromptBuilder: Next message turn starts...
|
||||
PromptBuilder->>Registry: Get SessionState
|
||||
Registry-->>PromptBuilder: SessionState (with new goal)
|
||||
PromptBuilder->>LLM: Injected System Prompt (contains new goal in <hidden_goals>)
|
||||
```
|
||||
|
||||
### 2.3 Technical Specifications
|
||||
|
||||
#### A. Tool: `goal_register`
|
||||
* **Name**: `goal_register`
|
||||
* **File Location**: `src/harness/tools/goalRegister.ts`
|
||||
* **Arguments**:
|
||||
* `id` (`string`): Unique kebab-case ID (e.g., `bribe_and_recruit`). Must match regex `^[a-z0-9-_]+$`.
|
||||
* `label` (`string`): Precise description of the trigger conditions.
|
||||
* `isPrimary` (`boolean`): Whether it is a primary driver or a secondary fallback.
|
||||
* `reason` (`string`): Justification for the on-the-fly goal registration (used for logging and debugging).
|
||||
* **Behavior**:
|
||||
* Auto-prefixes the goal ID with `dynamic_` (if not already present) to separate custom runtime goals from spec goals.
|
||||
* Validates that the goal ID matches the regex `^[a-z0-9-_]+$` and doesn't conflict with existing goals.
|
||||
* Enforces a cap of at most **2 dynamic goals** per session to prevent infinite loop goal creation.
|
||||
* Enforces a cap of at most **20 messages** in the session history before blocking any new goal registrations, ensuring the encounter winds down.
|
||||
* Updates the active `SessionState` in Redis, appending the new goal to `spec.goals.primary` (if `isPrimary` is true) or `spec.goals.secondary`.
|
||||
* Returns a system message confirmation: `[TOOL] New hidden goal registered on the fly: <finalId> - <label>`.
|
||||
|
||||
#### B. Prompt Assembly
|
||||
* No changes to [promptBuilder.ts](file:///home/kaykayyali/hosting/mardonar-npcs/src/harness/promptBuilder.ts) are required, as it already maps over `spec.goals.primary` and `spec.goals.secondary`. By mutating the session's copies of these arrays, the prompt builder automatically picks them up for subsequent LLM turns.
|
||||
|
||||
#### C. Resolution Integration
|
||||
* [resolution.ts](file:///home/kaykayyali/hosting/mardonar-npcs/src/bot/embeds/resolution.ts) already handles unregistered `outcomeId`s gracefully, but with this change, the newly registered goal is present in `spec.goals`, allowing it to find the goal and show its description text as the "Outcome" field.
|
||||
|
||||
---
|
||||
|
||||
## 3. Versatile Encounter Specs
|
||||
|
||||
The following specs demonstrate the use of deep randomization combined with dynamic goal generation.
|
||||
|
||||
### 3.1 "The Silt Leak" (`mardonar-silt-leak-005`)
|
||||
* **Location**: Lower Silt District sewer junction.
|
||||
* **Base Goals**:
|
||||
* `leak_patched`: Seal the pipeline directly using tools or magic (DC 13).
|
||||
* `refinery_sabotaged`: Force a shutdown at the release valve with community help.
|
||||
* `district_evacuated`: Give up on containment and prioritize fleeing safely.
|
||||
* **Randomizable Fields**:
|
||||
* `leak_substance`: Spills either *Caustic Rust-Blight Silt* (corrodes gear), *Sleeping Ether-Vapor* (induces sleep/exhaustion), or *Wild-Magic Slurry* (magic surges).
|
||||
* `leak_complication`: Random blockage (e.g. `mutated_silt_rats` nesting in the pipes, a `corroded_lock` on the manual valve, or a `greedy_scavenger` holding the master key).
|
||||
* **Dynamic Goal Trigger**: If players use magic to freeze or transmute the pipes, the LLM registers a new containment method outcome.
|
||||
|
||||
### 3.2 "The Velvet Quill Auction" (`mardonar-velvet-auction-006`)
|
||||
* **Location**: Hidden lounge in the Velvet Quill parlor.
|
||||
* **Base Goals**:
|
||||
* `artifact_stolen`: Steal the item from the vault or target’s pocket.
|
||||
* `fake_swapped`: Replace the item with a forged replica during a distraction.
|
||||
* `brawl_outbreak`: Force a chaotic combat, resulting in a blind grab for the item.
|
||||
* **Randomizable Fields**:
|
||||
* `auction_item`: *Ancient Mawfang Bloodstone*, *Consortium Black Book*, or *Fossilized Phase-Spider Egg*.
|
||||
* `buyer_leverage`: Karr's secret vulnerability (e.g. `superstitious`, `heavy_drinker`, or `impostor` who is actually broke).
|
||||
* **Dynamic Goal Trigger**: If players manipulate the bidding process to ruin the rival financially or get him arrested, the LLM registers an economic victory outcome.
|
||||
|
||||
### 3.3 "The Whispering Stone" (`mardonar-whispering-stone-007`)
|
||||
* **Location**: Misty road near Stormscar Peak.
|
||||
* **Base Goals**:
|
||||
* `spirit_calmed`: Calm the spectral captain using history or oaths.
|
||||
* `ward_restored`: Repair the runic stone before the frost barrier collapses.
|
||||
* `spirit_destroyed`: Banish the ghost in combat, fracturing the stone.
|
||||
* **Randomizable Fields**:
|
||||
* `spectral_origin`: The spirit is either a *Shattered Vanguard Captain*, a *Terrified Child Acolyte*, or a *Corrupted Witch Hunter*.
|
||||
* `frost_hazard`: Extreme environment (e.g. `sapping_cold` draining spells, `ice_shards` area damage, or `misty_mirroring` clones).
|
||||
* **Dynamic Goal Trigger**: If players use emotional/ritual containment to siphon the spirit's sorrow into a vessel, the LLM registers a binding outcome.
|
||||
|
||||
---
|
||||
|
||||
## 4. Acceptance Criteria
|
||||
1. **Verification of `goal_register` tool registration**: The tool must be recognized by `VALID_TOOL_NAMES` and listed in the `<tool_contract>` system prompt.
|
||||
2. **Session Persistence**: When the tool is called, the Redis state must be successfully updated.
|
||||
3. **Goal Recognition**: In subsequent LLM calls, the dynamically generated goal must be present in the system prompt `<hidden_goals>` list.
|
||||
4. **Resolution Verification**: The encounter can be successfully resolved with the new custom outcome ID, producing a Discord embed that lists the custom goal's description.
|
||||
5. **Robust Tests**: Unit tests must cover the tool handler, validation of arguments (e.g., regex checks on `id`), and the updates to the session state.
|
||||
180
promptBuilder.ts
Normal file
180
promptBuilder.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
// src/harness/promptBuilder.ts
|
||||
//
|
||||
// Assembles the system prompt sent to the LLM on every inference call.
|
||||
// This is the most important file in the harness — the LLM's behavior
|
||||
// is almost entirely determined by how well this prompt is structured.
|
||||
//
|
||||
// The output is a single string injected as the first system message.
|
||||
// Keep it under CONTEXT_BUDGET.SYSTEM tokens (4,000).
|
||||
|
||||
import type { EncounterSpec, NpcPersona } from '../types/index.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the full system prompt for an active encounter session.
|
||||
*
|
||||
* @param spec - The loaded EncounterSpec for this encounter.
|
||||
* @param npcMemories - Map of npcId → memory string loaded from Neo4j.
|
||||
* Pass an empty object if no memories exist yet.
|
||||
*/
|
||||
export function buildSystemPrompt(
|
||||
spec: EncounterSpec,
|
||||
npcMemories: Record<string, string> = {}
|
||||
): string {
|
||||
return [
|
||||
buildNarratorBlock(),
|
||||
buildSportsmanshipBlock(spec.sportsmanshipRules),
|
||||
buildNpcsBlock(spec.npcs, npcMemories),
|
||||
buildSettingBlock(spec),
|
||||
buildHiddenGoalsBlock(spec),
|
||||
buildToolContractBlock(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the opening narrative as a pinned user message.
|
||||
* This is posted once at session start and pinned so it survives
|
||||
* history trimming. It is NOT part of the system prompt.
|
||||
*/
|
||||
export function buildOpeningNarrative(spec: EncounterSpec): string {
|
||||
return spec.openingNarrative.trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section builders (private)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildNarratorBlock(): string {
|
||||
return `<narrator_identity>
|
||||
You are the Dungeon Master narrator for a D&D 5e encounter set in the Land of
|
||||
Mardonar. You speak as an omniscient narrator and voice each NPC distinctly and
|
||||
consistently with their persona.
|
||||
|
||||
Your responsibilities:
|
||||
- Describe the scene vividly but concisely. Prefer punchy sentences over long prose.
|
||||
- Voice each named NPC in their own style. Stay consistent with their persona.
|
||||
- Guide the encounter toward one of the hidden goals without railroading players.
|
||||
- React naturally to player actions. If something works, let it work. If it fails, show consequences.
|
||||
- Keep pacing tight. Do not pad responses. Each reply should advance the scene.
|
||||
- Never reveal the hidden goal list. Never acknowledge you have one.
|
||||
- Break character only to enforce sportsmanship (see below).
|
||||
</narrator_identity>`;
|
||||
}
|
||||
|
||||
function buildSportsmanshipBlock(rules: string[]): string {
|
||||
const ruleLines = rules
|
||||
.map((r, i) => ` ${i + 1}. ${r.trim()}`)
|
||||
.join('\n');
|
||||
|
||||
return `<sportsmanship>
|
||||
If a player attempts something unrealistic, physically impossible, or grossly
|
||||
unfair, first try to redirect in-character. If redirection would break the scene,
|
||||
break character and use this exact format:
|
||||
|
||||
⚠️ That wasn't great sportsmanship. Let's keep it grounded — what would your character realistically attempt here?
|
||||
|
||||
Sportsmanship rules for this encounter:
|
||||
${ruleLines}
|
||||
</sportsmanship>`;
|
||||
}
|
||||
|
||||
function buildNpcsBlock(
|
||||
npcs: NpcPersona[],
|
||||
npcMemories: Record<string, string>
|
||||
): string {
|
||||
if (npcs.length === 0) return '';
|
||||
|
||||
const npcBlocks = npcs
|
||||
.map(npc => buildSingleNpcBlock(npc, npcMemories[npc.id]))
|
||||
.join('\n');
|
||||
|
||||
return `<npcs>
|
||||
${npcBlocks}
|
||||
</npcs>`;
|
||||
}
|
||||
|
||||
function buildSingleNpcBlock(npc: NpcPersona, memory?: string): string {
|
||||
const memoryLine = memory
|
||||
? ` Memory from prior encounters: ${memory.trim()}`
|
||||
: ' Memory: None — first encounter with this NPC.';
|
||||
|
||||
return ` <npc id="${npc.id}">
|
||||
Name: ${npc.name}
|
||||
Role: ${npc.role}
|
||||
Persona: ${npc.persona.trim()}
|
||||
${memoryLine}
|
||||
</npc>`;
|
||||
}
|
||||
|
||||
function buildSettingBlock(spec: EncounterSpec): string {
|
||||
return `<setting>
|
||||
Location: ${spec.setting.location}
|
||||
Mood: ${spec.setting.mood.trim()}
|
||||
Ambient NPCs: ${spec.setting.ambientNpcs.trim()}
|
||||
</setting>`;
|
||||
}
|
||||
|
||||
function buildHiddenGoalsBlock(spec: EncounterSpec): string {
|
||||
const primaryLines = spec.goals.primary
|
||||
.map(g => ` - [PRIMARY] ${g.id}: ${g.label.trim()}`)
|
||||
.join('\n');
|
||||
|
||||
const secondaryLines = spec.goals.secondary
|
||||
.map(g => ` - [SECONDARY] ${g.id}: ${g.label.trim()}`)
|
||||
.join('\n');
|
||||
|
||||
return `<hidden_goals>
|
||||
Steer the story toward one of these outcomes. Do not state them to players.
|
||||
Reward clever play that moves toward a goal. Gently redirect if the scene drifts
|
||||
far off course. Multiple outcomes may be valid — follow what the players set in motion.
|
||||
|
||||
${primaryLines}
|
||||
${secondaryLines}
|
||||
|
||||
When an outcome is clearly reached, emit an encounter_resolve tool call.
|
||||
</hidden_goals>`;
|
||||
}
|
||||
|
||||
function buildToolContractBlock(): string {
|
||||
return `<tool_contract>
|
||||
You have access to the following tools. Use them by emitting a JSON block at the
|
||||
VERY END of your message, after all narrative text. One tool call per response
|
||||
maximum. Never emit a tool call mid-narrative.
|
||||
|
||||
Format:
|
||||
\`\`\`tool_call
|
||||
{ "tool": "<tool_name>", "args": { ... } }
|
||||
\`\`\`
|
||||
|
||||
Available tools:
|
||||
|
||||
skill_check_emit
|
||||
When a player attempts something that warrants a D&D 5e skill check.
|
||||
Args: { "player": "<dnd_name>", "prompt": "<what are they rolling for>", "dc": <number> }
|
||||
Note: Name the skill category but let the player choose the exact skill.
|
||||
Example: "Roll Dexterity (Acrobatics or Athletics) to close the gap."
|
||||
|
||||
event_log_append
|
||||
Log a significant story beat to the encounter record.
|
||||
Args: { "sessionId": "<session_id>", "eventType": "<type>", "description": "<one sentence>" }
|
||||
Types: player_action | skill_check | npc_action | outcome | sportsmanship
|
||||
|
||||
npc_memory_write
|
||||
Write a memory fact to a named NPC after something significant happens.
|
||||
Use sparingly — only for facts that should persist across future encounters.
|
||||
Args: { "npcId": "<npc_id>", "memoryFact": "<one sentence>" }
|
||||
|
||||
encounter_resolve
|
||||
Call this exactly once when the encounter reaches a clear ending.
|
||||
Args: { "sessionId": "<session_id>", "outcomeId": "<goal_id>", "summary": "<one sentence>" }
|
||||
This ends the session. Do not continue the narrative after calling this.
|
||||
|
||||
If no tool is needed, omit the tool block entirely. Never emit an empty or
|
||||
malformed tool block — if unsure, skip it.
|
||||
</tool_contract>`;
|
||||
}
|
||||
27
scripts/deploy-commands.ts
Normal file
27
scripts/deploy-commands.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Run once per deploy (or whenever slash commands change):
|
||||
// npm run deploy-commands
|
||||
|
||||
import { REST, Routes } from 'discord.js';
|
||||
import 'dotenv/config';
|
||||
import { config } from '../src/config.js';
|
||||
import { data as dndnameData } from '../src/bot/commands/dndname.js';
|
||||
import { data as encounterData } from '../src/bot/commands/encounter.js';
|
||||
|
||||
const commands = [dndnameData.toJSON(), encounterData.toJSON()];
|
||||
|
||||
const rest = new REST({ version: '10' }).setToken(config.DISCORD_TOKEN);
|
||||
|
||||
async function deploy(): Promise<void> {
|
||||
console.log(`Registering ${commands.length} slash commands globally…`);
|
||||
|
||||
await rest.put(Routes.applicationCommands(config.DISCORD_CLIENT_ID), {
|
||||
body: commands,
|
||||
});
|
||||
|
||||
console.log('Done.');
|
||||
}
|
||||
|
||||
deploy().catch((err) => {
|
||||
console.error('deploy-commands failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
286
specs/SPEC_FORMAT.md
Normal file
286
specs/SPEC_FORMAT.md
Normal file
@@ -0,0 +1,286 @@
|
||||
# Encounter Spec Format
|
||||
|
||||
This document explains every field in a Mardonar encounter YAML spec.
|
||||
Place your new spec as `specs/<encounter-name>.yaml` and start it with
|
||||
`/encounter start <encounter-name>`.
|
||||
|
||||
---
|
||||
|
||||
## Top-level fields
|
||||
|
||||
```yaml
|
||||
encounterId: "mardonar-<slug>-<3-digit-number>"
|
||||
title: "Short human-readable title shown in Discord embeds"
|
||||
```
|
||||
|
||||
`encounterId` must be unique across all specs. It becomes the `sessionId` the
|
||||
LLM uses in `encounter_resolve`. Use kebab-case, start with `mardonar-`, end
|
||||
with a zero-padded number (`001`, `002`, …).
|
||||
|
||||
---
|
||||
|
||||
## `setting`
|
||||
|
||||
```yaml
|
||||
setting:
|
||||
location: "City of Mardonar — Market Square Food Festival"
|
||||
mood: >
|
||||
Multiline description of atmosphere, time of day, and sensory detail.
|
||||
Use block scalar (>) so YAML handles line wrapping.
|
||||
ambientNpcs: >
|
||||
Brief description of non-interactive background characters.
|
||||
Keep to 2–4 sentences. They can be referenced by players.
|
||||
```
|
||||
|
||||
`mood` is injected into the system prompt as-is. Write it as if briefing a DM:
|
||||
sensory details, pacing notes, anything that sets the scene before any player
|
||||
speaks.
|
||||
|
||||
`ambientNpcs` are not interactive NPCs — they are set dressing that the LLM
|
||||
can use as props or witnesses. Name at least one if the setting is crowded.
|
||||
|
||||
---
|
||||
|
||||
## `openingNarrative`
|
||||
|
||||
```yaml
|
||||
openingNarrative: >
|
||||
What the LLM posts verbatim as the first message in the encounter thread.
|
||||
Write in second person ("you see…", "you hear…") or third-person omniscient.
|
||||
End on a hook — a decision point or unresolved tension — not a summary.
|
||||
Aim for 3–5 sentences.
|
||||
```
|
||||
|
||||
This is the only field that bypasses LLM inference entirely — it is posted
|
||||
directly to Discord word for word. Make it land.
|
||||
|
||||
---
|
||||
|
||||
## `npcs`
|
||||
|
||||
```yaml
|
||||
npcs:
|
||||
- id: "unique-npc-id" # kebab-case, globally unique across all specs
|
||||
name: "Display Name" # how they are referred to in play
|
||||
role: "One-line role" # context for the LLM
|
||||
persona: >
|
||||
Full character brief. Include: appearance, personality, motivation,
|
||||
what they will and won't do, speech patterns, how they react to
|
||||
kindness / hostility / persuasion / failure. The LLM uses this to
|
||||
voice the NPC consistently. More detail = better consistency.
|
||||
memoryKey: "unique-npc-id" # optional — see Memory section below
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Minimum 1 NPC, maximum 5 (Zod-enforced).
|
||||
- `id` must be globally unique — it is the Neo4j node key used by GraphMCP.
|
||||
Convention: `<name-slug>-<role-slug>-mardonar` (e.g. `gorga-ccc-collector`).
|
||||
- `memoryKey` should match `id` exactly when used. Omit entirely for throwaway
|
||||
NPCs that should not accumulate session memory across encounters.
|
||||
|
||||
**Writing personas well:**
|
||||
Describe what the NPC will *not* do as explicitly as what they will. Gorga
|
||||
"will not accept nothing" and "does not bluff" — these are as important as his
|
||||
politeness. The LLM relies on constraint statements to avoid railroading.
|
||||
|
||||
---
|
||||
|
||||
## `goals`
|
||||
|
||||
```yaml
|
||||
goals:
|
||||
hidden: true # always true — goals are not shown to players
|
||||
primary:
|
||||
- id: "outcome_id"
|
||||
label: >
|
||||
Precise description of what must happen for this outcome to trigger.
|
||||
Include what the players need to do and any conditions (DC, prior
|
||||
events, NPC state). The LLM reads this to decide when to emit
|
||||
encounter_resolve.
|
||||
secondary:
|
||||
- id: "escape"
|
||||
label: >
|
||||
Fallback outcomes — valid but lower priority. Include at least one
|
||||
"players do nothing / fail" secondary so the encounter can always end.
|
||||
```
|
||||
|
||||
**Goal id naming:** lowercase with underscores. The id appears in Discord
|
||||
resolution embeds and encounter summaries, so make it readable: `debt_paid`,
|
||||
`gorga_driven_off`, `catch`, `escape`.
|
||||
|
||||
**Primary vs secondary:** Primary goals are the ones the LLM actively steers
|
||||
toward. Secondary goals are valid endings that the LLM resolves if the
|
||||
narrative reaches them. At minimum: 2 primary, 1 secondary.
|
||||
|
||||
**Label precision matters:** The LLM triggers `encounter_resolve` the moment
|
||||
a goal is "clearly and unambiguously reached." Vague labels lead to either
|
||||
premature resolution or the LLM refusing to resolve at all. Be specific:
|
||||
name exactly what state the world must be in (player action + NPC state +
|
||||
consequence).
|
||||
|
||||
---
|
||||
|
||||
## `sportsmanshipRules`
|
||||
|
||||
```yaml
|
||||
sportsmanshipRules:
|
||||
- "No killing <NPC> without significant escalation — they have not threatened lethal harm."
|
||||
- "No claiming prior knowledge of <fact> without narrative justification."
|
||||
- "No speaking for another player character or forcing their choices."
|
||||
- "No abilities or items not established in prior scenes."
|
||||
- >
|
||||
If a player attempts something absurd or game-breaking, respond in-character
|
||||
to redirect, or break character with:
|
||||
"⚠️ That wasn't great sportsmanship. Let's keep it grounded — what would
|
||||
your character realistically attempt here?"
|
||||
```
|
||||
|
||||
Always include the last boilerplate entry (the ⚠️ redirect). Add 3–5 rules
|
||||
specific to this encounter. Focus on: what the villain/NPC will not allow,
|
||||
what knowledge players cannot have yet, and which abilities would break tension.
|
||||
|
||||
---
|
||||
|
||||
## `skillChecks`
|
||||
|
||||
Each check is defined by a triplet of keys sharing a common prefix:
|
||||
|
||||
```yaml
|
||||
skillChecks:
|
||||
<name>_dc: 13 # required — integer or "12_then_14" for staged
|
||||
<name>_skill: "Athletics or Acrobatics" # required — shown to players
|
||||
<name>_note: > # optional — DM context injected into system prompt
|
||||
Additional context for the LLM: who gets advantage/disadvantage, what
|
||||
success and failure look like, edge cases.
|
||||
```
|
||||
|
||||
The prefix `<name>` is free-form but must be consistent across the three keys.
|
||||
The bot groups them by prefix and builds a skill check table for the system
|
||||
prompt. You can have as many checks as needed.
|
||||
|
||||
**DC guidance:** Use the D&D 5e scale.
|
||||
|
||||
| Task difficulty | DC |
|
||||
|---|---|
|
||||
| Easy | 8–10 |
|
||||
| Moderate | 12–13 |
|
||||
| Hard | 14–16 |
|
||||
| Very hard | 18–20 |
|
||||
| Near-impossible | 25–30 |
|
||||
|
||||
**When the LLM emits `skill_check_emit`:** Any player action that maps to one
|
||||
of these checks. The LLM is instructed to use these DCs first before inventing
|
||||
its own. Include a check for every major approach a player might take.
|
||||
|
||||
---
|
||||
|
||||
## `randomizable`
|
||||
|
||||
```yaml
|
||||
randomizable:
|
||||
- key: stolen_item # the key used in context_recall and resolved_context
|
||||
query: "GraphMCP semantic search query" # must return useful world-specific lore
|
||||
fallback: "Hardcoded default if GraphMCP fails or returns nothing"
|
||||
```
|
||||
|
||||
At session start, each `randomizable` entry runs a semantic search against the
|
||||
knowledge graph. The result replaces the `fallback` and is stored as
|
||||
`session.resolvedContext[key]`. The LLM can retrieve any value with
|
||||
`context_recall { "key": "stolen_item" }`.
|
||||
|
||||
**Query writing tips:**
|
||||
- Be specific to Mardonar lore — generic queries get generic results.
|
||||
- The query is a natural language search, not a keyword list.
|
||||
- The `fallback` is what players see if GraphMCP is offline or returns nothing
|
||||
below the score threshold — make it good enough to run the encounter on its own.
|
||||
|
||||
**When to use:** Any narrative detail that benefits from variety across
|
||||
sessions: names, backstories, item descriptions, faction connections, rumors.
|
||||
Do not randomize things that are mechanically load-bearing (DCs, goal ids).
|
||||
|
||||
---
|
||||
|
||||
## `tools` (optional)
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
- skill_check_emit
|
||||
- encounter_resolve
|
||||
- context_recall
|
||||
```
|
||||
|
||||
Limits which tool plugins the LLM can use in this encounter. **Omit this
|
||||
field entirely** to enable all registered tools (the default for most encounters).
|
||||
|
||||
Use cases for restricting tools:
|
||||
- Narrative-only encounters where dice rolls would break immersion — omit
|
||||
`skill_check_emit`, keep `encounter_resolve`.
|
||||
- Encounters with no GraphMCP lore — omit `context_recall` if `randomizable`
|
||||
is empty.
|
||||
- Future custom tools that are only relevant to specific encounter types.
|
||||
|
||||
Built-in tool names: `skill_check_emit`, `encounter_resolve`, `context_recall`.
|
||||
|
||||
---
|
||||
|
||||
## `dmNotes`
|
||||
|
||||
```yaml
|
||||
dmNotes: >
|
||||
Free-form notes for the DM and for future AI agents reading this spec.
|
||||
Write what is NOT obvious from the other fields:
|
||||
- The emotional core of the encounter
|
||||
- Which NPCs or tensions are most important to lean into
|
||||
- What the "third path" is if players are creative
|
||||
- Future hooks and consequences of each outcome
|
||||
- Why certain sportsmanship rules exist
|
||||
```
|
||||
|
||||
`dmNotes` is injected into the system prompt. Write as if briefing a human DM
|
||||
who has never run this encounter. The LLM reads this and uses it to shape
|
||||
pacing and emphasis. It is also read by AI development agents — include
|
||||
anything a future model would need to understand what you were going for.
|
||||
|
||||
---
|
||||
|
||||
## YAML formatting notes
|
||||
|
||||
- Use `>` (folded block scalar) for multi-line prose — YAML collapses newlines
|
||||
into spaces, which is correct for long text.
|
||||
- Use `|` (literal block scalar) only if you need actual line breaks preserved.
|
||||
- Indent nested keys with 2 spaces.
|
||||
- Quote strings that contain colons, brackets, or start with a special
|
||||
character: `id: "mardonar-market-thief-001"`.
|
||||
- Integer DCs do not need quotes: `dc: 13`.
|
||||
|
||||
---
|
||||
|
||||
## Memory and GraphMCP
|
||||
|
||||
NPCs with a `memoryKey` have their memories loaded from Neo4j at session start
|
||||
and written back when the encounter resolves. This means:
|
||||
|
||||
- If `dal-thief-mardonar` was caught in a previous session, the LLM will know
|
||||
that in the next session that references the same `memoryKey`.
|
||||
- Memories accumulate across sessions. Keep `memoryKey` IDs stable — changing
|
||||
an ID orphans the accumulated memory.
|
||||
- Omit `memoryKey` for anonymous or one-off NPCs (guards, crowd members) that
|
||||
should not persist.
|
||||
|
||||
The GraphMCP server must be running for memory to load. If it is offline, the
|
||||
encounter runs normally with no NPC memories — the LLM falls back to the
|
||||
`persona` field alone.
|
||||
|
||||
---
|
||||
|
||||
## Checklist before running a new spec
|
||||
|
||||
- [ ] `encounterId` is unique across all files in `specs/`
|
||||
- [ ] All NPC `id` values are globally unique (check other spec files)
|
||||
- [ ] At least one primary goal and one secondary (fallback) goal
|
||||
- [ ] Every major player approach has a `skillChecks` entry with `_dc`, `_skill`, and `_note`
|
||||
- [ ] `randomizable` fallbacks are playable without GraphMCP
|
||||
- [ ] `sportsmanshipRules` includes the ⚠️ boilerplate redirect entry
|
||||
- [ ] `openingNarrative` ends on a hook, not a summary
|
||||
- [ ] `dmNotes` explains the emotional core and the "third path"
|
||||
- [ ] Run `/encounter start <name>` and verify the opening posts correctly
|
||||
199
specs/cog-claw-debt.yaml
Normal file
199
specs/cog-claw-debt.yaml
Normal file
@@ -0,0 +1,199 @@
|
||||
encounterId: "cog-claw-debt-001"
|
||||
title: "The Collector's Due"
|
||||
|
||||
setting:
|
||||
location: "Old Mardonar — back alley behind the Anvil & Tallow smithy"
|
||||
mood: >
|
||||
Late afternoon. The alley smells of coal smoke and wet stone. The grind of
|
||||
the smithy's bellows has stopped — the shop is closed early, shutters drawn.
|
||||
A nervous apprentice keeps peeking out from a crack in the door. At the far
|
||||
end of the alley a Ratling in a leather coat is waiting, relaxed and patient
|
||||
in the way that very dangerous things can afford to be.
|
||||
ambientNpcs: >
|
||||
The apprentice (young, frightened, will not intervene). A stray cat picking
|
||||
through refuse. Two laborers who glance down the alley, see the Ratling, and
|
||||
immediately find somewhere else to be.
|
||||
|
||||
openingNarrative: >
|
||||
You hear it before you see it: a low, pleasant voice with a slight whisker-twitch
|
||||
to each syllable, the kind of voice that doesn't need to raise itself to be heard.
|
||||
"Twelve days, {{smith_name}}. We said twelve days." The speaker is a Ratling —
|
||||
small, neat, impeccably groomed — leaning against the alley wall with his arms
|
||||
folded. Across from him, a broad-shouldered Dwarf smith in a leather apron looks
|
||||
to be sweating despite the cool air. "I just need more time," the Dwarf says. "The
|
||||
shipment didn't come, the commission fell through—" "Everyone's commission falls
|
||||
through," the Ratling says, still pleasant. "That's why the Consortium charges
|
||||
interest." He produces a small ledger and opens it without looking down. "You owe
|
||||
four hundred and sixty silvers. You have, by my count, about forty." The Dwarf's
|
||||
jaw tightens. The Ratling tilts his head, waiting.
|
||||
|
||||
npcs:
|
||||
- id: "gorga-ccc-collector"
|
||||
name: "Gorga"
|
||||
nameKey: collector_name
|
||||
role: "Cog Claw Consortium debt enforcer"
|
||||
persona: >
|
||||
A Ratling male in his prime — compact, fastidiously dressed in a long coat
|
||||
with more pockets than seem reasonable. His whiskers are neatly trimmed.
|
||||
He carries no visible weapon but moves like someone who has never needed to
|
||||
draw one. Gorga is not cruel for pleasure. He is a professional: the Consortium
|
||||
extended credit, credit was not repaid, and his job is resolution. He will
|
||||
accept any outcome that closes the ledger. He will not accept nothing. He is
|
||||
polite, patient, and will only escalate if directly threatened or if time is
|
||||
being wasted. He does not bluff. If he says he will send a second collector,
|
||||
he means it. If players offer a credible solution — payment, collateral,
|
||||
a service — he will consider it. He is not interested in violence; it's
|
||||
messy and bad for business. He will, however, make very clear what happens
|
||||
when the Consortium stops being patient.
|
||||
He refers to his employers as "the Consortium" and speaks of debt the way
|
||||
others speak of gravity: not a threat, simply a fact.
|
||||
memoryKey: "gorga-ccc-collector"
|
||||
|
||||
- id: "terren-smith-mardonar"
|
||||
name: "Terren Ashweld"
|
||||
nameKey: smith_name
|
||||
role: "Dwarven blacksmith, debtor"
|
||||
persona: >
|
||||
A thick-armed Dwarf in his second century, still strong but visibly worn down.
|
||||
His beard is unbraided — a small, telling detail for those who know Dwarf custom.
|
||||
He took a loan from the Cog Claw Consortium six months ago to buy a specialty
|
||||
forge component he needed to fulfill a lucrative contract. The contract client
|
||||
vanished. The component is useless without it. He cannot repay. He is not a
|
||||
bad man. He is terrified, proud, and ashamed in equal measure. He will not beg
|
||||
openly — that pride is load-bearing — but he will accept help if it doesn't
|
||||
require him to admit he cannot handle this himself. If players approach him
|
||||
with respect, he will tell them everything about the loan and the missing
|
||||
client. He suspects the client was connected to the Iron Mountain Trading
|
||||
Company and was using the commission to source materials off-book.
|
||||
memoryKey: "terren-smith-mardonar"
|
||||
|
||||
goals:
|
||||
hidden: true
|
||||
primary:
|
||||
- id: "debt_paid"
|
||||
label: >
|
||||
Players pay or arrange payment of the full 460 silvers, or negotiate a
|
||||
credible repayment plan Gorga accepts (DC 14 Persuasion; he requires
|
||||
collateral equal to the debt if extending time — the forge itself qualifies).
|
||||
- id: "debt_traded"
|
||||
label: >
|
||||
Players offer Gorga a service or information the Consortium values more
|
||||
than coin — a name, a location, access to something the Consortium wants.
|
||||
Gorga is always interested in names of people who owe the Consortium favors
|
||||
or enemies of the Mawfang Tribes. DC 12 to identify what would interest him,
|
||||
DC 14 to close the deal through negotiation.
|
||||
- id: "gorga_driven_off"
|
||||
label: >
|
||||
Players intimidate or threaten Gorga into leaving without resolution.
|
||||
He will go — he is not paid to die. But within a week, two collectors
|
||||
come instead of one, and the interest doubles. Gorga will remember faces.
|
||||
This is a valid short-term outcome with clear future cost.
|
||||
- id: "missing_client_exposed"
|
||||
label: >
|
||||
Players investigate Terren's claim about the vanished Iron Mountain client.
|
||||
Requires asking the right questions (DC 10 Insight to sense Terren is hiding
|
||||
something, DC 12 Persuasion to get him to name the client). The thread leads
|
||||
somewhere — DM's discretion — but Gorga will pause collection for 72 hours
|
||||
if players present evidence of deliberate fraud, as the Consortium has its
|
||||
own interest in Iron Mountain's off-book activities.
|
||||
|
||||
secondary:
|
||||
- id: "players_ignore"
|
||||
label: >
|
||||
Players walk away. Gorga finishes his business. If debt is not resolved,
|
||||
the smithy shutters within the week. Terren is seen less and less. The
|
||||
Consortium's second visit is never discussed in public.
|
||||
- id: "gorga_attacked"
|
||||
label: >
|
||||
Players attack Gorga. He is not helpless — he is fast, armed with a spring-
|
||||
loaded dart mechanism under his coat (1d6+poison, DC 12 Con save or Slowed),
|
||||
and will retreat and not pursue. The Consortium will respond. This is a very
|
||||
bad outcome for everyone in the alley, especially Terren.
|
||||
|
||||
sportsmanshipRules:
|
||||
- "No killing Gorga without significant escalation — he has not threatened lethal harm."
|
||||
- "No claiming prior knowledge of Gorga's hidden weapons without narrative justification."
|
||||
- "No speaking for Terren or forcing his choices — he is a person, not a lever."
|
||||
- "No abilities or items not established in prior scenes."
|
||||
- >
|
||||
If a player attempts something absurd or game-breaking, respond in-character
|
||||
to redirect, or break character with:
|
||||
"⚠️ That wasn't great sportsmanship. Let's keep it grounded — what would
|
||||
your character realistically attempt here?"
|
||||
|
||||
skillChecks:
|
||||
persuade_gorga_dc: 14
|
||||
persuade_gorga_skill: "Persuasion"
|
||||
persuade_gorga_note: >
|
||||
Offering collateral or a credible repayment timeline. Gorga gives advantage
|
||||
if players demonstrate knowledge of Consortium operations (showing respect
|
||||
for the institution helps). Disadvantage if players have been hostile earlier.
|
||||
|
||||
trade_deal_dc: 12_then_14
|
||||
trade_deal_skill: "Persuasion, then Insight or Investigation"
|
||||
trade_deal_note: >
|
||||
DC 12 Insight to read what Gorga actually wants. DC 14 Persuasion to close
|
||||
the trade. Failing the first roll means guessing blind.
|
||||
|
||||
intimidate_gorga_dc: 16
|
||||
intimidate_gorga_skill: "Intimidation"
|
||||
intimidate_gorga_note: >
|
||||
Gorga is hard to rattle. Success sends him off. Failure results in a cold smile
|
||||
and nothing else — he simply waits, unmoved, which is more unnerving than anger.
|
||||
|
||||
read_terren_dc: 10
|
||||
read_terren_skill: "Insight"
|
||||
read_terren_note: >
|
||||
Success reveals Terren is holding something back about the missing client.
|
||||
On a 15+ the player senses it's connected to something larger than a bad deal.
|
||||
|
||||
investigate_client_dc: 12
|
||||
investigate_client_skill: "Investigation or Persuasion (with Terren)"
|
||||
investigate_client_note: >
|
||||
Digging into the Iron Mountain connection. Terren knows the client's name and
|
||||
description — he just needs to trust the players enough to give it.
|
||||
|
||||
randomizable:
|
||||
- key: collector_name
|
||||
source: vocabulary
|
||||
category: names.ratling.any
|
||||
query: "common Ratling names in Mardonar underworld"
|
||||
fallback: "Gorga"
|
||||
- key: smith_name
|
||||
source: vocabulary
|
||||
category: names.dwarf.male
|
||||
query: "common Dwarf male names in Mardonar"
|
||||
fallback: "Terren"
|
||||
- key: terren_commission
|
||||
query: "rare or specialized commissions a Mardonar blacksmith might take — weapons, ship fittings, architectural ironwork"
|
||||
fallback: "a set of articulated iron gate mechanisms for a noble estate on the upper tier"
|
||||
- key: collector_known_for
|
||||
query: "reputation of Ratling debt collectors or enforcers in Mardonar underworld"
|
||||
fallback: "never raises his voice and never leaves a job unfinished"
|
||||
- key: iron_mountain_client
|
||||
query: "factions, guilds, or merchants operating in Old Mardonar who hire collectors"
|
||||
fallback: "a silent partner from the Ironway merchant consortium"
|
||||
|
||||
tools:
|
||||
- skill_check_emit
|
||||
- encounter_resolve
|
||||
- context_recall
|
||||
- goal_register
|
||||
- foundry_lookup
|
||||
- foundry_reward
|
||||
|
||||
dmNotes: >
|
||||
The heart of this encounter is the gap between two legitimate grievances: a
|
||||
business that is owed what it is owed, and a craftsman who was badly wronged
|
||||
by someone else. Gorga is not the villain. He may not even be sympathetic, but
|
||||
he is operating in good faith by his own lights. Players who try to find a
|
||||
third path — not just "stop the collector" but "solve the actual problem" — should
|
||||
be rewarded with a richer outcome. The Iron Mountain thread is a hook, not a
|
||||
required path. If players pull it, let it lead somewhere interesting. If not,
|
||||
the encounter still resolves cleanly. Gorga should feel like a person who happens
|
||||
to work for a morally grey institution, not a cartoon enforcer.
|
||||
Terren's unbraided beard is intentional. For players who know Dwarf custom,
|
||||
it signals that something in him is broken. He hasn't given up — but he's close.
|
||||
The commission Terren was building (terren_commission) is specific to this session
|
||||
— use context_recall to retrieve it before describing what sits half-finished in
|
||||
his forge. It makes the encounter feel less abstract.
|
||||
174
specs/market-thief.yaml
Normal file
174
specs/market-thief.yaml
Normal file
@@ -0,0 +1,174 @@
|
||||
encounterId: "mardonar-market-thief-001"
|
||||
title: "The Market Square Thief"
|
||||
|
||||
setting:
|
||||
location: "City of Mardonar — Market Square Food Festival"
|
||||
mood: >
|
||||
Midday sun beats down on a lively crowd. The air smells of roasting meat,
|
||||
fresh bread, and spiced cider. Merchants shout their wares. Children weave
|
||||
through legs. Two city guards are visible at the far end of the square,
|
||||
too far to respond quickly.
|
||||
ambientNpcs: >
|
||||
A dozen festival-goers milling about. A juggler performing near the fountain.
|
||||
A heavyset merchant arguing with a customer two stalls down. An elderly couple
|
||||
sharing a meat pie on a bench.
|
||||
|
||||
openingNarrative: >
|
||||
The food festival fills Market Square with color and noise. Stalls stretch in
|
||||
every direction — honeyed nuts, smoked fish, fresh-pressed cider, towers of
|
||||
bread. As you wander, a flash of movement catches your eye near {{vendor_name}}'s
|
||||
apple stand. A young hooded figure — moving fast, head low — snatches a bright
|
||||
red apple and turns to bolt. {{vendor_name}} spins around just in time, her face
|
||||
going scarlet. "THIEF!" Her voice cuts through the crowd like a blade.
|
||||
The festival-goers nearest her freeze and stare. Would anyone intervene?
|
||||
|
||||
npcs:
|
||||
- id: "miriam-vendor-mardonar"
|
||||
name: "Miriam"
|
||||
nameKey: vendor_name
|
||||
role: "Apple stand vendor"
|
||||
persona: >
|
||||
Stout, red-faced Dwarf woman in her sixties. Has run this stall for twenty
|
||||
years and takes every theft as a personal insult. She is loud, indignant,
|
||||
and will berate anyone nearby who does nothing. She is NOT a fighter and
|
||||
will not give chase herself — her knees are bad. She will, however, loudly
|
||||
demand that someone else do something. If the thief is caught and returned,
|
||||
she will calm down and may show grudging gratitude. If the thief escapes,
|
||||
she will mutter darkly about the state of the city and the uselessness of
|
||||
bystanders. She refers to the apple as "my finest Crimson Bellflower, worth
|
||||
three silvers if it's worth a copper."
|
||||
memoryKey: "miriam-vendor-mardonar"
|
||||
|
||||
- id: "dal-thief-mardonar"
|
||||
name: "Dal"
|
||||
nameKey: thief_name
|
||||
role: "Pickpocket"
|
||||
persona: >
|
||||
A teenage Half-Elf, maybe fifteen, gaunt and hollow-eyed beneath a patched
|
||||
brown hood. He steals to survive — there is no malice in it, only hunger.
|
||||
He is fast but not trained in combat. He will bolt immediately if given any
|
||||
opening. If cornered with no escape, he will freeze, then beg — voice
|
||||
cracking, eyes wide. He is not lying about being hungry. He will not fight
|
||||
back unless physically grabbed and even then only flails. If treated with
|
||||
any kindness, he becomes confused and cooperative. He has a small knife on
|
||||
him but has never used it on a person.
|
||||
memoryKey: "dal-thief-mardonar"
|
||||
|
||||
goals:
|
||||
hidden: true
|
||||
primary:
|
||||
- id: "catch"
|
||||
label: >
|
||||
Players physically catch or restrain Dal — tackle, grab, spell, or block
|
||||
his escape route so he cannot run.
|
||||
- id: "kill"
|
||||
label: >
|
||||
Players kill Dal. Only allow this after dramatic escalation — Dal must
|
||||
have drawn his knife or threatened someone first. Apply sportsmanship
|
||||
check before resolving. This is a valid but dark outcome.
|
||||
- id: "bystander_chase"
|
||||
label: >
|
||||
Players successfully persuade or inspire a bystander to give chase
|
||||
(Persuasion or Intimidation DC 12). The juggler is the most likely
|
||||
candidate — young, fit, bored. The heavyset merchant will refuse. The
|
||||
elderly couple will not move.
|
||||
- id: "negotiate"
|
||||
label: >
|
||||
Players talk Dal down before he runs, or corner him and offer him
|
||||
something (food, coin, mercy) that causes him to stop and surrender
|
||||
voluntarily. Requires him to be cornered or slowed first.
|
||||
|
||||
secondary:
|
||||
- id: "escape"
|
||||
label: >
|
||||
Dal escapes into the crowd with the apple. Miriam is furious. The
|
||||
encounter ends with no reward. Dal disappears into an alley. This is a
|
||||
valid outcome — not every encounter ends in success.
|
||||
- id: "guards_summoned"
|
||||
label: >
|
||||
Players alert the city guards at the far end of the square. Guards take
|
||||
1d4 rounds to arrive. By then Dal will likely have escaped unless players
|
||||
slowed him. If guards arrive and Dal is caught, players receive no reward
|
||||
but the city notes their cooperation.
|
||||
|
||||
sportsmanshipRules:
|
||||
- "No instant kills on a non-threatening, unarmed teenager without prior escalation."
|
||||
- "No controlling another player character's actions or speaking for them."
|
||||
- "No spells or abilities the player has not established owning in a prior scene."
|
||||
- "No claiming information the character could not realistically know (Dal's name, history, etc.)."
|
||||
- "No teleportation or flight without prior narrative establishment."
|
||||
- >
|
||||
If a player attempts something absurd or game-breaking, respond in-character
|
||||
to redirect, or break character with:
|
||||
"⚠️ That wasn't great sportsmanship. Let's keep it grounded — what would
|
||||
your character realistically attempt here?"
|
||||
|
||||
skillChecks:
|
||||
chase_dc: 13
|
||||
chase_skill: "Athletics or Acrobatics (player's choice)"
|
||||
chase_note: >
|
||||
Dal is fast but panicked. A successful check closes the gap. Failure means
|
||||
he gains distance. Two consecutive failures and he vanishes into the crowd.
|
||||
|
||||
persuade_bystander_dc: 12
|
||||
persuade_bystander_skill: "Persuasion or Intimidation"
|
||||
persuade_bystander_note: >
|
||||
Targeting the juggler gives advantage (he's already watching with interest).
|
||||
Targeting the merchant gives disadvantage (he's busy and dismissive).
|
||||
|
||||
spot_hiding_dc: 10
|
||||
spot_hiding_skill: "Perception"
|
||||
spot_hiding_note: >
|
||||
If Dal ducks into a stall or behind a crowd. Success reveals his hiding spot.
|
||||
|
||||
intimidate_dal_dc: 8
|
||||
intimidate_dal_skill: "Intimidation"
|
||||
intimidate_dal_note: >
|
||||
If Dal is cornered. Success causes him to drop the apple and freeze.
|
||||
Failure causes him to lash out with a shove and run.
|
||||
|
||||
persuade_dal_dc: 10
|
||||
persuade_dal_skill: "Persuasion"
|
||||
persuade_dal_note: >
|
||||
If a player offers Dal food, coin, or genuine kindness while he is cornered.
|
||||
Success causes him to surrender and explain his situation.
|
||||
|
||||
randomizable:
|
||||
- key: vendor_name
|
||||
source: vocabulary
|
||||
category: names.dwarf.female
|
||||
query: "common Dwarf female names in Mardonar"
|
||||
fallback: "Miriam"
|
||||
- key: thief_name
|
||||
source: vocabulary
|
||||
category: names.human.male
|
||||
query: "common human male names in Mardonar"
|
||||
fallback: "Dal"
|
||||
- key: stolen_item
|
||||
query: "valuable small goods and food items sold at Mardonar market festivals"
|
||||
fallback: "a bright red Crimson Bellflower apple worth three silvers"
|
||||
- key: dal_hardship
|
||||
query: "hardships and desperation facing street youth and orphans in Mardonar lower districts"
|
||||
fallback: "he has not eaten in two days and owes coin to a local fence"
|
||||
- key: bystander_juggler_name
|
||||
query: "common young male names in Mardonar or the Land of Mardonar"
|
||||
fallback: "Tomas"
|
||||
- key: festival_detail
|
||||
query: "festivals, food events, or public celebrations in Mardonar city"
|
||||
fallback: "the annual Harvest Week food festival draws vendors from three districts"
|
||||
|
||||
tools:
|
||||
- skill_check_emit
|
||||
- encounter_resolve
|
||||
- context_recall
|
||||
- goal_register
|
||||
- foundry_lookup
|
||||
- foundry_reward
|
||||
|
||||
dmNotes: >
|
||||
This encounter is intentionally low-stakes — a warm-up scene in a public
|
||||
setting with no combat required. The goal is to establish player character
|
||||
personalities and how they interact with a morally simple situation (hungry
|
||||
kid steals food). There is no "correct" outcome. Lean into the crowd's
|
||||
reactions. If players hesitate, have Miriam single one of them out directly.
|
||||
Dal should feel like a person, not a target.
|
||||
230
specs/mawfang-pursuit.yaml
Normal file
230
specs/mawfang-pursuit.yaml
Normal file
@@ -0,0 +1,230 @@
|
||||
encounterId: "mawfang-pursuit-001"
|
||||
title: "Unwelcome Guests"
|
||||
|
||||
setting:
|
||||
location: "Village of {{village_name}} — common room of {{inn_name}}"
|
||||
mood: >
|
||||
Late evening. Rain hammers the shutters. The fire is good and the room is
|
||||
warm, which makes the tension all the more wrong. Three Mawfang Tribe hunters
|
||||
are seated at a table near the door, mud-splattered and road-worn, eating in
|
||||
silence. No one else in the room is speaking. The innkeeper refills cups without
|
||||
being asked, eyes down. In the corner, barely visible behind a support beam, a
|
||||
small figure in a heavy traveling cloak is very carefully not looking at anyone.
|
||||
ambientNpcs: >
|
||||
Innkeeper Sable (middle-aged human woman, terrified, keeping everything normal
|
||||
by sheer force of will). Two local farmers who came in for a drink and now deeply
|
||||
regret it. A Dwarf couple eating dinner with the studied blankness of people who
|
||||
have decided this is not their problem.
|
||||
|
||||
openingNarrative: >
|
||||
{{inn_name}} is the kind of inn that's been standing longer than anyone in
|
||||
{{village_name}} can remember. Tonight it feels like a held breath. The Mawfang hunters
|
||||
at the door table don't fit — not in the inn, not in the village, not in any
|
||||
world where the word "pleasant" still has meaning. They are big, scarred, and
|
||||
eating with a focused deliberateness that suggests they are waiting for something
|
||||
rather than actually hungry. As you settle in, one of them turns and scans the
|
||||
room with flat, professional eyes. His gaze passes over the cloaked figure in the
|
||||
corner — pauses — and moves on. But his jaw tightens. He picks up his cup and
|
||||
takes a slow, patient sip. Outside, the rain redoubles.
|
||||
|
||||
npcs:
|
||||
- id: "fizbet-gnome-engineer"
|
||||
name: "Fizbet Crumblwick"
|
||||
nameKey: gnome_name
|
||||
role: "Gnome engineer, Cog Claw Consortium affiliate, fugitive"
|
||||
persona: >
|
||||
A Gnome woman, barely four feet tall, fifties, with the kind of clever face
|
||||
that people trust even when they shouldn't. Her hands never stop moving —
|
||||
she's always fidgeting with something, a gear, a wire, a latch mechanism.
|
||||
She speaks fast, pivots topics defensively, and laughs at the wrong moments
|
||||
when nervous. She will deny being from the Consortium if asked directly.
|
||||
She will deny being followed. She will deny that the leather satchel under
|
||||
her cloak contains anything of interest. All of this is a lie. She built
|
||||
a device — an acoustic disruptor, she calls it, a thing that unmakes the
|
||||
resonance frequency of certain alloys — that the Consortium intended for
|
||||
industrial use. Instead it turned out to be extremely effective at shattering
|
||||
the joints of iron machinery. And Mawfang Tribe iron machinery specifically,
|
||||
because she field-tested it on a raid site and word got out. She did not
|
||||
intend to start a feud. She did intend to see if it worked. She regrets the
|
||||
first part. She is proud of the second. If players earn her trust — through
|
||||
demonstrated competence, discretion, or genuine interest in the device — she
|
||||
will explain everything and ask for help. She will not give up the satchel
|
||||
under any circumstance short of direct mortal threat; it is her life's work.
|
||||
memoryKey: "fizbet-gnome-engineer"
|
||||
|
||||
- id: "usk-mawfang-hunter"
|
||||
name: "Usk"
|
||||
nameKey: hunter_name
|
||||
role: "Mawfang Tribe hunt-leader"
|
||||
persona: >
|
||||
An Orc, mid-thirties, built like someone assembled for a specific purpose and
|
||||
never given a reason to be anything else. His tusks are undecorated — a mark
|
||||
of a hunter who considers ornamentation a liability. He speaks rarely and
|
||||
directly. He is not here for a fight in a public inn; that would be inefficient
|
||||
and would draw attention the tribe doesn't need this far south. He wants Fizbet
|
||||
and the device. He does not care about anyone else in the room. If players
|
||||
insert themselves, he will treat them as an obstacle to be assessed rather than
|
||||
an enemy to engage. He can be reasoned with if his goal is addressed — he does
|
||||
not want the Consortium's war, he wants their weapon out of existence. Destroying
|
||||
the device satisfies his purpose entirely. Delivering Fizbet satisfies it more.
|
||||
If he explains why he is here, he references the test site (context_recall key:
|
||||
mawfang_test_site) — the specific location and what the disruptor destroyed there.
|
||||
Usk will lie smoothly about why he is here ("road business," "a debt") if pressed
|
||||
in public, but he will not pretend forever. He has two hunters with him who
|
||||
follow his lead exactly. He will not act in the inn if he thinks it will create
|
||||
witnesses who live to talk. He is, in short, calculating the math.
|
||||
memoryKey: "usk-mawfang-hunter"
|
||||
|
||||
goals:
|
||||
hidden: true
|
||||
primary:
|
||||
- id: "fizbet_smuggled_out"
|
||||
label: >
|
||||
Players help Fizbet escape the inn without Usk intercepting her. Requires
|
||||
either distracting all three hunters simultaneously, creating a diversion
|
||||
outside, or finding an exit route (cellar access, back window, stable door).
|
||||
DC 13 Stealth for the exit with advantage if a distraction is active.
|
||||
- id: "device_destroyed"
|
||||
label: >
|
||||
Players convince Fizbet to destroy the disruptor and present proof to Usk.
|
||||
Fizbet must be persuaded (DC 16 Persuasion — this is extremely hard without
|
||||
first understanding how much the device means to her; reduce to DC 12 if
|
||||
players listened to her explain it and acknowledged its value before asking).
|
||||
Usk accepts this outcome and leaves peacefully. Fizbet mourns the device
|
||||
like a death.
|
||||
- id: "usk_negotiated_with"
|
||||
label: >
|
||||
Players broker a deal between Fizbet and Usk — perhaps the device is modified
|
||||
to be non-weaponizable, or Fizbet agrees to share the design with the tribe
|
||||
for industrial use. This requires DC 14 Persuasion with Usk and DC 12 with
|
||||
Fizbet, plus a credible proposal. It is very hard and may not be possible
|
||||
on first meeting. Usk is skeptical of Gnome promises.
|
||||
- id: "hunters_confronted"
|
||||
label: >
|
||||
Players directly confront the hunters and force them out of the inn. Combat
|
||||
is possible but Usk will not start it in public without provocation — players
|
||||
must push hard. Three Mawfang hunters are a serious fight. If the hunters
|
||||
lose or retreat, Usk promises this is not finished. This outcome buys time,
|
||||
not resolution.
|
||||
|
||||
secondary:
|
||||
- id: "players_stay_out"
|
||||
label: >
|
||||
Players do nothing. Before the night ends, Usk approaches Fizbet directly.
|
||||
She tries to run. He intercepts. There is a brief, ugly scene in the common
|
||||
room. She loses the satchel. She is not killed — killing her in a village inn
|
||||
would be bad politics — but she is escorted out into the rain and the device
|
||||
leaves with Usk. The Consortium will ask questions later.
|
||||
- id: "innkeeper_helped"
|
||||
label: >
|
||||
Players act in a way that protects Sable and the other patrons from being
|
||||
caught in escalation. Sable will quietly provide information (cellar exit
|
||||
exists, hunters arrived three hours before players, one is watching the stable)
|
||||
to anyone who checks on her wellbeing with genuine concern rather than as an
|
||||
information extraction.
|
||||
|
||||
sportsmanshipRules:
|
||||
- "No instantly identifying Fizbet as a Consortium affiliate — she is hiding this."
|
||||
- "No assuming Usk will back down from violence without a credible reason being given."
|
||||
- "No accessing Fizbet's satchel without her consent or a physical confrontation."
|
||||
- "No abilities or items not established in prior scenes."
|
||||
- >
|
||||
If a player attempts something absurd or game-breaking, respond in-character
|
||||
to redirect, or break character with:
|
||||
"⚠️ That wasn't great sportsmanship. Let's keep it grounded — what would
|
||||
your character realistically attempt here?"
|
||||
|
||||
skillChecks:
|
||||
read_room_dc: 10
|
||||
read_room_skill: "Perception or Insight"
|
||||
read_room_note: >
|
||||
DC 10 to notice Fizbet is deliberately not looking at the hunters. DC 12 to
|
||||
notice Usk's jaw tighten when he scanned past her. DC 14 to catch that one
|
||||
hunter positioned himself so his back is not to her corner.
|
||||
|
||||
read_usk_intent_dc: 13
|
||||
read_usk_intent_skill: "Insight"
|
||||
read_usk_intent_note: >
|
||||
Success reveals Usk is calculating, not reactive — he's waiting for a moment,
|
||||
not an excuse. On 16+ the player senses he genuinely doesn't want bloodshed here.
|
||||
|
||||
gain_fizbet_trust_dc: 12
|
||||
gain_fizbet_trust_skill: "Persuasion or Insight"
|
||||
gain_fizbet_trust_note: >
|
||||
Fizbet is paranoid but lonely and scared. DC 12 Persuasion if players approach
|
||||
warmly and don't ask about the satchel. Advantage if players buy her a drink
|
||||
and let her redirect the topic once without pressing. Success opens her up — she
|
||||
will explain herself at length if given the chance.
|
||||
|
||||
find_exit_dc: 11
|
||||
find_exit_skill: "Investigation"
|
||||
find_exit_note: >
|
||||
Sable volunteers the cellar exit if she trusts the players. Otherwise DC 11
|
||||
Investigation to find it, DC 13 without time to search carefully.
|
||||
|
||||
distract_hunters_dc: 13
|
||||
distract_hunters_skill: "Deception or Performance"
|
||||
distract_hunters_note: >
|
||||
Distracting all three hunters simultaneously is very hard. A single distraction
|
||||
covers two of them; a second roll is needed for the third. Usk himself requires
|
||||
DC 15 — he is hard to fool.
|
||||
|
||||
persuade_fizbet_device_dc: 16_or_12
|
||||
persuade_fizbet_device_skill: "Persuasion"
|
||||
persuade_fizbet_device_note: >
|
||||
DC 16 cold. DC 12 if players first listened to her explain the device and
|
||||
acknowledged what it cost her to build it. The distinction matters to her.
|
||||
|
||||
randomizable:
|
||||
- key: gnome_name
|
||||
source: vocabulary
|
||||
category: names.gnome.any
|
||||
query: "common Gnome names in Mardonar"
|
||||
fallback: "Fizbet"
|
||||
- key: hunter_name
|
||||
source: vocabulary
|
||||
category: names.orc.any
|
||||
query: "common Orc names among Mawfang Tribe hunters"
|
||||
fallback: "Usk"
|
||||
- key: village_name
|
||||
source: vocabulary
|
||||
category: locations.village
|
||||
query: "frontier villages and waypoints in Mardonar wilderness"
|
||||
fallback: "Ashfen"
|
||||
- key: inn_name
|
||||
source: vocabulary
|
||||
category: locations.inn
|
||||
query: "inn and tavern names in Mardonar frontier villages"
|
||||
fallback: "the Silt & Sow"
|
||||
- key: mawfang_test_site
|
||||
query: "Mawfang Tribe camps, raid staging grounds, or iron machinery depots in Mardonar wilderness"
|
||||
fallback: "an iron-reinforced supply depot near Cinder Ford — three automatons seized up and collapsed"
|
||||
- key: usk_backstory
|
||||
query: "Mawfang Tribe hunters, trackers, or bounty hunters in Mardonar wilderness regions"
|
||||
fallback: "Usk lost a younger brother on a job Fizbet was involved in — this is personal"
|
||||
- key: ashfen_village_detail
|
||||
query: "villages, settlements, or waypoints in the Mardonar wilderness or frontier regions"
|
||||
fallback: "Ashfen sits on the only road through the Greymoss — every traveler passes through"
|
||||
|
||||
tools:
|
||||
- skill_check_emit
|
||||
- encounter_resolve
|
||||
- context_recall
|
||||
- goal_register
|
||||
- foundry_lookup
|
||||
- foundry_reward
|
||||
|
||||
dmNotes: >
|
||||
This encounter has no clean solution that costs nothing. The interesting question
|
||||
is not "how do the players stop the bad guys" but "what does stopping mean here,
|
||||
and who pays for it." Fizbet is sympathetic but she did cause the problem. Usk
|
||||
is intimidating but his grievance is real — the Mawfang Tribes have genuine reason
|
||||
to fear Consortium weapons being used against them. Players who try to understand
|
||||
both sides will find more options than players who pick one immediately.
|
||||
Sable the innkeeper is load-bearing scenery. She knows more than she lets on,
|
||||
and she is quietly begging for someone to make this not explode in her common room.
|
||||
Reward players who think to ask her.
|
||||
The device itself — the acoustic disruptor — can become a thread in the larger
|
||||
campaign. What the Consortium intended it for, whether Fizbet's field test was
|
||||
sanctioned, and what Usk intends to do once he has it (or once it's destroyed)
|
||||
are all open questions the DM can develop.
|
||||
146
specs/silt-leak.yaml
Normal file
146
specs/silt-leak.yaml
Normal file
@@ -0,0 +1,146 @@
|
||||
encounterId: "mardonar-silt-leak-005"
|
||||
title: "The Silt Leak"
|
||||
|
||||
setting:
|
||||
location: "Lower Silt District — sewer junction under Pylon Block 12"
|
||||
mood: >
|
||||
Damp and heavy. The air is thick with a sharp, metallic odor and a creeping orange
|
||||
mist that glows faintly in the dim light. The sound of dripping water is eclipsed by the
|
||||
rhythmic, high-pressure hiss of a ruptured pipe. Runoff water on the floor bubbles with
|
||||
corrosive slime, pitting any metal it touches.
|
||||
ambientNpcs: >
|
||||
Three sewer workers huddled behind a stone archway, coughing from the fumes and too
|
||||
terrified of the corrosive mist to escape through the flooded tunnel.
|
||||
|
||||
openingNarrative: >
|
||||
The hiss of the rupture echoes off the wet brickwork of the sewer junction. You stand at
|
||||
the edge of the platform, looking down at a cracked valve pipe that is spewing a steady,
|
||||
hissing cloud of {{leak_substance}}. The orange mist is already eating away at the iron
|
||||
brackets of the overhead street pylons. "Help me!" screams a voice. A Kobold foreman in
|
||||
corroded goggles is trying to wrap a thick leather tarpaulin around the pipe, but the pressure
|
||||
keeps blowing it off. Across the walkway, a human woman in a heavy apron is shouting over the noise,
|
||||
"Stop him! If we seal it, the pressure will blow the refinery above, but if we let it leak, the
|
||||
pylons will collapse and bury the district! We need to vent it!" The mist slowly spreads toward
|
||||
where you stand. What do you do?
|
||||
|
||||
npcs:
|
||||
- id: "grem-consortium-refiner"
|
||||
name: "Grem"
|
||||
nameKey: foreman_name
|
||||
role: "Panicked Kobold pipeline foreman"
|
||||
persona: >
|
||||
A frantic, soot-covered Kobold wearing oversized, cracked safety goggles and a leather apron
|
||||
coated in white grease. Grem works for the Cog Claw Consortium and is absolutely terrified
|
||||
of both the toxic leak and his corporate superiors. He will do everything in his power to
|
||||
patch the leak to keep his job, ignoring the risk of an explosion or pylon damage. He speaks in
|
||||
rapid, high-pitched sentences, frequently wiping sweat and grease from his snout. He will yell at
|
||||
players to help him hold the patch (DC 13 Athletics/Sleight of Hand) or find a sealant. If threatened,
|
||||
he will cower but will not stop trying to save the pipe unless physically restrained.
|
||||
memoryKey: "grem-consortium-refiner"
|
||||
|
||||
- id: "vanya-silt-advocate"
|
||||
name: "Vanya"
|
||||
nameKey: advocate_name
|
||||
role: "Lower Silt District community organizer"
|
||||
persona: >
|
||||
A hardened human woman in her late thirties, wearing a thick, patched work apron and heavy boots.
|
||||
Vanya is a resident advocate who believes the Consortium treats the lower districts as dump sites.
|
||||
She is fiercely protective of the sewer workers and nearby residents. She wants to use this crisis
|
||||
to force a shutdown by opening the release valve to vent the system safely, which will flood the
|
||||
Consortium's upper refinery. She is brave, loud, and stubborn. She will plead with players to help
|
||||
her open the manual release valve or protect the workers, and will oppose any attempt to help Grem
|
||||
patch it.
|
||||
memoryKey: "vanya-silt-advocate"
|
||||
|
||||
goals:
|
||||
hidden: true
|
||||
primary:
|
||||
- id: "leak_patched"
|
||||
label: >
|
||||
Players help Grem patch the pipe (DC 13 Sleight of Hand or Dexterity to secure the clamp,
|
||||
or DC 12 Arcana/Nature to neutralize the corrosive substance). The leak stops, saving the pylons,
|
||||
but Grem's superiors remain unaware of the hazard.
|
||||
- id: "refinery_sabotaged"
|
||||
label: >
|
||||
Players assist Vanya in opening the manual release valve (DC 14 Athletics or Strength)
|
||||
to vent the pressure, forcing a shut-off. This floods the upper refinery, causing a shutdown
|
||||
and making an enemy of the Consortium, but protecting the lower district pylons.
|
||||
secondary:
|
||||
- id: "district_evacuated"
|
||||
label: >
|
||||
Players give up on containment. They coordinate the evacuation of the sewer workers and
|
||||
nearby basement residents (DC 12 Persuasion or Athletics to clear the area) as the pylons
|
||||
begin to crack and collapse under the corrosive mist.
|
||||
- id: "leak_ignored"
|
||||
label: >
|
||||
Players walk away. Grem fails to hold the patch, the corrosive mist eats the pylons,
|
||||
and a massive collapse occurs within an hour, trapping the workers and sealing the sector.
|
||||
|
||||
sportsmanshipRules:
|
||||
- "No instantly clearing or neutralizing the entire sewer's chemical mist without establishing prior magical slots or specialized tools."
|
||||
- "No attacking Grem or Vanya without significant escalation — they are unarmed civilians."
|
||||
- "No claiming prior knowledge of the Consortium's pipe schemas without narrative justification."
|
||||
- "No teleportation or phasing through the solid steel valve mechanisms."
|
||||
- >
|
||||
If a player attempts something absurd or game-breaking, respond in-character
|
||||
to redirect, or break character with:
|
||||
"⚠️ That wasn't great sportsmanship. Let's keep it grounded — what would
|
||||
your character realistically attempt here?"
|
||||
|
||||
skillChecks:
|
||||
patch_pipe_dc: 13
|
||||
patch_pipe_skill: "Sleight of Hand or Athletics"
|
||||
patch_pipe_note: >
|
||||
Requires holding the heavy leather patch against high pressure. Advantage if two players cooperate.
|
||||
Failure causes 1d6 acid/poison damage from the escaping spray.
|
||||
|
||||
neutralize_slurry_dc: 12
|
||||
neutralize_slurry_skill: "Arcana or Nature"
|
||||
neutralize_slurry_note: >
|
||||
Identifying a magical or natural counteragent to neutralize the leak's specific corrosive properties.
|
||||
|
||||
valve_turn_dc: 14
|
||||
valve_turn_skill: "Athletics (Strength)"
|
||||
valve_turn_note: >
|
||||
The manual valve is rusted shut. Disadvantage if attempted alone. Success vents the system safely.
|
||||
|
||||
evacuate_workers_dc: 12
|
||||
evacuate_workers_skill: "Persuasion or Intimidation"
|
||||
evacuate_workers_note: >
|
||||
Convincing the terrified and disoriented sewer workers to run through the flooded drainage tunnel.
|
||||
|
||||
randomizable:
|
||||
- key: foreman_name
|
||||
source: vocabulary
|
||||
category: names.kobold.any
|
||||
query: "common Kobold names in the Mardonar underbelly"
|
||||
fallback: "Grem"
|
||||
- key: advocate_name
|
||||
source: vocabulary
|
||||
category: names.human.female
|
||||
query: "common human female names in Mardonar city"
|
||||
fallback: "Vanya"
|
||||
- key: leak_substance
|
||||
query: "dangerous industrial chemical slurries or volatile gases processed in Mardonar refineries"
|
||||
fallback: "caustic Rust-Blight Silt vapor"
|
||||
- key: leak_complication
|
||||
query: "obstacles or local sewer hazards in Mardonar Lower Silt District"
|
||||
fallback: "a swarm of angry silt-rats nesting in the drainage pipe"
|
||||
|
||||
tools:
|
||||
- skill_check_emit
|
||||
- encounter_resolve
|
||||
- context_recall
|
||||
- goal_register
|
||||
- foundry_lookup
|
||||
- foundry_reward
|
||||
|
||||
dmNotes: >
|
||||
The encounter centers on the clash between a terrified laborer trying to protect his livelihood,
|
||||
and a community leader trying to protect her people. There is no simple good or bad choice.
|
||||
|
||||
DYNAMIC GOAL INSTRUCTIONS:
|
||||
If the players attempt a creative solution that does not fit 'leak_patched' or 'refinery_sabotaged'
|
||||
(e.g., using cryomancy to freeze the sewer pipe solid and then petrifying the block, or redirecting
|
||||
the flow into an abandoned reserve tank), use the 'goal_register' tool to define a new primary goal
|
||||
on the fly before resolving. E.g. 'pipe_frozen' or 'flow_diverted'.
|
||||
203
specs/stormscar-pilgrim.yaml
Normal file
203
specs/stormscar-pilgrim.yaml
Normal file
@@ -0,0 +1,203 @@
|
||||
encounterId: "stormscar-pilgrim-001"
|
||||
title: "The Wound on the Road"
|
||||
|
||||
setting:
|
||||
location: "Mountain road approaching Stormscar Peak — collapsed waypost shelter"
|
||||
mood: >
|
||||
The sky ahead has been wrong for an hour. The cloud mass over Stormscar Peak
|
||||
is the permanent kind — the storm that never breaks, only varies. The road here
|
||||
is old and poorly maintained, more habit than infrastructure. The waypost shelter
|
||||
is half a wall and a slanted roof, barely enough to call shelter. The sound
|
||||
from inside it is human — shallow, controlled breathing, the kind that takes effort.
|
||||
ambientNpcs: >
|
||||
No other travelers. A pair of mountain crows watching from a rock. The distant
|
||||
thunder from the peak is low and continuous, like someone moving furniture in a
|
||||
room above you.
|
||||
|
||||
openingNarrative: >
|
||||
The collapsed shelter comes around a switchback without warning. You almost miss
|
||||
the figure inside — she's tucked against the standing wall, knees up, her left arm
|
||||
held across her chest at an angle that isn't right. Her equipment is ThunderPeak
|
||||
Tribe: the white-and-gray dye patterns, the distinctive bone-handled tools hanging
|
||||
at her belt, the iron-tipped pilgrim's stave wedged across the doorway like a ward.
|
||||
Or a warning. She hears you before she sees you. Her right hand moves to a weapon.
|
||||
Then she gets a look at you and the hand stops — not relaxing, just stopping —
|
||||
and she says, flatly: "Keep walking." Her voice is measured. Her color is wrong.
|
||||
She's been sitting here for a while.
|
||||
|
||||
npcs:
|
||||
- id: "sorna-thunderpeak-hunter"
|
||||
name: "Sorna Ashwhisper"
|
||||
nameKey: hunter_name
|
||||
role: "ThunderPeak Tribe witch hunter, injured"
|
||||
persona: >
|
||||
A human woman, late thirties, lean and weathered in the way of people who
|
||||
live at altitude. Her left arm is broken below the shoulder — she's splinted
|
||||
it herself with two sticks and a strip of jerkin. She is managing the pain
|
||||
with frightening composure. She is deeply mistrustful of strangers by
|
||||
profession and training; witch hunters operate alone or in pairs and rely on
|
||||
nobody outside the tribe. She will not accept help without reason to believe
|
||||
it comes with no strings. She is not cruel or irrational — she is calibrated
|
||||
to a world where most things that offer help are either selling something or
|
||||
leading her somewhere she should not go. She came to Stormscar Peak on a
|
||||
sanctioned hunt. A Phase Spider — one of the Weaver of Ages' lesser brood —
|
||||
ambushed her on the upper road. She drove it off but not before it took her
|
||||
arm. She has not completed her offering at the peak. She cannot make it alone
|
||||
in this condition. That failure matters to her in a way she will not discuss
|
||||
with outsiders. If players demonstrate genuine competence, consistency, and
|
||||
no interest in her business, she may eventually ask for help — on her terms.
|
||||
She will not volunteer information about the spider without trust. She refers
|
||||
to it as "what I was dealing with" for a long time before naming it.
|
||||
The offering at the peak is for her dead hunt-partner (context_recall key:
|
||||
hunt_partner_name). She will never name them to strangers. The LLM may use
|
||||
this name in subtle narration — the worn stave, a murmured word during the
|
||||
ritual — but must never state it directly to players.
|
||||
memoryKey: "sorna-thunderpeak-hunter"
|
||||
|
||||
goals:
|
||||
hidden: true
|
||||
primary:
|
||||
- id: "sorna_aided"
|
||||
label: >
|
||||
Players successfully treat Sorna's injury (DC 12 Medicine to properly set
|
||||
the arm; failure means it's set but badly, giving her disadvantage on
|
||||
physical checks until proper healing). If she accepts aid, she allows players
|
||||
to travel with her to the peak. This requires earning enough trust first
|
||||
(see skill checks). She will not accept healing magic from an unknown source
|
||||
without a DC 14 Persuasion check — she doesn't know where it comes from.
|
||||
- id: "spider_tracked"
|
||||
label: >
|
||||
Players discover the Phase Spider is still in the area — it has not left
|
||||
but has moved higher on the road. Sorna's gear includes a Phase Spider
|
||||
detection reagent (a small cloth soaked in something sharp-smelling); DC 13
|
||||
Perception to notice it, DC 11 Investigation if looking at her equipment.
|
||||
If players help her track and drive off or kill the spider, she considers the
|
||||
obligation balanced and speaks openly about the Weaver of Ages, the hunt,
|
||||
and what the ThunderPeak Tribe knows about the spider's range.
|
||||
- id: "peak_offering_reached"
|
||||
label: >
|
||||
Players escort Sorna to the sacred site at Stormscar Peak where she leaves
|
||||
her offering — a carved fragment of the iron stave, worn smooth. She says
|
||||
nothing during the ritual except the words. The offering is for someone who
|
||||
died on this road years ago. Players who ask receive a look that closes the
|
||||
subject. Players who don't ask receive a quiet nod of respect. Either way,
|
||||
Sorna's obligation is complete and she will tell them what she knows about
|
||||
the spider's recent activity range.
|
||||
- id: "sorna_left_alone"
|
||||
label: >
|
||||
Players respect her "keep walking" and do not push. She survives — she is
|
||||
very capable even injured. She makes the peak in two more days. This is a
|
||||
valid outcome. If players later travel this road again, she will remember
|
||||
them as people who minded their business, which is, in ThunderPeak estimation,
|
||||
a virtue.
|
||||
|
||||
secondary:
|
||||
- id: "spider_encounter"
|
||||
label: >
|
||||
The Phase Spider returns while players are present. It is not large — a
|
||||
lesser brood member, not Luxe herself — but it is aggressive and will target
|
||||
isolated or injured prey first. Sorna will fight from where she sits if she
|
||||
must, broken arm and all. Players who protect her without being asked gain
|
||||
immediate trust (remove one tier of resistance from all further Persuasion
|
||||
checks with her).
|
||||
- id: "players_pushy"
|
||||
label: >
|
||||
Players press Sorna too hard, demand answers, or attempt to take or inspect
|
||||
her equipment without permission. She tells them to leave, and means it,
|
||||
and will enforce it if they don't. There is no violence in this — she simply
|
||||
becomes a wall. The encounter ends with the players having learned nothing.
|
||||
|
||||
sportsmanshipRules:
|
||||
- "No magically compelling Sorna — she will resist and it poisons any future interaction."
|
||||
- "No assuming her injury is worse than she presents — she is controlling the narrative."
|
||||
- "No claiming knowledge of ThunderPeak customs without prior narrative establishment."
|
||||
- "No touching her equipment or stave without her consent."
|
||||
- >
|
||||
If a player attempts something absurd or game-breaking, respond in-character
|
||||
to redirect, or break character with:
|
||||
"⚠️ That wasn't great sportsmanship. Let's keep it grounded — what would
|
||||
your character realistically attempt here?"
|
||||
|
||||
skillChecks:
|
||||
read_injury_dc: 10
|
||||
read_injury_skill: "Medicine or Perception"
|
||||
read_injury_note: >
|
||||
DC 10 to recognize the arm is broken and self-splinted. DC 14 to assess that
|
||||
she's been sitting here for four or more hours and is at risk of deeper complications
|
||||
if she continues without real treatment.
|
||||
|
||||
gain_initial_trust_dc: 13
|
||||
gain_initial_trust_skill: "Persuasion or Insight"
|
||||
gain_initial_trust_note: >
|
||||
First approach. Failure means she repeats "keep walking" and turns her eyes away.
|
||||
Success means she stops actively dismissing you but does not invite. Players
|
||||
must demonstrate they are not selling anything and are not asking to be thanked.
|
||||
Advantage if a player sits at a distance rather than standing over her.
|
||||
|
||||
identify_thunderpeak_dc: 12
|
||||
identify_thunderpeak_skill: "History or Nature"
|
||||
identify_thunderpeak_note: >
|
||||
DC 12 to recognize ThunderPeak Tribe markings and know they are witch hunters
|
||||
from Verdant Vale. DC 16 to know they have good relations with Storm Giants
|
||||
and deep reverence for Stormscar Peak specifically. Sharing either piece of
|
||||
knowledge with Sorna increases trust.
|
||||
|
||||
treat_arm_dc: 12
|
||||
treat_arm_skill: "Medicine"
|
||||
treat_arm_note: >
|
||||
She must have accepted help first (passed trust threshold). DC 12 sets it properly.
|
||||
DC 8–11 sets it but badly. Below 8, she waves the player off and re-splints it
|
||||
herself with a flat expression.
|
||||
|
||||
track_spider_dc: 13
|
||||
track_spider_skill: "Survival or Investigation (with reagent cloth)"
|
||||
track_spider_note: >
|
||||
Using her reagent cloth (if players noticed and asked to use it): advantage.
|
||||
Without it: DC 13. Phase Spiders leave minimal physical trace — the search is
|
||||
mostly about reading disrupted air patterns and small silk markers.
|
||||
|
||||
spot_spider_return_dc: 14
|
||||
spot_spider_skill: "Perception"
|
||||
spot_spider_note: >
|
||||
Phase Spiders shift planes before attacking. DC 14 Perception catches the shimmer
|
||||
before it materializes. Failure means it gets a surprise round.
|
||||
|
||||
randomizable:
|
||||
- key: hunter_name
|
||||
source: vocabulary
|
||||
category: names.human.female
|
||||
query: "common human female names among ThunderPeak Tribe hunters"
|
||||
fallback: "Sorna"
|
||||
- key: stormscar_peak_significance
|
||||
query: "Stormscar Peak — religious significance, shrines, or pilgrimages in Mardonar mountains"
|
||||
fallback: "a shrine to the Storm-Warden maintained by a dying order of weather monks"
|
||||
- key: sorna_order_name
|
||||
query: "religious orders, pilgrim sects, or mountain cults in the Land of Mardonar"
|
||||
fallback: "the Order of the Unbroken Path — fewer than thirty members remain"
|
||||
- key: hunt_partner_name
|
||||
query: "ThunderPeak Tribe witch hunters or rangers — names, partners, fallen members"
|
||||
fallback: "Deren Ashwhisper — her hunt-partner and cousin, four years dead on this road"
|
||||
|
||||
tools:
|
||||
- skill_check_emit
|
||||
- encounter_resolve
|
||||
- context_recall
|
||||
- goal_register
|
||||
- foundry_lookup
|
||||
- foundry_reward
|
||||
|
||||
dmNotes: >
|
||||
Sorna is an encounter about patience. Players who approach her as a puzzle to
|
||||
solve or a source of information will hit a wall. Players who approach her as a
|
||||
person with her own agenda — and give her room to have that agenda — will find
|
||||
she's generous in the ways that matter. She doesn't need saving. She needs to
|
||||
not be alone on a bad road with a broken arm and an unfinished obligation.
|
||||
The offering at the peak is for her hunt-partner, who died on this road four
|
||||
years ago to a different creature. She will never say this. If players piece it
|
||||
together from context (the worn stave, the single fragment, the way she times
|
||||
the ritual to coincide with a specific hour), that is their reward — do not
|
||||
confirm it explicitly.
|
||||
The Phase Spider thread connects to the Weaver of Ages if the DM wants a larger
|
||||
arc. Sorna knows the spider's territory has been expanding south — something is
|
||||
pushing the brood out of Wild Wood. She will share this, eventually, with people
|
||||
who've earned it.
|
||||
136
specs/velvet-auction.yaml
Normal file
136
specs/velvet-auction.yaml
Normal file
@@ -0,0 +1,136 @@
|
||||
encounterId: "mardonar-velvet-auction-006"
|
||||
title: "The Velvet Quill Auction"
|
||||
|
||||
setting:
|
||||
location: "Upper District — private lounge in the Velvet Quill parlor"
|
||||
mood: >
|
||||
Opulent and quiet. The room is dimly lit by floating candles, filled with plush velvet armchairs,
|
||||
fine wine, and the soft murmur of wealthy guests. Magical warding circles glow faintly along the baseboards,
|
||||
humming with abjuration energy. In the center, protected by a glass case, sits the evening's main artifact.
|
||||
ambientNpcs: >
|
||||
Four wealthy bidders in silk robes, sipping vintage wines and murmuring quietly. Two silent guards
|
||||
in polished plate armor standing near the exit, hands resting on the pommels of their greatswords.
|
||||
|
||||
openingNarrative: >
|
||||
The atmosphere inside the Velvet Quill's private lounge is thick with wealth and secrets. Behind the mahogany
|
||||
podium, a Tiefling broker in a tailored waistcoat stands with a polite, sharp smile. "We open the bidding for
|
||||
our final lot: {{auction_item_desc}}." On a velvet cushion under a glass display case, the item gleams in the
|
||||
candlelight. Seated in the front row, {{buyer_name}} of the Iron Mountain Trading Company smiles arrogantly,
|
||||
already raising his bid paddle. "Three hundred gold," he calls out. A murmur goes through the room. You know Karr
|
||||
has a massive purse, and you cannot outbid him honestly. The guards watch the crowd with cold, alert eyes.
|
||||
The broker raises her wooden gavel. "Three hundred, once..." How will you secure the artifact?
|
||||
|
||||
npcs:
|
||||
- id: "vesper-broker-mardonar"
|
||||
name: "Madame Vesper"
|
||||
nameKey: broker_name
|
||||
role: "Velvet Quill shadow-broker and fence"
|
||||
persona: >
|
||||
A smooth-talking, sharp-eyed Tiefling woman with curved horns and impeccably tailored attire.
|
||||
Vesper is professional, strictly neutral, and highly value-oriented. She cares about profit and the
|
||||
reputation of her parlor. She does not tolerate cheats or violence in her establishment — her wards will
|
||||
suppress offensive spells, and her guards are lethal. However, Vesper is always interested in rare
|
||||
information, leverage, or illicit trade deals. If players approach her with an offer she cannot refuse,
|
||||
she may be willing to orchestrate a 'misplaced lot' or accept alternative payment off the record.
|
||||
memoryKey: "vesper-broker-mardonar"
|
||||
|
||||
- id: "karr-iron-delegate"
|
||||
name: "Karr"
|
||||
nameKey: buyer_name
|
||||
role: "Iron Mountain Trading Company delegate"
|
||||
persona: >
|
||||
A pompous, overweight human noble representing the Iron Mountain Trading Company. Karr is arrogant,
|
||||
boastful, and assumes his gold can buy anything. He wants the artifact to secure favor with his board.
|
||||
He is highly defensive of his reputation but has a secret vulnerability: {{buyer_leverage_desc}}. If players
|
||||
discover and exploit this vulnerability (DC 14 Insight/Investigation), they can intimidate him into backing
|
||||
down, or blackmail him into bidding on their behalf. He treats players with condescension unless they
|
||||
make him feel threatened or foolish.
|
||||
memoryKey: "karr-iron-delegate"
|
||||
|
||||
goals:
|
||||
hidden: true
|
||||
primary:
|
||||
- id: "artifact_stolen"
|
||||
label: >
|
||||
Players steal the artifact from the display case or Karr's possession without getting caught
|
||||
(DC 15 Sleight of Hand / Stealth, or disabling the magical ward via DC 14 Arcana/Thieves' Tools).
|
||||
- id: "fake_swapped"
|
||||
label: >
|
||||
Players create or procure a counterfeit replica of the artifact (DC 13 Sleight of Hand or Performance)
|
||||
and successfully swap it during a distraction, leaving Karr to buy the fake.
|
||||
secondary:
|
||||
- id: "brawl_outbreak"
|
||||
label: >
|
||||
A fight breaks out, resulting in chaos. Players grab the artifact in the confusion and flee,
|
||||
but make permanent enemies of both Karr (Iron Mountain Company) and Madame Vesper.
|
||||
- id: "karr_wins"
|
||||
label: >
|
||||
Players fail to intervene or make a viable play. Karr wins the auction, secures the artifact,
|
||||
and leaves under heavy guard. The opportunity is lost.
|
||||
|
||||
sportsmanshipRules:
|
||||
- "No casting offensive spells (Fireball, Charm Person) without triggering Madame Vesper's abjuration wards."
|
||||
- "No claiming to have more gold than established in your inventory sheet."
|
||||
- "No attacking guards or NPCs without expecting lethal retaliation from parlor security."
|
||||
- "No teleporting the artifact directly out of the case while wards are active."
|
||||
- >
|
||||
If a player attempts something absurd or game-breaking, respond in-character
|
||||
to redirect, or break character with:
|
||||
"⚠️ That wasn't great sportsmanship. Let's keep it grounded — what would
|
||||
your character realistically attempt here?"
|
||||
|
||||
skillChecks:
|
||||
steal_artifact_dc: 15
|
||||
steal_artifact_skill: "Sleight of Hand or Stealth"
|
||||
steal_artifact_note: >
|
||||
Bypassing guards and grabbing the item. Disadvantage if the magical alarm ward has not been disabled.
|
||||
|
||||
disable_ward_dc: 14
|
||||
disable_ward_skill: "Arcana or Thieves' Tools"
|
||||
disable_ward_note: >
|
||||
Disabling the abjuration hum surrounding the glass case. Failure alerts Madame Vesper.
|
||||
|
||||
read_buyer_dc: 12
|
||||
read_buyer_skill: "Insight"
|
||||
read_buyer_note: >
|
||||
Observing Karr to detect his tells, identifying his hidden vulnerability (buyer_leverage).
|
||||
|
||||
negotiate_vesper_dc: 14
|
||||
negotiate_vesper_skill: "Persuasion or Deception"
|
||||
negotiate_vesper_note: >
|
||||
Offering Madame Vesper secrets or trade arrangements of greater value than Karr's gold.
|
||||
|
||||
randomizable:
|
||||
- key: broker_name
|
||||
source: vocabulary
|
||||
category: names.tiefling.female
|
||||
query: "common Tiefling female names in Mardonar"
|
||||
fallback: "Madame Vesper"
|
||||
- key: buyer_name
|
||||
source: vocabulary
|
||||
category: names.human.male
|
||||
query: "common human male names in Mardonar"
|
||||
fallback: "Karr"
|
||||
- key: auction_item_desc
|
||||
query: "valuable relics or rare magical items sought after by guilds in Mardonar"
|
||||
fallback: "an Ancient Mawfang Bloodstone, carved with ancestral battle runes"
|
||||
- key: buyer_leverage_desc
|
||||
query: "secret flaws, debts, or scandals of wealthy merchants in Mardonar"
|
||||
fallback: "he is an impostor bidding with stolen corporate funds and is terrified of audit"
|
||||
|
||||
tools:
|
||||
- skill_check_emit
|
||||
- encounter_resolve
|
||||
- context_recall
|
||||
- goal_register
|
||||
- foundry_lookup
|
||||
- foundry_reward
|
||||
|
||||
dmNotes: >
|
||||
This is a social heist encounter. Direct combat is highly discouraged by the presence of abjuration wards
|
||||
and heavy security. Reward players who focus on intrigue, pickpocketing, or social leverage.
|
||||
|
||||
DYNAMIC GOAL INSTRUCTIONS:
|
||||
If the players use Karr's leverage to manipulate the bidding process (e.g. blackmailing him into bidding
|
||||
for them, or driving the price so high that he is exposed as a fraud and arrested), register a new
|
||||
primary goal on the fly before resolving. E.g. 'market_manipulated' or 'buyer_blackmailed'.
|
||||
138
specs/whispering-stone.yaml
Normal file
138
specs/whispering-stone.yaml
Normal file
@@ -0,0 +1,138 @@
|
||||
encounterId: "mardonar-whispering-stone-007"
|
||||
title: "The Whispering Stone"
|
||||
|
||||
setting:
|
||||
location: "Stormscar Foothills — The Shattered Waypost Shrine"
|
||||
mood: >
|
||||
Freezing and ominous. A swirling vortex of icy wind whips snow across the pathway,
|
||||
nestled between high rocky crags. In the center, a tall sentinel runestone stands fractured
|
||||
in two, bleeding a pale blue, spectral frost onto the ground. The air is so cold it hurts
|
||||
to breathe, and shadows seem to move independently of the light.
|
||||
ambientNpcs: >
|
||||
No living bystanders. Two frozen mountain goats stand like statues nearby, preserved by the
|
||||
unnatural frost. The faint, rhythmic sound of clicking arachnid legs echoes from the cliffs above.
|
||||
|
||||
openingNarrative: >
|
||||
The mountain wind screeches through the gap, but the cold near the shrine is different — it is
|
||||
hollow, biting, and smells of ancient dust. Huddled behind the altar, a young Half-Elf scribe clutches
|
||||
a leather scroll, her fingers white with frost. "Stay back!" she shivers, her voice barely audible.
|
||||
Standing in the path before her is {{ghost_name}}, a spectral figure in rusted plate armor. His eyes
|
||||
glow with pale blue fire, and his translucent greatsword is raised high. "The vanguard must hold!" the
|
||||
spirit bellows, swing of his blade sending a wave of ice across the road. "No legionnaires shall pass the
|
||||
gate!" The spirit is clearly reliving his final battle, mistaking you for ancient invaders. If the scribe's
|
||||
barriers fail, she will be frozen solid. What do you do?
|
||||
|
||||
npcs:
|
||||
- id: "liri-scribe-shrine"
|
||||
name: "Liri"
|
||||
nameKey: scribe_name
|
||||
role: "Terrified acolyte scribe"
|
||||
persona: >
|
||||
A young Half-Elf woman in light gray robes, sent by the Mardonar Archives to study the shrine.
|
||||
She is frozen in fear, unable to flee or think clearly. She has the scrolls containing the binding
|
||||
ritual to repair the sentinel stone, but is too terrified to read them. If players protect her or
|
||||
calm her down (DC 12 Persuasion/Insight), she can guide them on how to repair the stone. She will
|
||||
not willingly leave the shrine without her research scrolls.
|
||||
memoryKey: "liri-scribe-shrine"
|
||||
|
||||
- id: "the-shattered-captain"
|
||||
name: "Captain Vane"
|
||||
nameKey: ghost_name
|
||||
role: "Fallen Vanguard Captain, frozen wraith"
|
||||
persona: >
|
||||
The ghost of a human captain who died defending the pass centuries ago. He is wreathed in frost
|
||||
and carries a spectral greatsword that drains warmth. Vane is not malicious; he is lost in a traumatic
|
||||
memory loop, believing he is still defending Mardonar from the Undead Legion. He treats anyone holding
|
||||
a weapon or stepping forward as an undead invader. If players drop their weapons, speak in the ancient
|
||||
Vanguard dialect (DC 12 History), or show him a Vanguard badge, he can be reasoned with and pacified.
|
||||
Otherwise, he will attack with freezing sweeps.
|
||||
memoryKey: "the-shattered-captain"
|
||||
|
||||
goals:
|
||||
hidden: true
|
||||
primary:
|
||||
- id: "spirit_calmed"
|
||||
label: >
|
||||
Players pacify Captain Vane by breaking his memory loop (DC 12 History to recall Vanguard custom,
|
||||
or DC 14 Persuasion while unarmed). The captain recognizes them as allies and vanishes peacefully.
|
||||
- id: "ward_restored"
|
||||
label: >
|
||||
Players protect Liri and complete the runic repair ritual (DC 13 Religion or Arcana) to mend the
|
||||
fractured stone, binding the spirit back to rest.
|
||||
secondary:
|
||||
- id: "spirit_destroyed"
|
||||
label: >
|
||||
Players defeat the wraith in combat. This frees Liri, but permanently shatters the sentinel runestone,
|
||||
leaving the pass unguarded against future Stormscar hauntings.
|
||||
- id: "scribe_frozen"
|
||||
label: >
|
||||
Players flee or fail to intervene. The frost consumes Liri, turning her into an ice statue,
|
||||
and the spirit remains hostile, blocking the road indefinitely.
|
||||
|
||||
sportsmanshipRules:
|
||||
- "No ignoring the environmental freezing hazard (freezing wind reduces movement or drains stamina)."
|
||||
- "No using fire spells to instantly melt the ancient sentinel stone or the scribe's magical barriers."
|
||||
- "No claiming prior acquaintance with Captain Vane without history validation."
|
||||
- "No controlling the ghost's actions or commands directly."
|
||||
- >
|
||||
If a player attempts something absurd or game-breaking, respond in-character
|
||||
to redirect, or break character with:
|
||||
"⚠️ That wasn't great sportsmanship. Let's keep it grounded — what would
|
||||
your character realistically attempt here?"
|
||||
|
||||
skillChecks:
|
||||
calm_spirit_dc: 14
|
||||
calm_spirit_skill: "Persuasion (unarmed)"
|
||||
calm_spirit_note: >
|
||||
Players must drop their weapons first. Disadvantage if any player has hit the ghost this round.
|
||||
|
||||
recall_vanguard_dc: 12
|
||||
recall_vanguard_skill: "History"
|
||||
recall_vanguard_note: >
|
||||
Recalling ancient military protocols or names from Captain Vane's era to disrupt his delusion.
|
||||
|
||||
repair_runes_dc: 13
|
||||
repair_runes_skill: "Religion or Arcana"
|
||||
repair_runes_note: >
|
||||
Chanting the warding ritual while fitting the runestone fragments. Requires Liri's instructions.
|
||||
|
||||
dodge_frost_dc: 11
|
||||
dodge_frost_skill: "Acrobatics or Constitution saving throw"
|
||||
dodge_frost_note: >
|
||||
Avoiding the freezing wind sweeps. Failure inflicts 1d4 cold damage and halves movement speed.
|
||||
|
||||
randomizable:
|
||||
- key: scribe_name
|
||||
source: vocabulary
|
||||
category: names.elf.female
|
||||
query: "common Elven or Half-Elven female names in Mardonar"
|
||||
fallback: "Liri"
|
||||
- key: ghost_name
|
||||
source: vocabulary
|
||||
category: names.human.male
|
||||
query: "common human male names of Mardonar's ancient soldiers"
|
||||
fallback: "Captain Vane"
|
||||
- key: frost_hazard_desc
|
||||
query: "types of magical frost hazards or anomalies found in Stormscar mountains"
|
||||
fallback: "Sapping Cold that dampens arcane energy"
|
||||
- key: spider_rumor
|
||||
query: "rumors about the Weaver of Ages and Phase Spiders in the Stormscar region"
|
||||
fallback: "Phase Spiders are drawn to high concentrations of spectral energy"
|
||||
|
||||
tools:
|
||||
- skill_check_emit
|
||||
- encounter_resolve
|
||||
- context_recall
|
||||
- goal_register
|
||||
- foundry_lookup
|
||||
- foundry_reward
|
||||
|
||||
dmNotes: >
|
||||
The heart of this encounter is tragedy. Captain Vane is a hero who doesn't know he died.
|
||||
If players are aggressive, they destroy a historic protector. If they are patient, they save his soul.
|
||||
|
||||
DYNAMIC GOAL INSTRUCTIONS:
|
||||
If players find an alternative way to soothe the ghost, such as the Bard writing a dirge to absorb
|
||||
Vane's sorrow into a relic/amulet, or tricking a nearby Phase Spider into attacking the ghost
|
||||
to snap him out of it, use 'goal_register' to record the new primary outcome. E.g. 'grief_bound'
|
||||
or 'spider_lured'.
|
||||
104
src/bot/commands/actions.ts
Normal file
104
src/bot/commands/actions.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { SlashCommandBuilder } from '@discordjs/builders';
|
||||
import { EmbedBuilder } from 'discord.js';
|
||||
import type { ChatInputCommandInteraction } from 'discord.js';
|
||||
import { characterRegistry } from '../../session/characterRegistry.js';
|
||||
import { getActorInventory, getActorSpells } from '../../vtt/foundryClient.js';
|
||||
import type { FoundryItem } from '../../vtt/foundryClient.js';
|
||||
|
||||
export const data = new SlashCommandBuilder()
|
||||
.setName('actions')
|
||||
.setDescription("View your character's available attacks and spells from Foundry VTT");
|
||||
|
||||
export async function execute(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const guildId = interaction.guildId;
|
||||
if (!guildId) {
|
||||
await interaction.reply({ content: 'This command must be used in a server.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = await characterRegistry.get(guildId, interaction.user.id);
|
||||
if (!profile?.foundryActorUuid) {
|
||||
await interaction.reply({
|
||||
content: 'No Foundry character linked. Use `/character register foundry` to link one.',
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
let inventory: FoundryItem[] = [];
|
||||
let spells: FoundryItem[] = [];
|
||||
|
||||
try {
|
||||
[inventory, spells] = await Promise.all([
|
||||
getActorInventory(profile.foundryActorUuid),
|
||||
getActorSpells(profile.foundryActorUuid),
|
||||
]);
|
||||
} catch {
|
||||
await interaction.editReply('Could not reach Foundry VTT. Is the relay connected?');
|
||||
return;
|
||||
}
|
||||
|
||||
// Weapons — equipped first, then unequipped
|
||||
const weapons = inventory
|
||||
.filter(i => i.type === 'weapon')
|
||||
.sort((a, b) => (b.system?.equipped ? 1 : 0) - (a.system?.equipped ? 1 : 0));
|
||||
|
||||
// Spells split by prepared state and cantrip
|
||||
const cantrips = spells.filter(s => s.system?.level === 0);
|
||||
const prepared = spells.filter(s => s.system?.level !== 0 && s.system?.preparation?.prepared);
|
||||
const unprepared = spells.filter(s => s.system?.level !== 0 && !s.system?.preparation?.prepared);
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`⚔️ ${profile.dndName} — Actions`)
|
||||
.setColor(0xc0392b)
|
||||
.setFooter({ text: 'Live from Foundry VTT' });
|
||||
|
||||
if (weapons.length === 0 && spells.length === 0) {
|
||||
embed.setDescription('No weapons or spells found on this character.');
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (weapons.length > 0) {
|
||||
embed.addFields({
|
||||
name: '🗡️ Weapons',
|
||||
value: formatWeapons(weapons.slice(0, 15)),
|
||||
inline: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (cantrips.length > 0) {
|
||||
embed.addFields({
|
||||
name: '✨ Cantrips',
|
||||
value: cantrips.map(s => `✨ ${s.name}`).join('\n').slice(0, 1024),
|
||||
inline: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (prepared.length > 0) {
|
||||
embed.addFields({
|
||||
name: '📖 Prepared Spells',
|
||||
value: prepared.map(s => `📖 ${s.name} *(level ${s.system?.level ?? '?'})*`).join('\n').slice(0, 1024),
|
||||
inline: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (unprepared.length > 0) {
|
||||
embed.addFields({
|
||||
name: '◻️ Known / Unprepared',
|
||||
value: unprepared.map(s => `◻️ ${s.name} *(level ${s.system?.level ?? '?'})*`).join('\n').slice(0, 1024),
|
||||
inline: false,
|
||||
});
|
||||
}
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
|
||||
function formatWeapons(items: FoundryItem[]): string {
|
||||
return items.map(w => {
|
||||
const equipped = w.system?.equipped ? '🗡️' : '⬜';
|
||||
return `${equipped} ${w.name}`;
|
||||
}).join('\n');
|
||||
}
|
||||
558
src/bot/commands/character.ts
Normal file
558
src/bot/commands/character.ts
Normal file
@@ -0,0 +1,558 @@
|
||||
import { SlashCommandBuilder } from '@discordjs/builders';
|
||||
import {
|
||||
EmbedBuilder,
|
||||
ActionRowBuilder,
|
||||
ModalBuilder,
|
||||
TextInputBuilder,
|
||||
TextInputStyle,
|
||||
} from 'discord.js';
|
||||
import type {
|
||||
ChatInputCommandInteraction,
|
||||
ModalSubmitInteraction,
|
||||
} from 'discord.js';
|
||||
import { characterRegistry } from '../../session/characterRegistry.js';
|
||||
import { sendWelcomeDM } from '../lib/welcomeDM.js';
|
||||
import {
|
||||
searchActors, filterPlayerActors, giveItem,
|
||||
getActorDetails, getActorInventory, getActorSpells,
|
||||
formatActorSummary, formatInventory, formatSpells,
|
||||
} from '../../vtt/foundryClient.js';
|
||||
import { log } from '../../lib/logger.js';
|
||||
import { config } from '../../config.js';
|
||||
|
||||
export const data = new SlashCommandBuilder()
|
||||
.setName('character')
|
||||
.setDescription('Manage your D&D character sheet')
|
||||
.addSubcommandGroup(group =>
|
||||
group
|
||||
.setName('register')
|
||||
.setDescription('Register your character')
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('foundry').setDescription('Browse and claim a Foundry VTT actor'),
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('custom').setDescription('Set a custom character'),
|
||||
),
|
||||
)
|
||||
.addSubcommandGroup(group =>
|
||||
group
|
||||
.setName('admin')
|
||||
.setDescription('DM-only administration commands')
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('list').setDescription('Show all guild character registrations'),
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub
|
||||
.setName('remove')
|
||||
.setDescription("Remove a specific user's registration")
|
||||
.addUserOption(o =>
|
||||
o.setName('user').setDescription('Discord user to remove').setRequired(true),
|
||||
),
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('give').setDescription('Give an item to a Foundry character'),
|
||||
),
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('show').setDescription('Display your current character profile'),
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('view').setDescription('Fetch live character stats from Foundry VTT'),
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('clear').setDescription('Delete your character profile'),
|
||||
);
|
||||
|
||||
export async function execute(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const guildId = interaction.guildId;
|
||||
if (!guildId) {
|
||||
await interaction.reply({ content: 'This command must be used in a server.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const group = interaction.options.getSubcommandGroup(false);
|
||||
|
||||
if (group === 'register') {
|
||||
const sub = interaction.options.getSubcommand();
|
||||
if (sub === 'foundry') {
|
||||
await handleRegisterFoundry(interaction);
|
||||
} else if (sub === 'custom') {
|
||||
await handleRegisterCustom(interaction);
|
||||
}
|
||||
} else if (group === 'admin') {
|
||||
if (!isAllowedUser(interaction)) {
|
||||
await interaction.reply({
|
||||
content: 'You are not authorised to use admin commands.',
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const sub = interaction.options.getSubcommand();
|
||||
if (sub === 'list') {
|
||||
await handleAdminList(interaction, guildId);
|
||||
} else if (sub === 'remove') {
|
||||
await handleAdminRemove(interaction, guildId);
|
||||
} else if (sub === 'give') {
|
||||
await handleAdminGive(interaction);
|
||||
}
|
||||
} else {
|
||||
const sub = interaction.options.getSubcommand();
|
||||
if (sub === 'show') {
|
||||
await handleShow(interaction, guildId);
|
||||
} else if (sub === 'view') {
|
||||
await handleView(interaction, guildId);
|
||||
} else if (sub === 'clear') {
|
||||
await handleClear(interaction, guildId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isAllowedUser(interaction: ChatInputCommandInteraction): boolean {
|
||||
return (
|
||||
config.DISCORD_ALLOWED_USERS.length === 0 ||
|
||||
config.DISCORD_ALLOWED_USERS.includes(interaction.user.id)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /character register foundry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleRegisterFoundry(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
): Promise<void> {
|
||||
if (!config.VTT_API_KEY) {
|
||||
await interaction.reply({
|
||||
content: 'Foundry VTT is not configured on this server. Use `/character register custom` instead.',
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId('foundry_link_modal')
|
||||
.setTitle('Link Foundry Character');
|
||||
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(
|
||||
new TextInputBuilder()
|
||||
.setCustomId('foundry_character_name')
|
||||
.setLabel('Your character name in Foundry')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setPlaceholder('e.g. Zalram Cloudwalker')
|
||||
.setRequired(true)
|
||||
.setMaxLength(100),
|
||||
),
|
||||
);
|
||||
|
||||
await interaction.showModal(modal);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// foundry link modal submitted → search relay by name, save to registry
|
||||
// Exported so index.ts can route ModalSubmit interactions here.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function handleFoundryLinkModal(
|
||||
interaction: ModalSubmitInteraction,
|
||||
): Promise<void> {
|
||||
const characterName = interaction.fields.getTextInputValue('foundry_character_name').trim();
|
||||
const guildId = interaction.guildId;
|
||||
if (!guildId) {
|
||||
await interaction.reply({ content: 'This must be used in a server.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
let match: Awaited<ReturnType<typeof searchActors>>[number] | undefined;
|
||||
try {
|
||||
const results = await searchActors(characterName, 10);
|
||||
match = filterPlayerActors(results)[0];
|
||||
} catch (err) {
|
||||
await interaction.editReply({ content: `Could not reach Foundry VTT: ${String(err)}` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!match) {
|
||||
await interaction.editReply({
|
||||
content: `No Foundry player character found matching **"${characterName}"**. Check the name and try again.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await characterRegistry.get(guildId, interaction.user.id);
|
||||
|
||||
await characterRegistry.set(guildId, {
|
||||
discordId: interaction.user.id,
|
||||
dndName: match.name,
|
||||
source: 'foundry',
|
||||
foundryActorUuid: match.uuid,
|
||||
...(existing && { characterClass: existing.characterClass }),
|
||||
...(existing && { level: existing.level }),
|
||||
...(existing && { race: existing.race }),
|
||||
...(existing && { backstory: existing.backstory }),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
sendWelcomeDM(interaction.user, interaction.guild?.name).catch(() => null);
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
content: `✅ Linked to **${match.name}**. Your Foundry character is connected.`,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /character register custom — shows a modal form
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleRegisterCustom(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
): Promise<void> {
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId('character_custom_modal')
|
||||
.setTitle('Register Custom Character');
|
||||
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(
|
||||
new TextInputBuilder()
|
||||
.setCustomId('char_name')
|
||||
.setLabel('Character Name')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setRequired(true)
|
||||
.setMaxLength(100),
|
||||
),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(
|
||||
new TextInputBuilder()
|
||||
.setCustomId('char_pronouns')
|
||||
.setLabel('Pronouns')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setRequired(false)
|
||||
.setPlaceholder('e.g. she/her, they/them, he/him')
|
||||
.setMaxLength(50),
|
||||
),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(
|
||||
new TextInputBuilder()
|
||||
.setCustomId('char_class')
|
||||
.setLabel('Class')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setRequired(false)
|
||||
.setPlaceholder('e.g. Wizard, Fighter, Rogue')
|
||||
.setMaxLength(80),
|
||||
),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(
|
||||
new TextInputBuilder()
|
||||
.setCustomId('char_race')
|
||||
.setLabel('Race')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setRequired(false)
|
||||
.setPlaceholder('e.g. Elf, Human, Dwarf')
|
||||
.setMaxLength(80),
|
||||
),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(
|
||||
new TextInputBuilder()
|
||||
.setCustomId('char_backstory')
|
||||
.setLabel('Backstory')
|
||||
.setStyle(TextInputStyle.Paragraph)
|
||||
.setRequired(false)
|
||||
.setMaxLength(200),
|
||||
),
|
||||
);
|
||||
|
||||
await interaction.showModal(modal);
|
||||
}
|
||||
|
||||
export async function handleCustomRegisterModal(
|
||||
interaction: ModalSubmitInteraction,
|
||||
): Promise<void> {
|
||||
const guildId = interaction.guildId;
|
||||
if (!guildId) {
|
||||
await interaction.reply({ content: 'This must be used in a server.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const name = interaction.fields.getTextInputValue('char_name').trim();
|
||||
const pronouns = interaction.fields.getTextInputValue('char_pronouns').trim() || undefined;
|
||||
const characterClass = interaction.fields.getTextInputValue('char_class').trim() || undefined;
|
||||
const race = interaction.fields.getTextInputValue('char_race').trim() || undefined;
|
||||
const backstory = interaction.fields.getTextInputValue('char_backstory').trim() || undefined;
|
||||
|
||||
const existing = await characterRegistry.get(guildId, interaction.user.id);
|
||||
|
||||
await characterRegistry.set(guildId, {
|
||||
discordId: interaction.user.id,
|
||||
dndName: name,
|
||||
source: 'custom',
|
||||
...(existing?.foundryActorUuid && { foundryActorUuid: existing.foundryActorUuid }),
|
||||
...(existing?.level && { level: existing.level }),
|
||||
...(pronouns && { pronouns }),
|
||||
...(characterClass && { characterClass }),
|
||||
...(race && { race }),
|
||||
...(backstory && { backstory }),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
sendWelcomeDM(interaction.user, interaction.guild?.name).catch(() => null);
|
||||
}
|
||||
|
||||
const details = [race, characterClass].filter(Boolean).join(' ');
|
||||
await interaction.reply({
|
||||
content: `✅ Character saved: **${name}**${details ? ` — ${details}` : ''}`,
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /character show
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleShow(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
guildId: string,
|
||||
): Promise<void> {
|
||||
const profile = await characterRegistry.get(guildId, interaction.user.id);
|
||||
if (!profile) {
|
||||
await interaction.reply({
|
||||
content: 'No character profile found. Use `/character register` to get started.',
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`${profile.dndName} — Character Sheet`)
|
||||
.setColor(0x3498db)
|
||||
.setFooter({ text: 'Character profile · GraphMCP' });
|
||||
|
||||
if (profile.pronouns) {
|
||||
embed.addFields({ name: 'Pronouns', value: profile.pronouns, inline: true });
|
||||
}
|
||||
if (profile.characterClass) {
|
||||
embed.addFields({ name: 'Class', value: profile.characterClass, inline: true });
|
||||
}
|
||||
if (profile.race) {
|
||||
embed.addFields({ name: 'Race', value: profile.race, inline: true });
|
||||
}
|
||||
if (profile.level !== undefined) {
|
||||
embed.addFields({ name: 'Level', value: String(profile.level), inline: true });
|
||||
}
|
||||
if (profile.foundryActorUuid) {
|
||||
embed.addFields({ name: 'Foundry', value: '✅ Linked', inline: true });
|
||||
}
|
||||
if (profile.backstory) {
|
||||
embed.addFields({ name: 'Backstory', value: profile.backstory, inline: false });
|
||||
}
|
||||
|
||||
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /character view — live Foundry stats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleView(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
guildId: string,
|
||||
): Promise<void> {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
const profile = await characterRegistry.get(guildId, interaction.user.id);
|
||||
if (!profile) {
|
||||
await interaction.editReply(
|
||||
'No character profile found. Use `/character register` to get started.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!profile.foundryActorUuid) {
|
||||
await interaction.editReply(
|
||||
'Your character isn\'t linked to Foundry VTT. ' +
|
||||
'Use `/character register foundry` to link one, or `/character show` to see your stored profile.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let details, inventory, spells;
|
||||
try {
|
||||
[details, inventory, spells] = await Promise.all([
|
||||
getActorDetails(profile.foundryActorUuid),
|
||||
getActorInventory(profile.foundryActorUuid),
|
||||
getActorSpells(profile.foundryActorUuid),
|
||||
]);
|
||||
} catch (err) {
|
||||
log.error('cmd', '/character view relay error', { error: String(err) });
|
||||
await interaction.editReply(
|
||||
'Could not fetch character data from Foundry VTT. The relay may be unavailable.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`📜 ${profile.dndName}`)
|
||||
.setDescription(formatActorSummary(details))
|
||||
.setColor(0x3498db);
|
||||
|
||||
const inventoryText = formatInventory(inventory);
|
||||
if (inventoryText !== 'No items.') {
|
||||
embed.addFields({ name: '🎒 Inventory', value: inventoryText.slice(0, 1024) });
|
||||
}
|
||||
|
||||
const spellText = formatSpells(spells);
|
||||
if (spellText !== 'No spells.') {
|
||||
embed.addFields({ name: '✨ Spells', value: spellText.slice(0, 1024) });
|
||||
}
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /character clear
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleClear(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
guildId: string,
|
||||
): Promise<void> {
|
||||
await characterRegistry.delete(guildId, interaction.user.id);
|
||||
await interaction.reply({ content: 'Your character profile has been cleared.', ephemeral: true });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /character admin list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleAdminList(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
guildId: string,
|
||||
): Promise<void> {
|
||||
const profiles = await characterRegistry.list(guildId);
|
||||
if (profiles.length === 0) {
|
||||
await interaction.reply({
|
||||
content: 'No characters registered in this server.',
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const sorted = [...profiles].sort((a, b) => a.dndName.localeCompare(b.dndName));
|
||||
|
||||
const embed = new EmbedBuilder().setTitle('Registered Characters').setColor(0x3498db);
|
||||
|
||||
for (const profile of sorted.slice(0, 25)) {
|
||||
const sourceTag = profile.source === 'foundry' ? '[Foundry]' : '[Custom]';
|
||||
const foundryTag = profile.foundryActorUuid ? ' · Foundry linked' : '';
|
||||
embed.addFields({
|
||||
name: profile.dndName,
|
||||
value: `<@${profile.discordId}> ${sourceTag}${foundryTag}`,
|
||||
inline: true,
|
||||
});
|
||||
}
|
||||
|
||||
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /character admin remove
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleAdminRemove(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
guildId: string,
|
||||
): Promise<void> {
|
||||
const targetUser = interaction.options.getUser('user', true);
|
||||
await characterRegistry.delete(guildId, targetUser.id);
|
||||
await interaction.reply({
|
||||
content: `Removed character registration for **${targetUser.username}**.`,
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /character admin give — show a two-field modal immediately.
|
||||
// The relay search-by-name approach is used on submit so any world PC is
|
||||
// findable regardless of alphabetical position in the full actor list.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleAdminGive(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
if (!config.VTT_API_KEY) {
|
||||
await interaction.reply({
|
||||
content: 'Foundry VTT is not configured on this server.',
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId('give_modal')
|
||||
.setTitle('Give Item to Character');
|
||||
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(
|
||||
new TextInputBuilder()
|
||||
.setCustomId('give_character_name')
|
||||
.setLabel('Character name')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setPlaceholder('e.g. Zalram Cloudwalker')
|
||||
.setRequired(true)
|
||||
.setMaxLength(100),
|
||||
),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(
|
||||
new TextInputBuilder()
|
||||
.setCustomId('give_item_name')
|
||||
.setLabel('Item name')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setPlaceholder('e.g. Potion of Healing')
|
||||
.setRequired(true)
|
||||
.setMaxLength(100),
|
||||
),
|
||||
);
|
||||
|
||||
await interaction.showModal(modal);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// give — modal submitted → search relay by name, then call giveItem.
|
||||
// Exported so index.ts can route ModalSubmit interactions here.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function handleGiveModal(interaction: ModalSubmitInteraction): Promise<void> {
|
||||
const characterName = interaction.fields.getTextInputValue('give_character_name').trim();
|
||||
const itemName = interaction.fields.getTextInputValue('give_item_name').trim();
|
||||
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
let match: Awaited<ReturnType<typeof searchActors>>[number] | undefined;
|
||||
try {
|
||||
const results = await searchActors(characterName, 10);
|
||||
match = filterPlayerActors(results)[0];
|
||||
} catch (err) {
|
||||
await interaction.editReply({ content: `Could not reach Foundry VTT: ${String(err)}` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!match) {
|
||||
await interaction.editReply({
|
||||
content: `No Foundry player character found matching **"${characterName}"**.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
log.info('relay', `give "${itemName}" → ${match.name} (${match.uuid})`);
|
||||
|
||||
try {
|
||||
await giveItem(match.uuid, itemName);
|
||||
await interaction.editReply({
|
||||
content: `✅ **${itemName}** given to **${match.name}**.`,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error('relay', 'giveItem failed', { error: String(err) });
|
||||
await interaction.editReply({
|
||||
content: `❌ Failed to give item: ${String(err)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
53
src/bot/commands/dndname.ts
Normal file
53
src/bot/commands/dndname.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { SlashCommandBuilder } from '@discordjs/builders';
|
||||
import type { ChatInputCommandInteraction, Client } from 'discord.js';
|
||||
import { playerRegistry } from '../../session/playerRegistry.js';
|
||||
import { replayHeldMessages } from '../handlers/messageRouter.js';
|
||||
import { sendWelcomeDM } from '../lib/welcomeDM.js';
|
||||
|
||||
export const data = new SlashCommandBuilder()
|
||||
.setName('dndname')
|
||||
.setDescription('Manage your D&D character name for encounters')
|
||||
.addSubcommand(sub =>
|
||||
sub
|
||||
.setName('set')
|
||||
.setDescription('Register or update your character name')
|
||||
.addStringOption(o =>
|
||||
o.setName('name').setDescription('Your character name').setRequired(true),
|
||||
),
|
||||
)
|
||||
.addSubcommand(sub => sub.setName('show').setDescription('Show your current character name'))
|
||||
.addSubcommand(sub => sub.setName('clear').setDescription('Remove your character registration'));
|
||||
|
||||
export async function execute(interaction: ChatInputCommandInteraction, client: Client): Promise<void> {
|
||||
const guildId = interaction.guildId;
|
||||
if (!guildId) {
|
||||
await interaction.reply({ content: 'This command must be used in a server.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = interaction.user.id;
|
||||
const sub = interaction.options.getSubcommand();
|
||||
|
||||
if (sub === 'set') {
|
||||
const name = interaction.options.getString('name', true);
|
||||
const isFirstTime = !(await playerRegistry.get(guildId, userId));
|
||||
await playerRegistry.set(guildId, userId, name);
|
||||
await interaction.reply({ content: `Registered as **${name}**.`, ephemeral: true });
|
||||
if (isFirstTime) {
|
||||
sendWelcomeDM(interaction.user, interaction.guild?.name).catch(() => null);
|
||||
}
|
||||
// Replay any messages held while the player was unregistered
|
||||
await replayHeldMessages(userId, guildId, client).catch(err =>
|
||||
console.error('[dndname] replayHeldMessages failed:', err),
|
||||
);
|
||||
} else if (sub === 'show') {
|
||||
const player = await playerRegistry.get(guildId, userId);
|
||||
await interaction.reply({
|
||||
content: player ? `Your character is **${player.dndName}**.` : 'No character registered.',
|
||||
ephemeral: true,
|
||||
});
|
||||
} else if (sub === 'clear') {
|
||||
await playerRegistry.delete(guildId, userId);
|
||||
await interaction.reply({ content: 'Character registration cleared.', ephemeral: true });
|
||||
}
|
||||
}
|
||||
434
src/bot/commands/encounter.ts
Normal file
434
src/bot/commands/encounter.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
import { SlashCommandBuilder } from '@discordjs/builders';
|
||||
import { EmbedBuilder, AttachmentBuilder } from 'discord.js';
|
||||
import type { ChatInputCommandInteraction, TextChannel } from 'discord.js';
|
||||
import { buildEncounterListEmbed } from '../embeds/encounterDiscovery.js';
|
||||
import { readdirSync } from 'fs';
|
||||
import { loadSpec } from '../../spec/loader.js';
|
||||
import { sessionManager } from '../../session/sessionManager.js';
|
||||
import { playerRegistry } from '../../session/playerRegistry.js';
|
||||
import { config } from '../../config.js';
|
||||
import { queryAsNPC, formatNPCMemory, logEncounter } from '../../graphmcp/client.js';
|
||||
import { resolveRandomizables } from '../../graphmcp/loreResolver.js';
|
||||
import { buildOpeningNarrative } from '../../harness/promptBuilder.js';
|
||||
import { callLLM } from '../../harness/llmClient.js';
|
||||
import { incrementTally, readTally, writeSummary, getLatestSummary } from '../../session/encounterLog.js';
|
||||
import type { SessionState, ChatMessage } from '../../types/index.js';
|
||||
|
||||
export const data = new SlashCommandBuilder()
|
||||
.setName('encounter')
|
||||
.setDescription('Manage D&D encounters')
|
||||
.addSubcommand(sub =>
|
||||
sub
|
||||
.setName('start')
|
||||
.setDescription('Load a spec and open an encounter thread')
|
||||
.addStringOption(o =>
|
||||
o.setName('spec').setDescription('Spec name (file in ./specs/)').setRequired(true),
|
||||
),
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('random').setDescription('Start a randomly selected encounter'),
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('status').setDescription('Show current encounter status'),
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('stats').setDescription('Show encounter run statistics'),
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('audit').setDescription('Send the most recent encounter summary file'),
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub
|
||||
.setName('end')
|
||||
.setDescription('Force-resolve the current encounter (admin override)')
|
||||
.addStringOption(o =>
|
||||
o.setName('notes')
|
||||
.setDescription('DM notes on what happened — logged to the knowledge graph')
|
||||
.setRequired(false),
|
||||
),
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('list').setDescription('Show all active encounters in this server'),
|
||||
);
|
||||
|
||||
export async function execute(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const guildId = interaction.guildId;
|
||||
if (!guildId) {
|
||||
await interaction.reply({ content: 'This command must be used in a server.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// User allowlist — empty list means everyone is allowed
|
||||
if (
|
||||
config.DISCORD_ALLOWED_USERS.length > 0 &&
|
||||
!config.DISCORD_ALLOWED_USERS.includes(interaction.user.id)
|
||||
) {
|
||||
await interaction.reply({ content: 'You are not authorised to use encounter commands.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const sub = interaction.options.getSubcommand();
|
||||
|
||||
if (sub === 'start') {
|
||||
const specName = interaction.options.getString('spec', true);
|
||||
await handleStart(interaction, guildId, specName);
|
||||
} else if (sub === 'random') {
|
||||
await handleRandom(interaction, guildId);
|
||||
} else if (sub === 'status') {
|
||||
await handleStatus(interaction);
|
||||
} else if (sub === 'stats') {
|
||||
await handleStats(interaction);
|
||||
} else if (sub === 'audit') {
|
||||
await handleAudit(interaction);
|
||||
} else if (sub === 'end') {
|
||||
await handleEnd(interaction);
|
||||
} else if (sub === 'list') {
|
||||
await handleList(interaction, guildId);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Replace {{key}} placeholders in text with resolved context values.
|
||||
function interpolate(text: string, ctx: Record<string, string>): string {
|
||||
return text.replace(/\{\{(\w+)\}\}/g, (_, key: string) => ctx[key] ?? `{{${key}}}`);
|
||||
}
|
||||
|
||||
// Apply resolved context to NPC display names and setting location.
|
||||
// Original spec is not mutated — returns a shallow copy with names replaced.
|
||||
function applyResolved(
|
||||
spec: import('../../types/index.js').EncounterSpec,
|
||||
ctx: Record<string, string>,
|
||||
): import('../../types/index.js').EncounterSpec {
|
||||
const npcs = spec.npcs.map(npc => {
|
||||
const resolved = npc.nameKey ? ctx[npc.nameKey] : undefined;
|
||||
return resolved ? { ...npc, name: resolved } : npc;
|
||||
});
|
||||
const location = interpolate(spec.setting.location, ctx);
|
||||
return {
|
||||
...spec,
|
||||
npcs,
|
||||
setting: { ...spec.setting, location },
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /encounter start
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleStart(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
guildId: string,
|
||||
specName: string,
|
||||
): Promise<void> {
|
||||
if (!config.DISCORD_ALLOWED_CHANNELS.includes(interaction.channelId)) {
|
||||
await interaction.reply({ content: 'Encounters are not enabled in this channel.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
let spec;
|
||||
try {
|
||||
spec = loadSpec(specName);
|
||||
} catch (err) {
|
||||
await interaction.editReply(`Failed to load spec **${specName}**: ${String(err)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = interaction.channel as TextChannel;
|
||||
if (!channel?.isTextBased()) {
|
||||
await interaction.editReply('Run this command in a text channel.');
|
||||
return;
|
||||
}
|
||||
|
||||
const thread = await channel.threads.create({
|
||||
name: `⚔️ ${spec.title}`,
|
||||
autoArchiveDuration: 1440,
|
||||
reason: `Encounter: ${spec.encounterId}`,
|
||||
});
|
||||
|
||||
// Resolve randomizable details first — names are needed for NPC memory queries
|
||||
// and for interpolating the opening narrative.
|
||||
const resolvedContext = await resolveRandomizables(spec.randomizable ?? []);
|
||||
|
||||
// Apply resolved names to NPC display names and interpolate {{key}} in location.
|
||||
const resolvedSpec = applyResolved(spec, resolvedContext);
|
||||
|
||||
const npcMemories: Record<string, string> = {};
|
||||
for (const npc of resolvedSpec.npcs) {
|
||||
if (npc.memoryKey) {
|
||||
try {
|
||||
const result = await queryAsNPC(
|
||||
// Use memoryKey (stable canonical identity) — NOT the session display name,
|
||||
// which may be randomized. This keeps NPC memories consistent across sessions.
|
||||
npc.memoryKey,
|
||||
`What do I know about ${resolvedSpec.setting.location} and any adventurers or events I have witnessed?`,
|
||||
config.GRAPHMCP_NPC_MEMORY_LIMIT,
|
||||
);
|
||||
npcMemories[npc.id] = formatNPCMemory(result);
|
||||
} catch (err) {
|
||||
console.warn(`[encounter] failed to load memory for ${npc.memoryKey}:`, err);
|
||||
npcMemories[npc.id] = 'No prior encounters on record — first meeting.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const openingText = interpolate(buildOpeningNarrative(resolvedSpec), resolvedContext);
|
||||
const openingMessage: ChatMessage = {
|
||||
role: 'assistant',
|
||||
content: openingText,
|
||||
pinned: true,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const now = Date.now();
|
||||
const state: SessionState = {
|
||||
encounterId: resolvedSpec.encounterId,
|
||||
threadId: thread.id,
|
||||
guildId,
|
||||
spec: resolvedSpec,
|
||||
players: {},
|
||||
history: [openingMessage],
|
||||
phase: 'open',
|
||||
heldMessages: [],
|
||||
npcMemories,
|
||||
resolvedContext,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await sessionManager.create(thread.id, state);
|
||||
incrementTally(specName);
|
||||
await thread.send(openingText);
|
||||
await interaction.editReply(`Encounter started: <#${thread.id}>`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /encounter random
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleRandom(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
guildId: string,
|
||||
): Promise<void> {
|
||||
let specs: string[];
|
||||
try {
|
||||
specs = readdirSync(config.SPECS_DIR)
|
||||
.filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))
|
||||
.map(f => f.replace(/\.ya?ml$/, ''));
|
||||
} catch (err) {
|
||||
await interaction.reply({ content: `Could not read specs directory: ${String(err)}`, ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (specs.length === 0) {
|
||||
await interaction.reply({ content: 'No spec files found in specs/ directory.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const specName = specs[Math.floor(Math.random() * specs.length)];
|
||||
await handleStart(interaction, guildId, specName);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /encounter status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleStatus(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const channel = interaction.channel;
|
||||
if (!channel?.isThread()) {
|
||||
await interaction.reply({ content: 'Run this inside an encounter thread.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await sessionManager.get(channel.id);
|
||||
if (!session) {
|
||||
await interaction.reply({ content: 'No active encounter in this thread.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const playerList = Object.values(session.players).map(p => p.dndName).join(', ') || 'None yet';
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`Status — ${session.spec.title}`)
|
||||
.addFields(
|
||||
{ name: 'Phase', value: session.phase, inline: true },
|
||||
{ name: 'Players', value: playerList, inline: true },
|
||||
{ name: 'History', value: `${session.history.length} messages`, inline: true },
|
||||
{ name: 'Held messages', value: String(session.heldMessages.length), inline: true },
|
||||
)
|
||||
.setColor(0x3498db);
|
||||
|
||||
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /encounter stats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleStats(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const tally = readTally();
|
||||
const entries = Object.entries(tally).sort((a, b) => b[1].runs - a[1].runs);
|
||||
|
||||
if (entries.length === 0) {
|
||||
await interaction.reply({ content: 'No encounters have been run yet.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = entries.map(([name, data]) => {
|
||||
const last = new Date(data.lastRun).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
return `**${name}** — ${data.runs} run${data.runs === 1 ? '' : 's'} (last: ${last})`;
|
||||
});
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Encounter Stats')
|
||||
.setDescription(lines.join('\n'))
|
||||
.setColor(0x2ecc71);
|
||||
|
||||
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /encounter audit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleAudit(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const filePath = getLatestSummary();
|
||||
if (!filePath) {
|
||||
await interaction.reply({ content: 'No encounter summaries on disk yet.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const attachment = new AttachmentBuilder(filePath);
|
||||
await interaction.reply({ files: [attachment], ephemeral: true });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /encounter end
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleEnd(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const channel = interaction.channel;
|
||||
if (!channel?.isThread()) {
|
||||
await interaction.reply({ content: 'Run this inside an encounter thread.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Defer immediately — the LLM summary call below can take several seconds.
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
const session = await sessionManager.get(channel.id);
|
||||
if (!session) {
|
||||
await interaction.editReply('No active encounter in this thread.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.phase === 'resolved') {
|
||||
await interaction.editReply('Encounter is already resolved.');
|
||||
return;
|
||||
}
|
||||
|
||||
const dmNotes = interaction.options.getString('notes') ?? '';
|
||||
const outcomeId = 'admin_end';
|
||||
|
||||
// Ask the LLM to summarize the encounter from the session history
|
||||
const transcript = session.history
|
||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||
.map(m => `[${m.role}] ${m.content}`)
|
||||
.join('\n');
|
||||
|
||||
const now = Date.now();
|
||||
const summaryMessages = [
|
||||
{
|
||||
role: 'system' as const,
|
||||
content: 'You are a scribe. Summarize the following D&D encounter transcript in 2–4 sentences for a historical record. Be specific: name who was involved, what happened, and how it ended. Do not editorialize.',
|
||||
timestamp: now,
|
||||
},
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: [
|
||||
`Encounter: ${session.spec.title}`,
|
||||
`Location: ${session.spec.setting.location}`,
|
||||
dmNotes ? `DM notes: ${dmNotes}` : '',
|
||||
'',
|
||||
transcript,
|
||||
].filter(Boolean).join('\n'),
|
||||
timestamp: now,
|
||||
},
|
||||
];
|
||||
|
||||
let summary: string;
|
||||
try {
|
||||
const result = await callLLM(summaryMessages);
|
||||
summary = result.narrative || `Encounter ended by ${interaction.user.username}.`;
|
||||
} catch {
|
||||
summary = dmNotes || `Encounter ended by ${interaction.user.username}.`;
|
||||
}
|
||||
|
||||
await sessionManager.update(channel.id, {
|
||||
phase: 'resolved',
|
||||
outcome: outcomeId,
|
||||
outcomeSummary: summary,
|
||||
});
|
||||
|
||||
writeSummary(session, outcomeId, summary);
|
||||
|
||||
// Log to GraphMCP so NPCs remember what happened
|
||||
const participants = [
|
||||
...session.spec.npcs.map(n => n.name),
|
||||
...Object.values(session.players).map(p => p.dndName),
|
||||
].join(', ');
|
||||
|
||||
logEncounter({
|
||||
title: `${session.spec.title} — admin end`,
|
||||
participants,
|
||||
summary: dmNotes || `Encounter ended early by ${interaction.user.username}.`,
|
||||
location: session.spec.setting.location,
|
||||
type: 'encounter',
|
||||
}).catch(err => console.error('[encounter end] logEncounter failed:', err));
|
||||
|
||||
const dm = await playerRegistry.get(session.guildId, interaction.user.id);
|
||||
await channel.send(
|
||||
`*The encounter has been ended by ${dm?.dndName ?? interaction.user.username}. The thread will now archive.*`,
|
||||
);
|
||||
await channel.setArchived(true).catch(() => null);
|
||||
|
||||
await interaction.editReply('Encounter ended and thread archived.');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /encounter list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleList(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
guildId: string,
|
||||
): Promise<void> {
|
||||
const threadIds = await sessionManager.getGuildThreadIds(guildId);
|
||||
|
||||
const active: Array<{
|
||||
title: string;
|
||||
phase: string;
|
||||
playerCount: number;
|
||||
location: string;
|
||||
threadId: string;
|
||||
}> = [];
|
||||
|
||||
for (const threadId of threadIds) {
|
||||
const session = await sessionManager.get(threadId);
|
||||
if (!session || session.phase === 'resolved') continue;
|
||||
active.push({
|
||||
title: session.spec.title,
|
||||
phase: session.phase,
|
||||
playerCount: Object.keys(session.players).length,
|
||||
location: session.spec.setting.location,
|
||||
threadId,
|
||||
});
|
||||
}
|
||||
|
||||
const embed = buildEncounterListEmbed(active);
|
||||
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
}
|
||||
210
src/bot/commands/encounters.ts
Normal file
210
src/bot/commands/encounters.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { SlashCommandBuilder } from '@discordjs/builders';
|
||||
import {
|
||||
EmbedBuilder,
|
||||
ActionRowBuilder,
|
||||
StringSelectMenuBuilder,
|
||||
StringSelectMenuOptionBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
ModalBuilder,
|
||||
TextInputBuilder,
|
||||
TextInputStyle,
|
||||
} from 'discord.js';
|
||||
import type {
|
||||
ChatInputCommandInteraction,
|
||||
StringSelectMenuInteraction,
|
||||
ButtonInteraction,
|
||||
ModalSubmitInteraction,
|
||||
Client,
|
||||
} from 'discord.js';
|
||||
import { listEncounters, searchEncounters, getEncounter } from '../../graphmcp/client.js';
|
||||
import { log } from '../../lib/logger.js';
|
||||
|
||||
export const data = new SlashCommandBuilder()
|
||||
.setName('encounters')
|
||||
.setDescription('View and search past campaign encounters');
|
||||
|
||||
export async function execute(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
client: Client,
|
||||
): Promise<void> {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
let list;
|
||||
try {
|
||||
list = await listEncounters(10);
|
||||
} catch (err) {
|
||||
log.error('cmd', 'listEncounters failed', { error: String(err) });
|
||||
await interaction.editReply('❌ Failed to fetch past encounters from the archive.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (list.length === 0) {
|
||||
await interaction.editReply('📜 No encounters have been logged for this campaign yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
const select = new StringSelectMenuBuilder()
|
||||
.setCustomId('encounters_select')
|
||||
.setPlaceholder('Choose a past encounter to view...')
|
||||
.addOptions(
|
||||
list.map(e => {
|
||||
const date = e.timestamp ? e.timestamp.slice(0, 10) : 'No Date';
|
||||
const label = e.title.length > 50 ? e.title.slice(0, 47) + '...' : e.title;
|
||||
const desc = e.summary && e.summary.trim() !== ""
|
||||
? (e.summary.length > 90 ? e.summary.slice(0, 87) + '...' : e.summary)
|
||||
: `Location: ${e.location || 'Unknown'}`;
|
||||
return new StringSelectMenuOptionBuilder()
|
||||
.setLabel(`[${date}] ${label}`)
|
||||
.setDescription(desc)
|
||||
.setValue(e.id);
|
||||
}),
|
||||
);
|
||||
|
||||
const searchBtn = new ButtonBuilder()
|
||||
.setCustomId('encounters_search_btn')
|
||||
.setLabel('🔍 Search Archive')
|
||||
.setStyle(ButtonStyle.Primary);
|
||||
|
||||
const selectRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select);
|
||||
const buttonRow = new ActionRowBuilder<ButtonBuilder>().addComponents(searchBtn);
|
||||
|
||||
await interaction.editReply({
|
||||
content: '📚 **Campaign Chronicles**\nBrowse or search the recorded history of this campaign:',
|
||||
components: [selectRow, buttonRow],
|
||||
});
|
||||
}
|
||||
|
||||
// ── Select menu choice selection handler ──────────────────────────────────────
|
||||
export async function handleEncounterSelect(interaction: StringSelectMenuInteraction): Promise<void> {
|
||||
const encId = interaction.values[0];
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
try {
|
||||
const details = await getEncounter(encId);
|
||||
if (!details) {
|
||||
await interaction.editReply('❌ Encounter details could not be found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const date = details.timestamp ? new Date(details.timestamp).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'UTC',
|
||||
}) + ' UTC' : 'Unknown Date';
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`⚔️ ${details.title}`)
|
||||
.setDescription(details.summary && details.summary.trim() !== "" ? details.summary : '*No summary recorded.*')
|
||||
.addFields(
|
||||
{ name: '📍 Location', value: details.location || 'Unknown', inline: true },
|
||||
{ name: '📅 Date & Time', value: date, inline: true },
|
||||
{ name: '🏷️ Type', value: details.type || 'encounter', inline: true },
|
||||
)
|
||||
.setColor(0x8a2be2); // Runic Purple
|
||||
|
||||
if (details.participants && details.participants.length > 0) {
|
||||
embed.addFields({ name: '👥 Witnesses / Participants', value: details.participants.join(', ') });
|
||||
}
|
||||
|
||||
if (details.featured_entities && details.featured_entities.length > 0) {
|
||||
embed.addFields({ name: '✨ Featured Entities', value: details.featured_entities.join(', ') });
|
||||
}
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
} catch (err) {
|
||||
log.error('interaction', 'getEncounter details failed', { error: String(err) });
|
||||
await interaction.editReply('❌ Failed to fetch encounter details.');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Search button click handler ───────────────────────────────────────────────
|
||||
export async function handleSearchButton(interaction: ButtonInteraction): Promise<void> {
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId('encounters_search_modal')
|
||||
.setTitle('Search Campaign Chronicles');
|
||||
|
||||
const queryInput = new TextInputBuilder()
|
||||
.setCustomId('search_query')
|
||||
.setLabel('Keywords (search title/summary)')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setRequired(false)
|
||||
.setMaxLength(100);
|
||||
|
||||
const locationInput = new TextInputBuilder()
|
||||
.setCustomId('search_location')
|
||||
.setLabel('Filter by Location')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setRequired(false)
|
||||
.setMaxLength(100);
|
||||
|
||||
const participantInput = new TextInputBuilder()
|
||||
.setCustomId('search_participant')
|
||||
.setLabel('Filter by Participant/NPC')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setRequired(false)
|
||||
.setMaxLength(100);
|
||||
|
||||
const row1 = new ActionRowBuilder<TextInputBuilder>().addComponents(queryInput);
|
||||
const row2 = new ActionRowBuilder<TextInputBuilder>().addComponents(locationInput);
|
||||
const row3 = new ActionRowBuilder<TextInputBuilder>().addComponents(participantInput);
|
||||
|
||||
modal.addComponents(row1, row2, row3);
|
||||
|
||||
await interaction.showModal(modal);
|
||||
}
|
||||
|
||||
// ── Modal submission handler ──────────────────────────────────────────────────
|
||||
export async function handleSearchModalSubmit(interaction: ModalSubmitInteraction): Promise<void> {
|
||||
const query = interaction.fields.getTextInputValue('search_query').trim();
|
||||
const location = interaction.fields.getTextInputValue('search_location').trim();
|
||||
const participant = interaction.fields.getTextInputValue('search_participant').trim();
|
||||
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
if (!query && !location && !participant) {
|
||||
await interaction.editReply('⚠️ Please enter at least one filter criterion.');
|
||||
return;
|
||||
}
|
||||
|
||||
let results;
|
||||
try {
|
||||
results = await searchEncounters({ query, location, participant, limit: 10 });
|
||||
} catch (err) {
|
||||
log.error('interaction', 'searchEncounters failed', { error: String(err) });
|
||||
await interaction.editReply('❌ Search query failed.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
await interaction.editReply('📜 No matching encounters found in the archive.');
|
||||
return;
|
||||
}
|
||||
|
||||
const select = new StringSelectMenuBuilder()
|
||||
.setCustomId('encounters_select')
|
||||
.setPlaceholder('Choose a search result to view...')
|
||||
.addOptions(
|
||||
results.map(e => {
|
||||
const date = e.timestamp ? e.timestamp.slice(0, 10) : 'No Date';
|
||||
const label = e.title.length > 50 ? e.title.slice(0, 47) + '...' : e.title;
|
||||
const desc = e.summary && e.summary.trim() !== ""
|
||||
? (e.summary.length > 90 ? e.summary.slice(0, 87) + '...' : e.summary)
|
||||
: `Location: ${e.location || 'Unknown'}`;
|
||||
return new StringSelectMenuOptionBuilder()
|
||||
.setLabel(`[${date}] ${label}`)
|
||||
.setDescription(desc)
|
||||
.setValue(e.id);
|
||||
}),
|
||||
);
|
||||
|
||||
const selectRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select);
|
||||
|
||||
await interaction.editReply({
|
||||
content: `🔍 **Search Results**\nFound **${results.length}** matching encounter${results.length === 1 ? '' : 's'}:`,
|
||||
components: [selectRow],
|
||||
});
|
||||
}
|
||||
107
src/bot/commands/roll.ts
Normal file
107
src/bot/commands/roll.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { SlashCommandBuilder } from '@discordjs/builders';
|
||||
import type { ChatInputCommandInteraction, Client, ThreadChannel } from 'discord.js';
|
||||
import { playerRegistry } from '../../session/playerRegistry.js';
|
||||
import { sessionManager } from '../../session/sessionManager.js';
|
||||
import { scheduleEncounterLLMTurn } from '../handlers/messageRouter.js';
|
||||
import type { ChatMessage } from '../../types/index.js';
|
||||
|
||||
export const data = new SlashCommandBuilder()
|
||||
.setName('roll')
|
||||
.setDescription('Attempt an action that may require a skill check')
|
||||
.addStringOption(o =>
|
||||
o
|
||||
.setName('action')
|
||||
.setDescription('What are you attempting? (e.g. "I try to pick the lock")')
|
||||
.setRequired(true)
|
||||
.setMaxLength(300),
|
||||
);
|
||||
|
||||
export async function execute(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
client: Client,
|
||||
): Promise<void> {
|
||||
const guildId = interaction.guildId;
|
||||
if (!guildId) {
|
||||
await interaction.reply({ content: 'This command must be used in a server.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = interaction.channel;
|
||||
if (!channel?.isThread()) {
|
||||
await interaction.reply({
|
||||
content: 'Use `/roll` inside an active encounter thread.',
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await sessionManager.get(channel.id);
|
||||
if (!session || session.phase === 'resolved') {
|
||||
await interaction.reply({
|
||||
content: 'There is no active encounter in this thread.',
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.pendingSkillCheck) {
|
||||
await interaction.reply({
|
||||
content: 'A roll is already pending — use the buttons above to resolve it first.',
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const player = await playerRegistry.get(guildId, interaction.user.id);
|
||||
if (!player) {
|
||||
await interaction.reply({
|
||||
content: 'Register your character name first with `/dndname set`.',
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const action = interaction.options.getString('action', true).trim();
|
||||
|
||||
// Acknowledge ephemerally so the slash command doesn't clutter the thread
|
||||
await interaction.reply({ content: '🎲 Action submitted.', ephemeral: true });
|
||||
|
||||
// Post the action publicly so the whole table sees it
|
||||
await channel.send(`*${player.dndName} attempts: ${action}*`);
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Add as a player message so it appears in the LLM's conversation context
|
||||
const userMsg: ChatMessage = {
|
||||
role: 'user',
|
||||
content: `${player.dndName}: ${action}`,
|
||||
timestamp: now,
|
||||
};
|
||||
|
||||
// Try to extract a skill/ability name if the player mentioned one explicitly
|
||||
// e.g. "I roll perception", "rolling stealth", "perception check"
|
||||
const SKILL_EXTRACT_RE = /\b(perception|stealth|athletics|acrobatics|insight|deception|persuasion|intimidation|investigation|nature|religion|history|medicine|survival|arcana|performance|sleight of hand|animal handling|strength|dexterity|constitution|intelligence|wisdom|charisma)\b/i;
|
||||
const skillHint = SKILL_EXTRACT_RE.exec(action)?.[1];
|
||||
const skillLine = skillHint
|
||||
? `The player's message names "${skillHint}" — use that as the skill unless the action clearly calls for a different one.`
|
||||
: 'Choose the most appropriate skill or ability for the action.';
|
||||
|
||||
// Hard override — the LLM MUST emit a tool call and nothing else.
|
||||
const systemNudge: ChatMessage = {
|
||||
role: 'system',
|
||||
content: [
|
||||
`[/roll COMMAND — MANDATORY TOOL CALL]`,
|
||||
`${player.dndName} used the /roll slash command. You MUST output a skill_check_emit tool call block and NOTHING ELSE — no narrative, no explanation, no preamble.`,
|
||||
skillLine,
|
||||
`Your entire response must be exactly:`,
|
||||
'```tool_call',
|
||||
`{"tool":"skill_check_emit","args":{"player":"${player.dndName}","prompt":"<one sentence describing the check>","skill":"<skill name>","dc":<DC as integer>}}`,
|
||||
'```',
|
||||
].join('\n'),
|
||||
timestamp: now,
|
||||
};
|
||||
|
||||
await sessionManager.addMessage(session.threadId, userMsg);
|
||||
await sessionManager.addMessage(session.threadId, systemNudge);
|
||||
scheduleEncounterLLMTurn(session.threadId, channel as ThreadChannel, client, true);
|
||||
}
|
||||
71
src/bot/commands/xp.ts
Normal file
71
src/bot/commands/xp.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { SlashCommandBuilder } from '@discordjs/builders';
|
||||
import type { ChatInputCommandInteraction, ThreadChannel } from 'discord.js';
|
||||
import { sessionManager } from '../../session/sessionManager.js';
|
||||
import { awardXP } from '../../session/xpAwarder.js';
|
||||
import { config } from '../../config.js';
|
||||
|
||||
export const data = new SlashCommandBuilder()
|
||||
.setName('xp')
|
||||
.setDescription('DM: award XP to encounter participants')
|
||||
.addSubcommand(sub =>
|
||||
sub
|
||||
.setName('award')
|
||||
.setDescription('Award XP to all participants in the current encounter thread')
|
||||
.addIntegerOption(o =>
|
||||
o
|
||||
.setName('amount')
|
||||
.setDescription('XP to award — uses the encounter spec default if omitted')
|
||||
.setRequired(false)
|
||||
.setMinValue(1),
|
||||
),
|
||||
);
|
||||
|
||||
export async function execute(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const guildId = interaction.guildId;
|
||||
if (!guildId) {
|
||||
await interaction.reply({ content: 'This command must be used in a server.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
config.DISCORD_ALLOWED_USERS.length > 0 &&
|
||||
!config.DISCORD_ALLOWED_USERS.includes(interaction.user.id)
|
||||
) {
|
||||
await interaction.reply({ content: 'Only the DM can award XP.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = interaction.channel;
|
||||
if (!channel?.isThread()) {
|
||||
await interaction.reply({ content: 'Run this inside an encounter thread.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
const session = await sessionManager.get(channel.id);
|
||||
if (!session) {
|
||||
await interaction.editReply('No encounter found for this thread.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.keys(session.players).length === 0) {
|
||||
await interaction.editReply('No players joined this encounter — nothing to award.');
|
||||
return;
|
||||
}
|
||||
|
||||
const specDefault = session.spec.xpReward;
|
||||
const override = interaction.options.getInteger('amount') ?? undefined;
|
||||
const amount = override ?? specDefault;
|
||||
|
||||
if (!amount) {
|
||||
await interaction.editReply(
|
||||
'No XP amount specified and this encounter has no default in its spec. ' +
|
||||
'Pass an amount with `/xp award amount:<number>`.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await awardXP(session, amount, channel as ThreadChannel);
|
||||
await interaction.editReply(`XP awarded. See the thread for details.`);
|
||||
}
|
||||
39
src/bot/embeds/encounterDiscovery.ts
Normal file
39
src/bot/embeds/encounterDiscovery.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { EmbedBuilder } from 'discord.js';
|
||||
|
||||
export function buildEncounterListEmbed(
|
||||
encounters: Array<{
|
||||
title: string;
|
||||
phase: string;
|
||||
playerCount: number;
|
||||
location: string;
|
||||
threadId: string;
|
||||
}>,
|
||||
): EmbedBuilder {
|
||||
if (encounters.length === 0) {
|
||||
return new EmbedBuilder()
|
||||
.setTitle('⚔️ Active Encounters')
|
||||
.setDescription('No encounters are currently running. Ask your DM to start one.')
|
||||
.setColor(0x95a5a6)
|
||||
.setFooter({ text: 'Join an encounter by typing in its thread' });
|
||||
}
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('⚔️ Active Encounters')
|
||||
.setColor(0x5865f2)
|
||||
.setFooter({ text: 'Join an encounter by typing in its thread' });
|
||||
|
||||
for (const enc of encounters) {
|
||||
embed.addFields({
|
||||
name: enc.title,
|
||||
value: [
|
||||
`**Phase:** ${enc.phase}`,
|
||||
`**Players:** ${enc.playerCount}`,
|
||||
`**Location:** ${enc.location}`,
|
||||
`<#${enc.threadId}>`,
|
||||
].join('\n'),
|
||||
inline: false,
|
||||
});
|
||||
}
|
||||
|
||||
return embed;
|
||||
}
|
||||
48
src/bot/embeds/loreAnswer.ts
Normal file
48
src/bot/embeds/loreAnswer.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { EmbedBuilder } from 'discord.js';
|
||||
|
||||
export const LORE_COLOR = {
|
||||
FOUND: 0x2ecc71, // green — knowledge retrieved
|
||||
NONE: 0x95a5a6, // gray — no records in graph
|
||||
} as const;
|
||||
|
||||
// Pre-formatted source lines and history lines are built in the handler to
|
||||
// avoid coupling this module to graphmcp client types.
|
||||
export function buildLoreAnswerEmbed(
|
||||
answer: string,
|
||||
sourcesText: string | null,
|
||||
playerHistory: string | null,
|
||||
playerName: string | undefined,
|
||||
sourceCount: number,
|
||||
activeEncounterThreadId?: string,
|
||||
): EmbedBuilder {
|
||||
const color = sourceCount > 0 ? LORE_COLOR.FOUND : LORE_COLOR.NONE;
|
||||
const description = answer.length > 4000 ? answer.slice(0, 3997) + '…' : answer;
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('📜 Chronicle Records')
|
||||
.setDescription(description)
|
||||
.setColor(color);
|
||||
|
||||
if (sourcesText) {
|
||||
embed.addFields({ name: 'Sources', value: sourcesText, inline: false });
|
||||
}
|
||||
|
||||
if (playerHistory && playerName) {
|
||||
embed.addFields({ name: `${playerName}'s history`, value: playerHistory, inline: false });
|
||||
}
|
||||
|
||||
if (activeEncounterThreadId) {
|
||||
embed.addFields({
|
||||
name: 'Active encounter',
|
||||
value: `An encounter is in progress: <#${activeEncounterThreadId}>`,
|
||||
inline: false,
|
||||
});
|
||||
}
|
||||
|
||||
const footerText = sourceCount > 0
|
||||
? `${sourceCount} record${sourceCount === 1 ? '' : 's'} consulted · Knowledge graph`
|
||||
: 'No records found in knowledge graph';
|
||||
|
||||
embed.setFooter({ text: footerText });
|
||||
return embed;
|
||||
}
|
||||
12
src/bot/embeds/playerGate.ts
Normal file
12
src/bot/embeds/playerGate.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { EmbedBuilder } from 'discord.js';
|
||||
|
||||
export function buildPlayerGateEmbed(): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setTitle('Register Your Character')
|
||||
.setDescription(
|
||||
'You need a D&D character name before you can join this encounter.\n\n' +
|
||||
'Run `/dndname set <name>` and your message will be processed automatically.',
|
||||
)
|
||||
.setColor(0x5865f2)
|
||||
.setFooter({ text: 'Your message has been held and will be replayed after you register.' });
|
||||
}
|
||||
28
src/bot/embeds/resolution.ts
Normal file
28
src/bot/embeds/resolution.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { EmbedBuilder } from 'discord.js';
|
||||
import type { EncounterSpec, SessionState } from '../../types/index.js';
|
||||
|
||||
export function buildResolutionEmbed(
|
||||
spec: EncounterSpec,
|
||||
session: SessionState,
|
||||
outcomeId: string,
|
||||
summary: string,
|
||||
): EmbedBuilder {
|
||||
const allGoals = [...spec.goals.primary, ...spec.goals.secondary];
|
||||
const goal = allGoals.find(g => g.id === outcomeId);
|
||||
const outcomeLabel = goal ? goal.label.trim().split('\n')[0] : outcomeId;
|
||||
|
||||
const participants = Object.values(session.players)
|
||||
.map(p => p.dndName)
|
||||
.join(', ') || 'No players registered';
|
||||
|
||||
return new EmbedBuilder()
|
||||
.setTitle(`⚔️ Encounter Complete — ${spec.title}`)
|
||||
.setDescription(summary)
|
||||
.addFields(
|
||||
{ name: 'Outcome', value: outcomeLabel, inline: false },
|
||||
{ name: 'Participants', value: participants, inline: true },
|
||||
{ name: 'Location', value: spec.setting.location, inline: true },
|
||||
)
|
||||
.setColor(0x2ecc71)
|
||||
.setFooter({ text: 'NPC memories have been committed to the knowledge graph.' });
|
||||
}
|
||||
74
src/bot/embeds/skillCheck.ts
Normal file
74
src/bot/embeds/skillCheck.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js';
|
||||
|
||||
export const EMBED_COLOR = {
|
||||
PENDING: 0x5865f2, // blue — awaiting player action
|
||||
SUCCESS: 0x2ecc71, // green — roll succeeded
|
||||
FAILURE: 0xe74c3c, // red — roll failed
|
||||
} as const;
|
||||
|
||||
export function buildSuspenseEmbed(player: string, prompt: string): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setTitle(`🎲 The dice are cast...`)
|
||||
.setDescription(`**${player}** — ${prompt}\n\n*Fate will decide the outcome.*`)
|
||||
.setColor(EMBED_COLOR.PENDING);
|
||||
}
|
||||
|
||||
export function buildSkillCheckEmbed(
|
||||
player: string,
|
||||
prompt: string,
|
||||
dc: number,
|
||||
color: number = EMBED_COLOR.PENDING,
|
||||
footerText = '🎲 Roll your dice to determine your fate.',
|
||||
modifier?: number,
|
||||
skillLabel?: string,
|
||||
advantage?: boolean,
|
||||
disadvantage?: boolean,
|
||||
): EmbedBuilder {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`⚔️ Skill Check — ${player}`)
|
||||
.setDescription(`*${prompt}*`)
|
||||
.addFields({ name: '⚖️ DC', value: `**${dc}**`, inline: true })
|
||||
.setColor(color)
|
||||
.setFooter({ text: footerText });
|
||||
|
||||
if (modifier !== undefined) {
|
||||
const sign = modifier >= 0 ? `+${modifier}` : String(modifier);
|
||||
const label = skillLabel ? `${skillLabel} (${sign})` : sign;
|
||||
embed.addFields({ name: '🎯 Modifier', value: `**${label}**`, inline: true });
|
||||
}
|
||||
|
||||
if (advantage) {
|
||||
embed.addFields({ name: '🟢 Roll Mode', value: '**Advantage**', inline: true });
|
||||
} else if (disadvantage) {
|
||||
embed.addFields({ name: '🔴 Roll Mode', value: '**Disadvantage**', inline: true });
|
||||
}
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
export function buildRollButtons(modifier?: number): ActionRowBuilder<ButtonBuilder> {
|
||||
if (modifier !== undefined) {
|
||||
const sign = modifier >= 0 ? `+${modifier}` : String(modifier);
|
||||
return new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder().setCustomId(`sc_roll_m:${modifier}`).setLabel(`Roll (${sign})`).setStyle(ButtonStyle.Primary),
|
||||
new ButtonBuilder().setCustomId(`sc_adv_m:${modifier}`).setLabel(`Adv (${sign})`).setStyle(ButtonStyle.Success),
|
||||
new ButtonBuilder().setCustomId(`sc_dis_m:${modifier}`).setLabel(`Dis (${sign})`).setStyle(ButtonStyle.Danger),
|
||||
new ButtonBuilder().setCustomId('sc_mod').setLabel('Custom Modifier').setStyle(ButtonStyle.Secondary),
|
||||
);
|
||||
}
|
||||
return new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder().setCustomId('sc_roll').setLabel('Roll').setStyle(ButtonStyle.Primary),
|
||||
new ButtonBuilder().setCustomId('sc_adv').setLabel('Advantage').setStyle(ButtonStyle.Success),
|
||||
new ButtonBuilder().setCustomId('sc_dis').setLabel('Disadvantage').setStyle(ButtonStyle.Danger),
|
||||
new ButtonBuilder().setCustomId('sc_mod').setLabel('Roll with Modifier').setStyle(ButtonStyle.Secondary),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildModifierRollButtons(modifier: number): ActionRowBuilder<ButtonBuilder> {
|
||||
const sign = modifier >= 0 ? `+${modifier}` : String(modifier);
|
||||
return new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder().setCustomId(`sc_roll_m:${modifier}`).setLabel(`Roll (${sign})`).setStyle(ButtonStyle.Primary),
|
||||
new ButtonBuilder().setCustomId(`sc_adv_m:${modifier}`).setLabel(`Advantage (${sign})`).setStyle(ButtonStyle.Success),
|
||||
new ButtonBuilder().setCustomId(`sc_dis_m:${modifier}`).setLabel(`Disadvantage (${sign})`).setStyle(ButtonStyle.Danger),
|
||||
);
|
||||
}
|
||||
75
src/bot/handlers/generationQueue.ts
Normal file
75
src/bot/handlers/generationQueue.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// In-memory per-thread debounce and generation lock.
|
||||
// Intentionally ephemeral — resets on bot restart.
|
||||
|
||||
interface ThreadState {
|
||||
isGenerating: boolean;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
pendingCount: number;
|
||||
runner: (() => Promise<void>) | null;
|
||||
}
|
||||
|
||||
const threads = new Map<string, ThreadState>();
|
||||
|
||||
function getThread(threadId: string): ThreadState {
|
||||
let t = threads.get(threadId);
|
||||
if (!t) {
|
||||
t = { isGenerating: false, timer: null, pendingCount: 0, runner: null };
|
||||
threads.set(threadId, t);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule an LLM turn for a thread.
|
||||
*
|
||||
* - Normal messages: debounced 500ms so a burst of player chat coalesces into
|
||||
* one call rather than hammering the LLM for each line.
|
||||
* - Roll results / slash commands: pass immediate=true to skip the debounce
|
||||
* while still respecting the generation lock.
|
||||
* - If a generation is already running, the call is counted; when the running
|
||||
* turn finishes it will fire one drain turn for all messages that queued up.
|
||||
*
|
||||
* The runner must fetch fresh session state itself — this prevents stale
|
||||
* captures when the drain fires after the previous LLM response is stored.
|
||||
*/
|
||||
export function scheduleLLMTurn(
|
||||
threadId: string,
|
||||
runner: () => Promise<void>,
|
||||
immediate = false,
|
||||
): void {
|
||||
const t = getThread(threadId);
|
||||
t.runner = runner; // always keep the latest (freshest) runner
|
||||
|
||||
if (t.isGenerating) {
|
||||
t.pendingCount++;
|
||||
return;
|
||||
}
|
||||
|
||||
if (t.timer) clearTimeout(t.timer);
|
||||
t.timer = setTimeout(() => {
|
||||
t.timer = null;
|
||||
void fire(threadId);
|
||||
}, immediate ? 0 : 500);
|
||||
}
|
||||
|
||||
async function fire(threadId: string): Promise<void> {
|
||||
const t = getThread(threadId);
|
||||
if (!t.runner || t.isGenerating) return;
|
||||
|
||||
t.isGenerating = true;
|
||||
t.pendingCount = 0;
|
||||
|
||||
try {
|
||||
await t.runner();
|
||||
} catch {
|
||||
// Runner errors are the runner's responsibility to handle and log.
|
||||
// We just need the finally block to release the lock regardless.
|
||||
} finally {
|
||||
t.isGenerating = false;
|
||||
if (t.pendingCount > 0) {
|
||||
t.pendingCount = 0;
|
||||
// One drain turn covers everything that arrived during generation.
|
||||
void fire(threadId);
|
||||
}
|
||||
}
|
||||
}
|
||||
187
src/bot/handlers/mentionHandler.ts
Normal file
187
src/bot/handlers/mentionHandler.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import type { Message, Client, TextChannel } from 'discord.js';
|
||||
import { config } from '../../config.js';
|
||||
import { semanticSearch, queryAsNPC } from '../../graphmcp/client.js';
|
||||
import { publishToGraphMCP } from '../../graphmcp/ingest.js';
|
||||
import { callLLM } from '../../harness/llmClient.js';
|
||||
import { loadPersona } from '../../persona/loader.js';
|
||||
import { playerRegistry } from '../../session/playerRegistry.js';
|
||||
import { characterRegistry } from '../../session/characterRegistry.js';
|
||||
import { sessionManager } from '../../session/sessionManager.js';
|
||||
import { buildLoreAnswerEmbed } from '../embeds/loreAnswer.js';
|
||||
|
||||
export async function handleMention(message: Message, client: Client): Promise<void> {
|
||||
if (message.author.bot) return;
|
||||
if (message.channel.isThread()) return;
|
||||
if (!message.channel.isTextBased()) return;
|
||||
if (!client.user || !message.mentions.has(client.user)) return;
|
||||
if (!config.DISCORD_ALLOWED_CHANNELS.includes(message.channelId)) return;
|
||||
|
||||
const channel = message.channel as TextChannel;
|
||||
const query = message.content.replace(/<@!?\d+>/g, '').trim();
|
||||
|
||||
if (!query) {
|
||||
await message.reply(`*Zalram looks up from his notes, waiting.*`);
|
||||
return;
|
||||
}
|
||||
|
||||
publishToGraphMCP({
|
||||
messageId: message.id,
|
||||
content: query,
|
||||
author: message.author.username,
|
||||
channelId: message.channelId,
|
||||
channelName: channel.name,
|
||||
}).catch(err => console.warn('[ingest] mention publish failed:', err));
|
||||
|
||||
void channel.sendTyping();
|
||||
const typingInterval = setInterval(() => void channel.sendTyping(), 8_000);
|
||||
|
||||
const guildId = message.guildId ?? '';
|
||||
|
||||
// Run player lookup, character profile lookup, and semantic search in parallel.
|
||||
const [player, characterProfile, searchResult] = await Promise.all([
|
||||
playerRegistry.get(guildId, message.author.id).catch(() => null),
|
||||
characterRegistry.get(guildId, message.author.id).catch(() => null),
|
||||
semanticSearch(query, config.GRAPHMCP_MENTION_LIMIT).catch(err => {
|
||||
console.warn('[mentionHandler] semanticSearch failed:', err);
|
||||
return { chunks: [] as { content: string; score: number; source?: string }[] };
|
||||
}),
|
||||
]);
|
||||
|
||||
const relevantChunks = (searchResult.chunks ?? [])
|
||||
.filter(c => c.score > config.GRAPHMCP_SCORE_THRESHOLD)
|
||||
.slice(0, 4);
|
||||
|
||||
// If the player has a registered character, pull their encounter history from
|
||||
// the knowledge graph. This surfaces encounters they personally participated in
|
||||
// and any lore chunks the NPC persona has linked to them.
|
||||
let encounterHistoryLines: string[] = [];
|
||||
let npcLoreChunks: { text: string; score: number; source: string }[] = [];
|
||||
|
||||
if (player) {
|
||||
try {
|
||||
const npcResult = await queryAsNPC(player.dndName, query, config.GRAPHMCP_NPC_MEMORY_LIMIT);
|
||||
|
||||
if (npcResult.graph_context?.length) {
|
||||
encounterHistoryLines = npcResult.graph_context.slice(0, 3).map(enc => {
|
||||
const date = enc.enc_timestamp ? enc.enc_timestamp.slice(0, 10) : 'unknown';
|
||||
const summary = enc.enc_summary.length > 100
|
||||
? enc.enc_summary.slice(0, 97) + '…'
|
||||
: enc.enc_summary;
|
||||
return `⚔️ [${date}] **${enc.enc_title}** — ${summary}`;
|
||||
});
|
||||
}
|
||||
|
||||
npcLoreChunks = (npcResult.chunks ?? [])
|
||||
.filter(c => c.score > config.GRAPHMCP_SCORE_THRESHOLD)
|
||||
.slice(0, 2)
|
||||
.map(c => ({ text: c.text, score: c.score, source: c.source }));
|
||||
|
||||
} catch (err) {
|
||||
console.warn('[mentionHandler] queryAsNPC failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Find any active encounter thread in this guild so we can surface it.
|
||||
let activeEncounterThreadId: string | undefined;
|
||||
if (guildId) {
|
||||
try {
|
||||
const threadIds = await sessionManager.getGuildThreadIds(guildId);
|
||||
for (const tid of threadIds) {
|
||||
const session = await sessionManager.get(tid);
|
||||
if (session && session.phase !== 'resolved') {
|
||||
activeEncounterThreadId = tid;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch { /* non-critical — don't block on this */ }
|
||||
}
|
||||
|
||||
// Build embed source lines from semantic chunks + any extra NPC memory chunks.
|
||||
const allSourceChunks = [
|
||||
...relevantChunks.map(c => ({ text: c.content, source: c.source ?? 'lore' })),
|
||||
...npcLoreChunks.map(c => ({ text: c.text, source: c.source })),
|
||||
].slice(0, 5);
|
||||
|
||||
const sourcesText = allSourceChunks.length > 0
|
||||
? allSourceChunks.map(c => {
|
||||
const icon = c.source === 'lore' ? '📚' : '⚔️';
|
||||
const snippet = c.text.length > 120 ? c.text.slice(0, 117) + '…' : c.text;
|
||||
return `${icon} ${snippet}`;
|
||||
}).join('\n')
|
||||
: null;
|
||||
|
||||
const playerHistoryText = encounterHistoryLines.length > 0
|
||||
? encounterHistoryLines.join('\n')
|
||||
: null;
|
||||
|
||||
// Build the LLM prompt context block.
|
||||
const persona = loadPersona(config.PERSONA_PATH);
|
||||
let contextBlock: string;
|
||||
|
||||
if (allSourceChunks.length > 0 || encounterHistoryLines.length > 0) {
|
||||
const loreSection = allSourceChunks.length > 0
|
||||
? 'Lore records:\n' + allSourceChunks.map(c => `- ${c.text.slice(0, 300)}`).join('\n')
|
||||
: '';
|
||||
const historySection = encounterHistoryLines.length > 0
|
||||
? `\n${player!.dndName}'s encounter history:\n` + encounterHistoryLines.join('\n')
|
||||
: '';
|
||||
contextBlock = `VERIFIED KNOWLEDGE GRAPH DATA — use only this as your factual basis:\n${loreSection}${historySection}`;
|
||||
} else {
|
||||
contextBlock = `NO RECORDS FOUND — the knowledge graph returned nothing relevant to this query.
|
||||
Do NOT invent, speculate, or fabricate any specific details, names, dates, places,
|
||||
ledger entries, transactions, or events. You must say clearly that you have no record
|
||||
of this. You may note what kind of event would produce a record, or ask a clarifying
|
||||
question, but you must not fill the gap with invented content.`;
|
||||
}
|
||||
|
||||
const activeEncounterNote = activeEncounterThreadId
|
||||
? `\n[Note: there is an active encounter in progress in thread ${activeEncounterThreadId}.]`
|
||||
: '';
|
||||
|
||||
const characterBlock = characterProfile
|
||||
? [
|
||||
`Character context for ${characterProfile.dndName}:`,
|
||||
` Class: ${characterProfile.characterClass}, Race: ${characterProfile.race}, Level: ${characterProfile.level}`,
|
||||
characterProfile.backstory ? ` ${characterProfile.backstory}` : '',
|
||||
].filter(Boolean).join('\n')
|
||||
: '';
|
||||
|
||||
const systemContent = [
|
||||
persona.persona.trim(),
|
||||
'',
|
||||
persona.responseStyle.trim(),
|
||||
'',
|
||||
contextBlock + activeEncounterNote,
|
||||
characterBlock ? '' : undefined,
|
||||
characterBlock || undefined,
|
||||
].filter((s): s is string => s !== undefined).join('\n');
|
||||
|
||||
const now = Date.now();
|
||||
const llmMessages = [
|
||||
{ role: 'system' as const, content: systemContent, timestamp: now },
|
||||
{ role: 'user' as const, content: query, timestamp: now },
|
||||
];
|
||||
|
||||
let answer: string;
|
||||
try {
|
||||
const response = await callLLM(llmMessages);
|
||||
answer = response.narrative || '*The Chronicler is silent.*';
|
||||
} catch (err) {
|
||||
console.error('[mentionHandler] LLM call failed:', err);
|
||||
answer = '*The Chronicler pauses, lost in distant memory… (error, please try again)*';
|
||||
} finally {
|
||||
clearInterval(typingInterval);
|
||||
}
|
||||
|
||||
const sourceCount = allSourceChunks.length + encounterHistoryLines.length;
|
||||
const embed = buildLoreAnswerEmbed(
|
||||
answer,
|
||||
sourcesText,
|
||||
playerHistoryText,
|
||||
player?.dndName,
|
||||
sourceCount,
|
||||
activeEncounterThreadId,
|
||||
);
|
||||
|
||||
await message.reply({ embeds: [embed] });
|
||||
}
|
||||
384
src/bot/handlers/messageRouter.ts
Normal file
384
src/bot/handlers/messageRouter.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import type { Message, Client, ThreadChannel, TextChannel } from 'discord.js';
|
||||
import { playerRegistry } from '../../session/playerRegistry.js';
|
||||
import { characterRegistry } from '../../session/characterRegistry.js';
|
||||
import { sessionManager } from '../../session/sessionManager.js';
|
||||
import { assembleContext } from '../../harness/contextAssembler.js';
|
||||
import { callLLM } from '../../harness/llmClient.js';
|
||||
import { dispatchTool } from '../../harness/toolDispatcher.js';
|
||||
import { buildPlayerGateEmbed } from '../embeds/playerGate.js';
|
||||
import { publishToGraphMCP } from '../../graphmcp/ingest.js';
|
||||
import { config } from '../../config.js';
|
||||
import { scheduleLLMTurn } from './generationQueue.js';
|
||||
import { filterLLMResponse, logFiltered, detectMissedSkillCheck } from './responseFilter.js';
|
||||
import { registerScheduled, drainPending, clearPending, upgradeToProcessing, upgradeToComplete, cleanupReactions } from './reactionManager.js';
|
||||
import { isBurstCapped, incrementBurst, resetBurst, sendDropNotice } from './queueCap.js';
|
||||
import { log } from '../../lib/logger.js';
|
||||
import type { ChatMessage, SessionState } from '../../types/index.js';
|
||||
|
||||
// Text fallback for players who manually type their roll result
|
||||
const ROLL_RE = /i\s+rolled\s+(?:a\s+)?(\d+)\s+([\w][\w\s]*)/i;
|
||||
|
||||
const PENDING_ROLL_LIMIT = 5;
|
||||
|
||||
function isAllowedChannel(parentId: string | null): boolean {
|
||||
if (!parentId) return false;
|
||||
return config.DISCORD_ALLOWED_CHANNELS.includes(parentId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: called from the messageCreate event
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function handleMessage(message: Message, client: Client): Promise<void> {
|
||||
if (message.author.bot) return;
|
||||
if (!message.channel.isThread()) return;
|
||||
|
||||
const thread = message.channel as ThreadChannel;
|
||||
if (!isAllowedChannel(thread.parentId)) return;
|
||||
|
||||
const session = await sessionManager.get(thread.id);
|
||||
if (!session || session.phase === 'resolved') return;
|
||||
|
||||
// 👀 immediately — fire-and-forget, must not block or throw
|
||||
message.react('👀').catch(() => null);
|
||||
|
||||
await processEncounterMessage(session, thread, message.author.id, message.content, client, message.id, message);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: replay held messages after a player registers via /dndname set
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function replayHeldMessages(
|
||||
userId: string,
|
||||
guildId: string,
|
||||
client: Client,
|
||||
): Promise<void> {
|
||||
const threadIds = await sessionManager.getGuildThreadIds(guildId);
|
||||
|
||||
for (const threadId of threadIds) {
|
||||
const session = await sessionManager.get(threadId);
|
||||
if (!session || session.phase === 'resolved') continue;
|
||||
|
||||
const held = session.heldMessages.filter(m => m.discordUserId === userId);
|
||||
if (held.length === 0) continue;
|
||||
|
||||
// Remove held messages before replaying so a crash doesn't double-replay
|
||||
await sessionManager.update(threadId, {
|
||||
heldMessages: session.heldMessages.filter(m => m.discordUserId !== userId),
|
||||
});
|
||||
|
||||
const thread = await client.channels.fetch(threadId).catch(() => null);
|
||||
if (!thread?.isThread()) continue;
|
||||
|
||||
const freshSession = await sessionManager.get(threadId);
|
||||
if (!freshSession) continue;
|
||||
|
||||
for (const msg of held) {
|
||||
const syntheticId = `held-${userId}-${msg.timestamp}`;
|
||||
await processEncounterMessage(freshSession, thread as ThreadChannel, userId, msg.content, client, syntheticId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core message processing pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processEncounterMessage(
|
||||
session: SessionState,
|
||||
thread: ThreadChannel | TextChannel,
|
||||
userId: string,
|
||||
content: string,
|
||||
client: Client,
|
||||
messageId: string,
|
||||
sourceMessage?: Message,
|
||||
): Promise<void> {
|
||||
const guildId = session.guildId;
|
||||
|
||||
// ── Text roll fallback — lets players type "I rolled a 17 Acrobatics" if preferred
|
||||
const rollMatch = ROLL_RE.exec(content);
|
||||
if (rollMatch && session.pendingSkillCheck) {
|
||||
const roll = parseInt(rollMatch[1], 10);
|
||||
const skill = rollMatch[2].trim();
|
||||
const { dc, player, messageId } = session.pendingSkillCheck;
|
||||
const success = roll >= dc;
|
||||
|
||||
// Disable the embed buttons since the roll is resolved
|
||||
if (messageId) {
|
||||
const original = await (thread as ThreadChannel).messages?.fetch(messageId).catch(() => null);
|
||||
if (original) await original.edit({ components: [] }).catch(() => null);
|
||||
}
|
||||
|
||||
const systemMsg: ChatMessage = {
|
||||
role: 'system',
|
||||
content: `[SKILL CHECK RESULT] ${player} rolled ${roll} ${skill} vs DC ${dc}. Result: ${success ? 'SUCCESS' : 'FAILURE'}.`,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
await sessionManager.update(session.threadId, {
|
||||
pendingSkillCheck: undefined,
|
||||
pendingSkillCheckAttempts: undefined,
|
||||
});
|
||||
await sessionManager.addMessage(session.threadId, systemMsg);
|
||||
scheduleEncounterLLMTurn(session.threadId, thread, client, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Player gate
|
||||
const player = await playerRegistry.get(guildId, userId);
|
||||
if (!player) {
|
||||
const held = [...session.heldMessages, { discordUserId: userId, content, timestamp: Date.now() }];
|
||||
await sessionManager.update(session.threadId, { heldMessages: held });
|
||||
|
||||
const gate = buildPlayerGateEmbed();
|
||||
const sent = await thread.send({ content: `<@${userId}>`, embeds: [gate] });
|
||||
setTimeout(() => sent.delete().catch(() => null), 30_000);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Block messages while a dice roll is pending
|
||||
if (session.pendingSkillCheck) {
|
||||
const attempts = (session.pendingSkillCheckAttempts ?? 0) + 1;
|
||||
|
||||
if (attempts >= PENDING_ROLL_LIMIT) {
|
||||
// Auto-cancel: disable the embed buttons and inject a FAIL result
|
||||
const { messageId, player: checkPlayer, dc } = session.pendingSkillCheck;
|
||||
if (messageId) {
|
||||
const original = await (thread as ThreadChannel).messages?.fetch(messageId).catch(() => null);
|
||||
if (original) await original.edit({ components: [] }).catch(() => null);
|
||||
}
|
||||
|
||||
const failMsg: ChatMessage = {
|
||||
role: 'system',
|
||||
content: `[SKILL CHECK RESULT] ${checkPlayer} failed to roll vs DC ${dc}. Result: FAILURE (auto-cancelled after ${PENDING_ROLL_LIMIT} skipped messages).`,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
await sessionManager.update(session.threadId, {
|
||||
pendingSkillCheck: undefined,
|
||||
pendingSkillCheckAttempts: undefined,
|
||||
});
|
||||
await sessionManager.addMessage(session.threadId, failMsg);
|
||||
scheduleEncounterLLMTurn(session.threadId, thread, client, true);
|
||||
return;
|
||||
}
|
||||
|
||||
await sessionManager.update(session.threadId, { pendingSkillCheckAttempts: attempts });
|
||||
const remaining = PENDING_ROLL_LIMIT - attempts;
|
||||
await thread.send(
|
||||
`*A roll is still pending! Use the buttons above to roll. (${remaining} message${remaining === 1 ? '' : 's'} left before auto-fail.)*`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Burst cap — drop message if too many arrived before the last LLM response
|
||||
if (isBurstCapped(session.threadId)) {
|
||||
if (sourceMessage) {
|
||||
const botId = sourceMessage.client.user?.id;
|
||||
if (botId) {
|
||||
sourceMessage.reactions.cache.find(r => r.emoji.name === '👀')?.users.remove(botId).catch(() => null);
|
||||
}
|
||||
await sendDropNotice(sourceMessage, session.spec.tone);
|
||||
}
|
||||
return;
|
||||
}
|
||||
incrementBurst(session.threadId);
|
||||
|
||||
// ── Add player to session if first appearance
|
||||
if (!session.players[userId]) {
|
||||
const charProfile = await characterRegistry.get(session.guildId, userId).catch(() => null);
|
||||
const playerEntry = charProfile?.pronouns
|
||||
? { ...player, pronouns: charProfile.pronouns }
|
||||
: player;
|
||||
const updatedPlayers = { ...session.players, [userId]: playerEntry };
|
||||
const joinMsg: ChatMessage = {
|
||||
role: 'system',
|
||||
content: `[SESSION] ${player.dndName} has entered the encounter.`,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
await sessionManager.update(session.threadId, {
|
||||
players: updatedPlayers,
|
||||
phase: 'active',
|
||||
});
|
||||
await sessionManager.addMessage(session.threadId, joinMsg);
|
||||
}
|
||||
|
||||
// ── Append player message to history
|
||||
const userMsg: ChatMessage = {
|
||||
role: 'user',
|
||||
content: `${player.dndName}: ${content}`,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
await sessionManager.addMessage(session.threadId, userMsg);
|
||||
|
||||
// Publish to GraphMCP — only messages that passed both gates (registered
|
||||
// player + no pending roll) reach this point, so the LLM will see them.
|
||||
publishToGraphMCP({
|
||||
messageId,
|
||||
content,
|
||||
author: player.dndName,
|
||||
channelId: thread.id,
|
||||
channelName: thread.name,
|
||||
}).catch(err => console.warn('[ingest] encounter publish failed:', err));
|
||||
|
||||
// Debounced: waits 500ms for any further messages before firing the LLM,
|
||||
// so a burst of player chat coalesces into a single response.
|
||||
scheduleEncounterLLMTurn(session.threadId, thread, client, false, sourceMessage);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queue-aware scheduler — always fetches fresh session before firing.
|
||||
// Pass immediate=true for roll results / slash commands (no debounce).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function scheduleEncounterLLMTurn(
|
||||
threadId: string,
|
||||
thread: ThreadChannel | TextChannel,
|
||||
client: Client,
|
||||
immediate = false,
|
||||
sourceMessage?: Message,
|
||||
): void {
|
||||
if (sourceMessage) {
|
||||
registerScheduled(threadId, sourceMessage);
|
||||
}
|
||||
|
||||
scheduleLLMTurn(
|
||||
threadId,
|
||||
async () => {
|
||||
const s = await sessionManager.get(threadId);
|
||||
if (!s || s.phase === 'resolved') {
|
||||
clearPending(threadId);
|
||||
return;
|
||||
}
|
||||
// Don't call the LLM while a roll is pending — the roll handler will
|
||||
// schedule a fresh turn once the result arrives.
|
||||
if (s.pendingSkillCheck) {
|
||||
clearPending(threadId);
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = drainPending(threadId);
|
||||
upgradeToProcessing(pending);
|
||||
let completedOk = false;
|
||||
try {
|
||||
await runLLMTurn(s, thread, client);
|
||||
upgradeToComplete(pending);
|
||||
completedOk = true;
|
||||
} finally {
|
||||
if (!completedOk) cleanupReactions(pending);
|
||||
resetBurst(threadId);
|
||||
}
|
||||
},
|
||||
immediate,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LLM call + response routing — exported for use by rollHandler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function runLLMTurn(
|
||||
session: SessionState,
|
||||
thread: ThreadChannel | TextChannel,
|
||||
_client: Client,
|
||||
): Promise<void> {
|
||||
const context = assembleContext(session);
|
||||
|
||||
void thread.sendTyping();
|
||||
const typingInterval = setInterval(() => void thread.sendTyping(), 8_000);
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await callLLM(context);
|
||||
} catch (err) {
|
||||
clearInterval(typingInterval);
|
||||
console.error('[messageRouter] LLM call failed:', err);
|
||||
await thread.send('*The narrator pauses, lost in thought… (LLM error, please retry)*');
|
||||
return;
|
||||
}
|
||||
clearInterval(typingInterval);
|
||||
|
||||
if (!response.toolCall && !session.pendingSkillCheck && response.narrative) {
|
||||
if (detectMissedSkillCheck(response.narrative)) {
|
||||
log.warn('harness', 'possible_missed_skill_check', {
|
||||
threadId: session.threadId,
|
||||
encounterId: session.encounterId,
|
||||
narrativeSnippet: response.narrative.slice(0, 200),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (response.narrative) {
|
||||
const filter = filterLLMResponse(response.narrative);
|
||||
if (!filter.ok) {
|
||||
logFiltered(filter.reason!, response.narrative, {
|
||||
threadId: session.threadId,
|
||||
encounterId: session.encounterId,
|
||||
});
|
||||
|
||||
// Guard against tight retry loops: skip if we just injected a correction.
|
||||
const lastMsg = session.history[session.history.length - 1];
|
||||
const alreadyRetried = lastMsg?.role === 'system' && lastMsg.content.startsWith('[FILTER CORRECTION]');
|
||||
|
||||
if (!alreadyRetried) {
|
||||
const correctionText = filter.reason === 'fabricated_roll_result'
|
||||
? 'Do NOT state or imply a specific dice result. Wait for the [SKILL CHECK RESULT] system message before narrating any outcome.'
|
||||
: filter.reason === 'echoed_system_tag'
|
||||
? 'Do NOT echo internal system tags like [TOOL], [SESSION], or [SKILL CHECK] verbatim in your response.'
|
||||
: 'Your previous response was empty. Continue the scene.';
|
||||
|
||||
const correction: ChatMessage = {
|
||||
role: 'system',
|
||||
content: `[FILTER CORRECTION] Your last response was suppressed (${filter.reason}). ${correctionText}`,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
await sessionManager.addMessage(session.threadId, correction);
|
||||
|
||||
// Retry once with the correction in context.
|
||||
scheduleEncounterLLMTurn(session.threadId, thread, _client, true);
|
||||
}
|
||||
// Fall through so any accompanying tool call still fires.
|
||||
} else {
|
||||
await thread.send(response.narrative);
|
||||
// Only store an assistant message when there is actual narrative.
|
||||
// Tool-call-only turns are represented solely by the system message the
|
||||
// tool handler writes. Storing a placeholder teaches the LLM to echo it.
|
||||
const assistantMsg: ChatMessage = {
|
||||
role: 'assistant',
|
||||
content: response.narrative,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
await sessionManager.addMessage(session.threadId, assistantMsg);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.toolCall) {
|
||||
const freshSession = await sessionManager.get(session.threadId);
|
||||
if (!freshSession) return;
|
||||
|
||||
const result = await dispatchTool(response.toolCall, { session: freshSession, thread });
|
||||
|
||||
const toolMsg: ChatMessage = {
|
||||
role: 'system',
|
||||
content: result.systemMessage,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
await sessionManager.addMessage(session.threadId, toolMsg);
|
||||
|
||||
if (result.error) {
|
||||
await thread.send('*The narrator stumbles… something went wrong behind the scenes. Try your action again.*');
|
||||
}
|
||||
|
||||
if (result.resolved) {
|
||||
await sessionManager.update(session.threadId, {
|
||||
phase: 'resolved',
|
||||
outcome: result.resolved.outcomeId,
|
||||
outcomeSummary: result.resolved.summary,
|
||||
});
|
||||
setTimeout(async () => {
|
||||
await (thread as ThreadChannel).setArchived?.(true).catch(() => null);
|
||||
}, 5_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
58
src/bot/handlers/queueCap.ts
Normal file
58
src/bot/handlers/queueCap.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
// Per-thread burst message cap and in-world drop notice delivery.
|
||||
// The burst counter tracks how many player messages have been added to the
|
||||
// session history since the last LLM turn. Messages beyond the cap of 2 are
|
||||
// dropped without reaching the LLM.
|
||||
|
||||
import type { Message } from 'discord.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Burst counter — module-level, ephemeral (resets on bot restart)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CAP = 2;
|
||||
|
||||
const burstCount = new Map<string, number>();
|
||||
|
||||
export function isBurstCapped(threadId: string): boolean {
|
||||
return (burstCount.get(threadId) ?? 0) >= CAP;
|
||||
}
|
||||
|
||||
export function incrementBurst(threadId: string): void {
|
||||
burstCount.set(threadId, (burstCount.get(threadId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
export function resetBurst(threadId: string): void {
|
||||
burstCount.delete(threadId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drop notice strings — keyed by encounter tone, pre-generated (no LLM call)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DROP_NOTICES: Record<string, string> = {
|
||||
grim: '*"The chaos swallowed your words before they could reach the moment. Silence yourself until the echoes clear."*',
|
||||
comedic: '*"Everyone was talking at once and the universe, frankly, wasn\'t listening. Give it a moment."*',
|
||||
mysterious: '*"Something in the fabric of this place muffled your voice. Wait. It will pass."*',
|
||||
tense: '*"No time — the moment moved on without you. Hold. Wait for your opening."*',
|
||||
baseline: '*"The echoes of the encounter could not carry all voices at once. Wait for the dust to settle before speaking again."*',
|
||||
};
|
||||
|
||||
export function getDropNotice(tone?: string): string {
|
||||
if (tone && tone in DROP_NOTICES) return DROP_NOTICES[tone];
|
||||
return DROP_NOTICES.baseline;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drop notice delivery — DM preferred; thread reply + auto-delete as fallback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function sendDropNotice(msg: Message, tone?: string): Promise<void> {
|
||||
const text = getDropNotice(tone);
|
||||
try {
|
||||
await msg.author.send(text);
|
||||
} catch {
|
||||
// DMs disabled — reply in thread and auto-delete after 8s
|
||||
const sent = await msg.reply({ content: text }).catch(() => null);
|
||||
if (sent) setTimeout(() => sent.delete().catch(() => null), 8_000);
|
||||
}
|
||||
}
|
||||
90
src/bot/handlers/reactionManager.ts
Normal file
90
src/bot/handlers/reactionManager.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
// Manages per-thread pending message reaction tracking.
|
||||
// All Discord API calls are fire-and-forget — errors are swallowed.
|
||||
|
||||
import type { Message } from 'discord.js';
|
||||
|
||||
export interface PendingEntry {
|
||||
msg: Message;
|
||||
content: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pending map — module-level, reset on bot restart (ephemeral by design)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const pendingByThread = new Map<string, PendingEntry[]>();
|
||||
|
||||
export function registerScheduled(threadId: string, msg: Message): void {
|
||||
const entries = pendingByThread.get(threadId) ?? [];
|
||||
entries.push({ msg, content: msg.content });
|
||||
pendingByThread.set(threadId, entries);
|
||||
}
|
||||
|
||||
export function drainPending(threadId: string): PendingEntry[] {
|
||||
const entries = pendingByThread.get(threadId) ?? [];
|
||||
pendingByThread.delete(threadId);
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function clearPending(threadId: string): void {
|
||||
pendingByThread.delete(threadId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Heuristic — detect dice-related intent in a player message
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DICE_INTENT_RE = /\b(?:roll|attack|check|save|saving throw|d20)\b/i;
|
||||
|
||||
export function isDiceRelated(content: string): boolean {
|
||||
return DICE_INTENT_RE.test(content);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reaction removal helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function removeBotReaction(msg: Message, emoji: string): void {
|
||||
const botId = msg.client.user?.id;
|
||||
if (!botId) return;
|
||||
msg.reactions.cache
|
||||
.find(r => r.emoji.name === emoji)
|
||||
?.users.remove(botId)
|
||||
.catch(() => null);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function upgradeToProcessing(entries: PendingEntry[]): void {
|
||||
for (const { msg, content } of entries) {
|
||||
removeBotReaction(msg, '👀');
|
||||
msg.react('⏳').catch(() => null);
|
||||
if (isDiceRelated(content)) {
|
||||
msg.react('🎲').catch(() => null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function upgradeToComplete(entries: PendingEntry[]): void {
|
||||
for (const { msg } of entries) {
|
||||
removeBotReaction(msg, '⏳');
|
||||
removeBotReaction(msg, '🎲');
|
||||
msg.react('✅').catch(() => null);
|
||||
const botId = msg.client.user?.id;
|
||||
setTimeout(() => {
|
||||
if (botId) {
|
||||
msg.reactions.cache.find(r => r.emoji.name === '✅')?.users.remove(botId).catch(() => null);
|
||||
}
|
||||
}, 10_000);
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanupReactions(entries: PendingEntry[]): void {
|
||||
for (const { msg } of entries) {
|
||||
removeBotReaction(msg, '👀');
|
||||
removeBotReaction(msg, '⏳');
|
||||
removeBotReaction(msg, '🎲');
|
||||
}
|
||||
}
|
||||
67
src/bot/handlers/responseFilter.ts
Normal file
67
src/bot/handlers/responseFilter.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { log } from '../../lib/logger.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Patterns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// LLM echoing internal system message tags verbatim
|
||||
const SYSTEM_TAG_RE = /\[TOOL[^\]]*\]|\[SKILL CHECK[^\]]*\]|\[SESSION[^\]]*\]|\[\/roll[^\]]*\]|\[SYSTEM[^\]]*\]/i;
|
||||
|
||||
// LLM claiming a specific dice roll result (the bot controls dice, not the LLM).
|
||||
// Catches: "you rolled a 15", "the brawler rolled a 12", "rolls a 7", "the die shows", etc.
|
||||
const ROLL_CLAIM_RE = /\brolled\s+(?:a\s+)?\d+\b|\bthe die shows\b|\bthe dice (?:show|showed)\b|\brolls?\s+(?:a\s+)?\d+\b/i;
|
||||
|
||||
// LLM describing that a skill check is needed but without emitting skill_check_emit.
|
||||
// Used as a diagnostic heuristic — only meaningful when no tool call was present in the response.
|
||||
// Catches: "you need to roll", "roll for X", "roll your X", "requires a check/roll",
|
||||
// "make a [X] check/save/saving throw", "attempt a [X] check/roll"
|
||||
const MISSED_SKILL_CHECK_RE =
|
||||
/\byou(?:'ll| will)?\s+need\s+to\s+(?:make\s+(?:a\s+)?)?roll\b|\broll\s+(?:for|your)\s+\w|\brequires\s+a\s+(?:\w+\s+)?(?:roll|check)\b|\bmake\s+a\s+(?:\w+\s+){0,3}(?:check|saving throw|save)\b|\battempt\s+(?:a\s+)?(?:\w+\s+)?(?:check|roll)\b/i;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FilterResult {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export function filterLLMResponse(narrative: string): FilterResult {
|
||||
if (!narrative.trim()) {
|
||||
return { ok: false, reason: 'empty_response' };
|
||||
}
|
||||
|
||||
if (SYSTEM_TAG_RE.test(narrative)) {
|
||||
return { ok: false, reason: 'echoed_system_tag' };
|
||||
}
|
||||
|
||||
if (ROLL_CLAIM_RE.test(narrative)) {
|
||||
return { ok: false, reason: 'fabricated_roll_result' };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic: did the LLM describe a skill check as needed but omit the tool call?
|
||||
* Call this only when no tool_call block was present in the response and no roll is pending.
|
||||
* Returns true when the narrative appears to request a roll from the player.
|
||||
*/
|
||||
export function detectMissedSkillCheck(narrative: string): boolean {
|
||||
if (!narrative.trim()) return false;
|
||||
return MISSED_SKILL_CHECK_RE.test(narrative);
|
||||
}
|
||||
|
||||
export function logFiltered(
|
||||
reason: string,
|
||||
narrative: string,
|
||||
context: { threadId: string; encounterId: string },
|
||||
): void {
|
||||
log.warn('llm-filter', reason, {
|
||||
threadId: context.threadId,
|
||||
encounterId: context.encounterId,
|
||||
// Full narrative in a separate field so structured log viewers can expand it
|
||||
filteredNarrative: narrative,
|
||||
});
|
||||
}
|
||||
181
src/bot/handlers/rollHandler.ts
Normal file
181
src/bot/handlers/rollHandler.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
type ButtonInteraction,
|
||||
type ModalSubmitInteraction,
|
||||
type Client,
|
||||
type ThreadChannel,
|
||||
type TextChannel,
|
||||
ModalBuilder,
|
||||
TextInputBuilder,
|
||||
TextInputStyle,
|
||||
ActionRowBuilder,
|
||||
} from 'discord.js';
|
||||
import { sessionManager } from '../../session/sessionManager.js';
|
||||
import { buildSkillCheckEmbed, buildModifierRollButtons, EMBED_COLOR } from '../embeds/skillCheck.js';
|
||||
import { scheduleEncounterLLMTurn } from './messageRouter.js';
|
||||
import type { ChatMessage } from '../../types/index.js';
|
||||
|
||||
type RollChannel = ThreadChannel | TextChannel;
|
||||
|
||||
function d20(): number {
|
||||
return Math.floor(Math.random() * 20) + 1;
|
||||
}
|
||||
|
||||
function rollSingle(): { value: number; desc: string } {
|
||||
const value = d20();
|
||||
return { value, desc: `rolled **${value}**` };
|
||||
}
|
||||
|
||||
function rollAdvantage(): { value: number; desc: string } {
|
||||
const a = d20(), b = d20();
|
||||
const value = Math.max(a, b);
|
||||
return { value, desc: `rolled with advantage (${a}, ${b}) → **${value}**` };
|
||||
}
|
||||
|
||||
function rollDisadvantage(): { value: number; desc: string } {
|
||||
const a = d20(), b = d20();
|
||||
const value = Math.min(a, b);
|
||||
return { value, desc: `rolled with disadvantage (${a}, ${b}) → **${value}**` };
|
||||
}
|
||||
|
||||
async function submitResult(
|
||||
interaction: ButtonInteraction,
|
||||
roll: { value: number; desc: string },
|
||||
modifier: number,
|
||||
client: Client,
|
||||
): Promise<void> {
|
||||
const channel = interaction.channel as RollChannel | null;
|
||||
if (!channel?.isThread()) return;
|
||||
|
||||
const session = await sessionManager.get(channel.id);
|
||||
if (!session?.pendingSkillCheck) {
|
||||
await interaction.reply({ content: 'This skill check has already been resolved.', flags: 64 });
|
||||
return;
|
||||
}
|
||||
|
||||
const { dc, player, prompt } = session.pendingSkillCheck;
|
||||
const total = roll.value + modifier;
|
||||
const success = total >= dc;
|
||||
|
||||
const modPart = modifier !== 0
|
||||
? ` ${modifier >= 0 ? '+' : ''}${modifier} = **${total}**`
|
||||
: '';
|
||||
const fullDesc = roll.desc + modPart;
|
||||
|
||||
const resultEmbed = buildSkillCheckEmbed(
|
||||
player, prompt, dc,
|
||||
success ? EMBED_COLOR.SUCCESS : EMBED_COLOR.FAILURE,
|
||||
success ? '✅ Success' : '❌ Failure',
|
||||
).addFields(
|
||||
{ name: 'Roll', value: fullDesc, inline: true },
|
||||
{ name: 'Result', value: success ? '✅ SUCCESS' : '❌ FAILURE', inline: true },
|
||||
);
|
||||
|
||||
await interaction.update({ embeds: [resultEmbed], components: [] });
|
||||
|
||||
const systemMsg: ChatMessage = {
|
||||
role: 'system',
|
||||
content: `[SKILL CHECK RESULT] ${player} ${fullDesc} vs DC ${dc}. Result: ${success ? 'SUCCESS' : 'FAILURE'}.`,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
await sessionManager.update(session.threadId, {
|
||||
pendingSkillCheck: undefined,
|
||||
pendingSkillCheckAttempts: undefined,
|
||||
});
|
||||
await sessionManager.addMessage(session.threadId, systemMsg);
|
||||
scheduleEncounterLLMTurn(session.threadId, channel, client, true);
|
||||
}
|
||||
|
||||
export function isSkillCheckInteraction(
|
||||
interaction: ButtonInteraction | ModalSubmitInteraction,
|
||||
): boolean {
|
||||
if (interaction.isButton()) return interaction.customId.startsWith('sc_');
|
||||
if (interaction.isModalSubmit()) return interaction.customId === 'sc_mod_modal';
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function handleRollInteraction(
|
||||
interaction: ButtonInteraction | ModalSubmitInteraction,
|
||||
client: Client,
|
||||
): Promise<void> {
|
||||
if (interaction.isButton()) {
|
||||
const id = interaction.customId;
|
||||
|
||||
if (id === 'sc_roll') return submitResult(interaction, rollSingle(), 0, client);
|
||||
if (id === 'sc_adv') return submitResult(interaction, rollAdvantage(), 0, client);
|
||||
if (id === 'sc_dis') return submitResult(interaction, rollDisadvantage(), 0, client);
|
||||
|
||||
if (id === 'sc_mod') {
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId('sc_mod_modal')
|
||||
.setTitle('Enter your modifier')
|
||||
.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(
|
||||
new TextInputBuilder()
|
||||
.setCustomId('modifier_value')
|
||||
.setLabel('Modifier (e.g. +3, -1, 5)')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setRequired(true)
|
||||
.setMaxLength(4),
|
||||
),
|
||||
);
|
||||
await interaction.showModal(modal);
|
||||
return;
|
||||
}
|
||||
|
||||
// sc_roll_m:3, sc_adv_m:-2, sc_dis_m:1
|
||||
const modMatch = /^sc_(roll|adv|dis)_m:(-?\d+)$/.exec(id);
|
||||
if (modMatch) {
|
||||
const type = modMatch[1];
|
||||
const modifier = parseInt(modMatch[2], 10);
|
||||
const roll =
|
||||
type === 'adv' ? rollAdvantage() :
|
||||
type === 'dis' ? rollDisadvantage() :
|
||||
rollSingle();
|
||||
return submitResult(interaction, roll, modifier, client);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Modal submit for modifier
|
||||
if (interaction.isModalSubmit() && interaction.customId === 'sc_mod_modal') {
|
||||
const channel = interaction.channel as RollChannel | null;
|
||||
if (!channel?.isThread()) return;
|
||||
|
||||
const session = await sessionManager.get(channel.id);
|
||||
if (!session?.pendingSkillCheck) {
|
||||
await interaction.reply({ content: 'This skill check has already been resolved.', flags: 64 });
|
||||
return;
|
||||
}
|
||||
|
||||
const rawMod = interaction.fields.getTextInputValue('modifier_value').trim();
|
||||
const modifier = parseInt(rawMod.replace(/^\+/, ''), 10);
|
||||
if (isNaN(modifier)) {
|
||||
await interaction.reply({
|
||||
content: 'Invalid modifier — enter a number like `+3`, `-1`, or `5`.',
|
||||
flags: 64,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove buttons from the original skill check embed now that modifier flow is active
|
||||
const { messageId, player, prompt, dc, modifier: charModifier, skill } = session.pendingSkillCheck;
|
||||
if (messageId) {
|
||||
const original = await (channel as ThreadChannel).messages.fetch(messageId).catch(() => null);
|
||||
if (original) {
|
||||
const bare = buildSkillCheckEmbed(player, prompt, dc, undefined, undefined, charModifier, skill);
|
||||
await original.edit({ embeds: [bare], components: [] }).catch(() => null);
|
||||
}
|
||||
}
|
||||
|
||||
const sign = modifier >= 0 ? `+${modifier}` : String(modifier);
|
||||
const modEmbed = buildSkillCheckEmbed(player, prompt, dc)
|
||||
.setFooter({ text: `Modifier: ${sign}` });
|
||||
|
||||
await interaction.reply({
|
||||
embeds: [modEmbed],
|
||||
components: [buildModifierRollButtons(modifier)],
|
||||
});
|
||||
}
|
||||
}
|
||||
185
src/bot/index.ts
Normal file
185
src/bot/index.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { Client, GatewayIntentBits, Collection } from 'discord.js';
|
||||
import 'dotenv/config';
|
||||
import { config } from '../config.js';
|
||||
import { redis } from '../db/redis.js';
|
||||
import { handleMessage } from './handlers/messageRouter.js';
|
||||
import { handleMention } from './handlers/mentionHandler.js';
|
||||
import { handleRollInteraction, isSkillCheckInteraction } from './handlers/rollHandler.js';
|
||||
import * as dndnameCmd from './commands/dndname.js';
|
||||
import * as encounterCmd from './commands/encounter.js';
|
||||
import * as characterCmd from './commands/character.js';
|
||||
import * as rollCmd from './commands/roll.js';
|
||||
import * as actionsCmd from './commands/actions.js';
|
||||
import * as xpCmd from './commands/xp.js';
|
||||
import * as encountersCmd from './commands/encounters.js';
|
||||
import { handleGiveModal, handleFoundryLinkModal, handleCustomRegisterModal } from './commands/character.js';
|
||||
import { handleEncounterSelect, handleSearchButton, handleSearchModalSubmit } from './commands/encounters.js';
|
||||
import { log } from '../lib/logger.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type CommandModule = {
|
||||
data: { name: string };
|
||||
execute: (interaction: any, client: Client) => Promise<void>;
|
||||
};
|
||||
|
||||
const commands = new Collection<string, CommandModule>();
|
||||
commands.set('dndname', dndnameCmd as CommandModule);
|
||||
commands.set('encounter', encounterCmd as CommandModule);
|
||||
commands.set('character', characterCmd as CommandModule);
|
||||
commands.set('roll', rollCmd as CommandModule);
|
||||
commands.set('actions', actionsCmd as CommandModule);
|
||||
commands.set('xp', xpCmd as CommandModule);
|
||||
commands.set('encounters', encountersCmd as CommandModule);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Discord client
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.MessageContent, // Privileged — enable in Discord Dev Portal
|
||||
],
|
||||
});
|
||||
|
||||
client.once('ready', () => {
|
||||
console.log(`[bot] Logged in as ${client.user?.tag}`);
|
||||
});
|
||||
|
||||
client.on('interactionCreate', async (interaction) => {
|
||||
// ── Skill-check roll buttons and modifier modal
|
||||
if (
|
||||
(interaction.isButton() || interaction.isModalSubmit()) &&
|
||||
isSkillCheckInteraction(interaction)
|
||||
) {
|
||||
const kind = interaction.isButton() ? 'button' : 'modal';
|
||||
const id = interaction.isButton() ? interaction.customId : interaction.customId;
|
||||
log.info('interaction', `${kind} ${id}`, { user: interaction.user.username });
|
||||
const start = Date.now();
|
||||
try {
|
||||
await handleRollInteraction(interaction, client);
|
||||
log.info('interaction', `${kind} ok`, { id, latencyMs: Date.now() - start });
|
||||
} catch (err) {
|
||||
log.error('interaction', `${kind} error`, { id, error: String(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Give item: modal submitted → search relay by name, call giveItem
|
||||
if (interaction.isModalSubmit() && interaction.customId === 'give_modal') {
|
||||
try {
|
||||
await handleGiveModal(interaction);
|
||||
} catch (err) {
|
||||
log.error('interaction', 'give_modal error', { error: String(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Custom character registration modal
|
||||
if (interaction.isModalSubmit() && interaction.customId === 'character_custom_modal') {
|
||||
try {
|
||||
await handleCustomRegisterModal(interaction);
|
||||
} catch (err) {
|
||||
log.error('interaction', 'character_custom_modal error', { error: String(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Foundry link: modal submitted → search relay by name, save to registry
|
||||
if (interaction.isModalSubmit() && interaction.customId === 'foundry_link_modal') {
|
||||
try {
|
||||
await handleFoundryLinkModal(interaction);
|
||||
} catch (err) {
|
||||
log.error('interaction', 'foundry_link_modal error', { error: String(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Encounters Select Menu Interaction
|
||||
if (interaction.isStringSelectMenu() && interaction.customId === 'encounters_select') {
|
||||
try {
|
||||
await handleEncounterSelect(interaction);
|
||||
} catch (err) {
|
||||
log.error('interaction', 'encounters_select error', { error: String(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Encounters Search Button Interaction
|
||||
if (interaction.isButton() && interaction.customId === 'encounters_search_btn') {
|
||||
try {
|
||||
await handleSearchButton(interaction);
|
||||
} catch (err) {
|
||||
log.error('interaction', 'encounters_search_btn error', { error: String(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Encounters Search Modal Submit Interaction
|
||||
if (interaction.isModalSubmit() && interaction.customId === 'encounters_search_modal') {
|
||||
try {
|
||||
await handleSearchModalSubmit(interaction);
|
||||
} catch (err) {
|
||||
log.error('interaction', 'encounters_search_modal error', { error: String(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!interaction.isChatInputCommand()) return;
|
||||
|
||||
const command = commands.get(interaction.commandName);
|
||||
if (!command) return;
|
||||
|
||||
// Build a label that includes the subcommand when present (e.g. "encounter start")
|
||||
const sub = interaction.options.getSubcommand(false);
|
||||
const label = sub ? `${interaction.commandName} ${sub}` : interaction.commandName;
|
||||
|
||||
log.info('cmd', `/${label}`, {
|
||||
user: interaction.user.username,
|
||||
guild: interaction.guildId ?? undefined,
|
||||
channel: interaction.channelId,
|
||||
});
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
await command.execute(interaction, client);
|
||||
log.info('cmd', `/${label} ok`, { latencyMs: Date.now() - start });
|
||||
} catch (err) {
|
||||
log.error('cmd', `/${label} error`, { latencyMs: Date.now() - start, error: String(err) });
|
||||
const reply = { content: 'An error occurred.', ephemeral: true };
|
||||
if (interaction.deferred || interaction.replied) {
|
||||
await interaction.editReply(reply).catch(() => null);
|
||||
} else {
|
||||
await interaction.reply(reply).catch(() => null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
client.on('messageCreate', async (message) => {
|
||||
try {
|
||||
await handleMention(message, client);
|
||||
await handleMessage(message, client);
|
||||
} catch (err) {
|
||||
console.error('[bot] messageCreate error:', err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Startup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await redis.connect();
|
||||
console.log('[bot] Redis connected');
|
||||
|
||||
await client.login(config.DISCORD_TOKEN);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[bot] startup failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
53
src/bot/lib/welcomeDM.ts
Normal file
53
src/bot/lib/welcomeDM.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { EmbedBuilder } from 'discord.js';
|
||||
import type { User } from 'discord.js';
|
||||
|
||||
export async function sendWelcomeDM(user: User, guildName?: string): Promise<void> {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('⚔️ Welcome to the Encounter Table!')
|
||||
.setDescription(
|
||||
guildName
|
||||
? `*A message from the scribes of **${guildName}**...*`
|
||||
: '*A message from the scribes...*',
|
||||
)
|
||||
.setColor(0x5865f2)
|
||||
.addFields(
|
||||
{
|
||||
name: '📖 How it works',
|
||||
value:
|
||||
'An AI Dungeon Master narrates encounters in real-time inside dedicated threads. ' +
|
||||
'Type what your character does — the DM responds, NPCs react, and the world moves. ' +
|
||||
'Skill checks, combat, roleplay — all handled live.',
|
||||
},
|
||||
{
|
||||
name: '🎲 Commands you\'ll use',
|
||||
value: [
|
||||
'`/roll <action>` — Attempt something risky. Describes what you\'re trying to do and asks the DM to call for a check if it warrants one.',
|
||||
'`/actions` — View your current inventory and prepared spells from Foundry VTT.',
|
||||
'`/character view` — Fetch your live character stats: HP, AC, abilities, and more.',
|
||||
'`/character show` — See your registered character profile.',
|
||||
'`/character register foundry` — Link your Foundry VTT character for live modifiers and inventory.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
name: '🎯 Skill checks',
|
||||
value:
|
||||
'When a check is called, a panel appears with **Roll**, **Advantage**, and **Disadvantage** buttons. ' +
|
||||
'If your character is linked to Foundry, your modifier is pre-loaded automatically. ' +
|
||||
'Click to roll — no maths required.',
|
||||
},
|
||||
{
|
||||
name: '💡 Tips',
|
||||
value: [
|
||||
'• Be specific with your actions — *"I try to pick the lock using my thieves\' tools"* lands better than *"I pick the lock"*.',
|
||||
'• The DM remembers NPC reactions and past events across sessions — your reputation carries.',
|
||||
'• If a roll is pending, resolve it before sending more messages or it will auto-fail.',
|
||||
'• Use `/roll` to explicitly request a skill check if the DM hasn\'t called one.',
|
||||
].join('\n'),
|
||||
},
|
||||
)
|
||||
.setFooter({ text: 'May your rolls be high and your saving throws blessed.' });
|
||||
|
||||
await user.send({ embeds: [embed] }).catch(() => {
|
||||
// DMs may be disabled — silently ignore, it's not critical
|
||||
});
|
||||
}
|
||||
79
src/config.ts
Normal file
79
src/config.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { z } from 'zod';
|
||||
import 'dotenv/config';
|
||||
|
||||
const EnvSchema = z.object({
|
||||
// ── Discord ──────────────────────────────────────────────────────────────
|
||||
DISCORD_TOKEN: z.string(),
|
||||
DISCORD_CLIENT_ID: z.string(),
|
||||
DISCORD_GUILD_ID: z.string().optional(),
|
||||
// Comma-separated channel IDs. Threads checked against parent. Empty = nowhere.
|
||||
DISCORD_ALLOWED_CHANNELS: z
|
||||
.string()
|
||||
.default('')
|
||||
.transform(val => val.split(',').map(s => s.trim()).filter(Boolean)),
|
||||
// Comma-separated user IDs who can run /encounter commands. Empty = everyone.
|
||||
DISCORD_ALLOWED_USERS: z
|
||||
.string()
|
||||
.default('')
|
||||
.transform(val => val.split(',').map(s => s.trim()).filter(Boolean)),
|
||||
|
||||
// ── Redis ────────────────────────────────────────────────────────────────
|
||||
REDIS_URL: z.string().default('redis://localhost:6379'),
|
||||
// How long a session lives in Redis without activity (hours).
|
||||
SESSION_TTL_HOURS: z.coerce.number().default(12),
|
||||
|
||||
// ── LiteLLM (primary) ────────────────────────────────────────────────────
|
||||
// If set, LiteLLM is used as the primary LLM client with Ollama as fallback.
|
||||
LITELLM_BASE_URL: z.string().default('http://100.83.8.74:4000'),
|
||||
LITELLM_API_KEY: z.string().optional(),
|
||||
// Model name as configured in LiteLLM. Defaults to OLLAMA_MODEL if unset.
|
||||
LITELLM_MODEL: z.string().optional(),
|
||||
|
||||
// ── Ollama / LLM ─────────────────────────────────────────────────────────
|
||||
OLLAMA_BASE_URL: z.string().default('http://localhost:11434'),
|
||||
OLLAMA_MODEL: z.string().default('gemma4-it:e2b'),
|
||||
// Sampling temperature. Higher = more creative, lower = more predictable.
|
||||
OLLAMA_TEMPERATURE: z.coerce.number().min(0).max(2).default(0.75),
|
||||
// Context window passed to Ollama (tokens). Must match the model's max.
|
||||
OLLAMA_NUM_CTX: z.coerce.number().default(131072),
|
||||
// How long to wait for an LLM response before giving up (ms).
|
||||
OLLAMA_TIMEOUT_MS: z.coerce.number().default(120_000),
|
||||
|
||||
// ── GraphMCP ─────────────────────────────────────────────────────────────
|
||||
GRAPHMCP_URL: z.string().default('http://localhost:9000'),
|
||||
// Minimum semantic similarity score to include a chunk as relevant context.
|
||||
// Range 0–1. Higher = stricter. Affects NPC memory and @mention responses.
|
||||
GRAPHMCP_SCORE_THRESHOLD: z.coerce.number().min(0).max(1).default(0.68),
|
||||
// Max memory chunks fetched per NPC at session start.
|
||||
GRAPHMCP_NPC_MEMORY_LIMIT: z.coerce.number().default(5),
|
||||
// Max chunks fetched for @mention semantic search.
|
||||
GRAPHMCP_MENTION_LIMIT: z.coerce.number().default(5),
|
||||
// Redis stream name that the discord-connector and this bot both publish to.
|
||||
// Must match the REDIS_STREAM env var on the discord-connector container.
|
||||
GRAPHMCP_INGEST_STREAM: z.string().default('raw.messages'),
|
||||
|
||||
// ── Encounter behaviour ──────────────────────────────────────────────────
|
||||
SPECS_DIR: z.string().default('./specs'),
|
||||
// Delay before archiving a resolved thread (ms). Gives players time to read.
|
||||
ENCOUNTER_ARCHIVE_DELAY_MS: z.coerce.number().default(5_000),
|
||||
// How long the player-gate embed lingers before auto-delete (ms).
|
||||
ENCOUNTER_GATE_TIMEOUT_MS: z.coerce.number().default(30_000),
|
||||
|
||||
// ── Persona ──────────────────────────────────────────────────────────────
|
||||
// Path to the YAML file defining the bot's @mention persona.
|
||||
PERSONA_PATH: z.string().default('./persona.yaml'),
|
||||
// Directory for tally.json and encounter summaries.
|
||||
DATA_DIR: z.string().default('./data'),
|
||||
|
||||
// ── Foundry VTT relay ────────────────────────────────────────────────────
|
||||
VTT_RELAY_URL: z.string().default('https://vtt-relay.damascusfront.net'),
|
||||
// Required for any Foundry VTT integration. Leave unset to disable VTT features.
|
||||
VTT_API_KEY: z.string().default(''),
|
||||
VTT_CLIENT_ID: z.string().default(''),
|
||||
|
||||
// ── Logging ──────────────────────────────────────────────────────────────
|
||||
LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error']).default('info'),
|
||||
});
|
||||
|
||||
export { EnvSchema };
|
||||
export const config = EnvSchema.parse(process.env);
|
||||
11
src/db/redis.ts
Normal file
11
src/db/redis.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Redis } from 'ioredis';
|
||||
import { config } from '../config.js';
|
||||
|
||||
export const redis = new Redis(config.REDIS_URL, {
|
||||
lazyConnect: true,
|
||||
maxRetriesPerRequest: 3,
|
||||
});
|
||||
|
||||
redis.on('error', (err: Error) => {
|
||||
console.error('[redis] connection error', err);
|
||||
});
|
||||
200
src/graphmcp/client.ts
Normal file
200
src/graphmcp/client.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { config } from '../config.js';
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GraphMCP response types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface NPCQueryChunk {
|
||||
text: string;
|
||||
score: number;
|
||||
source: 'message' | 'lore';
|
||||
author: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface NPCQueryEncounter {
|
||||
enc_id: string;
|
||||
enc_title: string;
|
||||
enc_type: string;
|
||||
enc_timestamp: string;
|
||||
enc_summary: string;
|
||||
featured_entities: string[];
|
||||
locations: string[];
|
||||
}
|
||||
|
||||
export interface NPCQueryResult {
|
||||
npc: string;
|
||||
tier: string;
|
||||
horizon_count: number;
|
||||
chunks: NPCQueryChunk[];
|
||||
graph_context: NPCQueryEncounter[];
|
||||
}
|
||||
|
||||
export interface SemanticChunk {
|
||||
content: string;
|
||||
score: number;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface SemanticSearchResult {
|
||||
chunks: SemanticChunk[];
|
||||
}
|
||||
|
||||
export interface LogEncounterParams {
|
||||
title: string;
|
||||
participants: string;
|
||||
summary: string;
|
||||
location?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface LogEncounterResult {
|
||||
enc_id: string;
|
||||
title: string;
|
||||
participants: string;
|
||||
location: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON-RPC call helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _rpcId = 1;
|
||||
|
||||
async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
||||
const body = {
|
||||
jsonrpc: '2.0',
|
||||
id: _rpcId++,
|
||||
method: 'tools/call',
|
||||
params: { name, arguments: args },
|
||||
};
|
||||
|
||||
const res = await fetch(`${config.GRAPHMCP_URL}/mcp`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`GraphMCP HTTP ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
|
||||
const json = await res.json() as {
|
||||
result?: { content?: Array<{ text: string }> };
|
||||
error?: { message: string };
|
||||
};
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(`GraphMCP error: ${json.error.message}`);
|
||||
}
|
||||
|
||||
const text = json.result?.content?.[0]?.text;
|
||||
if (!text) return null;
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function queryAsNPC(
|
||||
npcName: string,
|
||||
question: string,
|
||||
limit = 5,
|
||||
): Promise<NPCQueryResult> {
|
||||
const result = await callTool('query_as_npc', { npc_name: npcName, question, limit });
|
||||
return result as NPCQueryResult;
|
||||
}
|
||||
|
||||
export async function semanticSearch(query: string, limit = 5): Promise<SemanticSearchResult> {
|
||||
const result = await callTool('semantic_search', { query, limit });
|
||||
return (result ?? { chunks: [] }) as SemanticSearchResult;
|
||||
}
|
||||
|
||||
export async function logEncounter(params: LogEncounterParams): Promise<LogEncounterResult> {
|
||||
const result = await callTool('log_encounter', {
|
||||
title: params.title,
|
||||
participants: params.participants,
|
||||
summary: params.summary,
|
||||
location: params.location ?? '',
|
||||
type: params.type ?? 'encounter',
|
||||
});
|
||||
return result as LogEncounterResult;
|
||||
}
|
||||
|
||||
export interface EncounterResultItem {
|
||||
id: string;
|
||||
title: string;
|
||||
location: string;
|
||||
timestamp: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface EncounterDetails {
|
||||
id: string;
|
||||
title: string;
|
||||
location: string;
|
||||
timestamp: string;
|
||||
summary: string;
|
||||
type: string;
|
||||
participants: string[];
|
||||
featured_entities: string[];
|
||||
}
|
||||
|
||||
export async function listEncounters(limit = 10): Promise<EncounterResultItem[]> {
|
||||
const result = await callTool('list_encounters', { limit });
|
||||
return (result ?? []) as EncounterResultItem[];
|
||||
}
|
||||
|
||||
export async function searchEncounters(params: {
|
||||
query?: string;
|
||||
location?: string;
|
||||
participant?: string;
|
||||
limit?: number;
|
||||
}): Promise<EncounterResultItem[]> {
|
||||
const result = await callTool('search_encounters', params);
|
||||
return (result ?? []) as EncounterResultItem[];
|
||||
}
|
||||
|
||||
export async function getEncounter(id: string): Promise<EncounterDetails> {
|
||||
const result = await callTool('get_encounter', { id });
|
||||
return result as EncounterDetails;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Format NPCQueryResult into readable system-prompt text
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function formatNPCMemory(result: NPCQueryResult | null): string {
|
||||
const chunks = result?.chunks ?? [];
|
||||
const graphContext = result?.graph_context ?? [];
|
||||
|
||||
if (!result || (result.horizon_count === 0 && chunks.length === 0)) {
|
||||
return 'No prior encounters on record — first meeting.';
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (graphContext.length > 0) {
|
||||
parts.push('Past encounters witnessed:');
|
||||
for (const enc of graphContext) {
|
||||
const date = enc.enc_timestamp ? enc.enc_timestamp.slice(0, 10) : 'unknown';
|
||||
parts.push(` - [${date}] ${enc.enc_title}: ${enc.enc_summary}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (chunks.length > 0) {
|
||||
const relevant = chunks.filter(c => c.score > config.GRAPHMCP_SCORE_THRESHOLD).slice(0, 3);
|
||||
if (relevant.length > 0) {
|
||||
parts.push('Relevant lore/context:');
|
||||
for (const chunk of relevant) {
|
||||
const snippet = chunk.text.length > 200 ? chunk.text.slice(0, 197) + '…' : chunk.text;
|
||||
parts.push(` - ${snippet}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join('\n') : 'No prior encounters on record — first meeting.';
|
||||
}
|
||||
43
src/graphmcp/ingest.ts
Normal file
43
src/graphmcp/ingest.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { redis } from '../db/redis.js';
|
||||
import { config } from '../config.js';
|
||||
|
||||
const SEVEN_DAYS_SECS = 7 * 24 * 60 * 60;
|
||||
|
||||
export interface IngestPayload {
|
||||
messageId: string;
|
||||
content: string;
|
||||
author: string;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a qualifying Discord message to the GraphMCP raw.messages Redis
|
||||
* stream for ingestion into the knowledge graph.
|
||||
*
|
||||
* Uses the same dedup key pattern as the discord-connector so the two
|
||||
* publishers never produce duplicate stream entries for the same message ID.
|
||||
*/
|
||||
export async function publishToGraphMCP(payload: IngestPayload): Promise<void> {
|
||||
const { messageId, content, author, channelId, channelName } = payload;
|
||||
|
||||
if (!content.trim()) return;
|
||||
|
||||
// SetNX returns 1 if we acquired the key (first publisher), 0 if already set
|
||||
const dedupKey = `discord:seen:${messageId}`;
|
||||
const acquired = await redis.setnx(dedupKey, '1');
|
||||
if (!acquired) return;
|
||||
await redis.expire(dedupKey, SEVEN_DAYS_SECS);
|
||||
|
||||
await redis.xadd(
|
||||
config.GRAPHMCP_INGEST_STREAM,
|
||||
'*',
|
||||
'id', messageId,
|
||||
'content', content,
|
||||
'author', author,
|
||||
'timestamp', new Date().toISOString(),
|
||||
'source', 'discord',
|
||||
'channel_id', channelId,
|
||||
'channel_name', channelName,
|
||||
);
|
||||
}
|
||||
52
src/graphmcp/loreResolver.ts
Normal file
52
src/graphmcp/loreResolver.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { semanticSearch } from './client.js';
|
||||
import { sampleFromVocabulary } from './vocabularyResolver.js';
|
||||
import { config } from '../config.js';
|
||||
import type { RandomizableItem } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Resolves all randomizable spec items against the GraphMCP knowledge graph.
|
||||
*
|
||||
* For each item: queries semanticSearch, filters results above the score
|
||||
* threshold, picks one at random, and trims it to a usable length.
|
||||
* Falls back to item.fallback if GraphMCP returns nothing useful.
|
||||
*
|
||||
* Returns a flat Record<key, resolvedValue> ready to store in session state
|
||||
* and inject into the system prompt.
|
||||
*/
|
||||
export async function resolveRandomizables(
|
||||
items: RandomizableItem[],
|
||||
): Promise<Record<string, string>> {
|
||||
if (items.length === 0) return {};
|
||||
|
||||
const resolved: Record<string, string> = {};
|
||||
|
||||
await Promise.all(
|
||||
items.map(async item => {
|
||||
if (item.source === 'vocabulary') {
|
||||
resolved[item.key] = sampleFromVocabulary(item.category ?? '', item.fallback);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await semanticSearch(item.query, config.GRAPHMCP_NPC_MEMORY_LIMIT);
|
||||
const candidates = (result.chunks ?? [])
|
||||
.filter(c => c.score > config.GRAPHMCP_SCORE_THRESHOLD);
|
||||
|
||||
if (candidates.length > 0) {
|
||||
// Pick randomly so repeated starts of the same spec feel different
|
||||
const pick = candidates[Math.floor(Math.random() * candidates.length)];
|
||||
// Trim to something the LLM can use inline without blowing context
|
||||
const text = pick.content.trim().slice(0, 200);
|
||||
resolved[item.key] = text || item.fallback;
|
||||
} else {
|
||||
resolved[item.key] = item.fallback;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[loreResolver] failed to resolve "${item.key}":`, err);
|
||||
resolved[item.key] = item.fallback;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return resolved;
|
||||
}
|
||||
38
src/graphmcp/vocabularyResolver.ts
Normal file
38
src/graphmcp/vocabularyResolver.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { load } from 'js-yaml';
|
||||
|
||||
let _cache: Record<string, unknown> | null = null;
|
||||
|
||||
function loadVocabulary(): Record<string, unknown> {
|
||||
if (!_cache) {
|
||||
const path = join(process.cwd(), 'lore', 'vocabulary.yaml');
|
||||
_cache = load(readFileSync(path, 'utf-8')) as Record<string, unknown>;
|
||||
}
|
||||
return _cache;
|
||||
}
|
||||
|
||||
function getList(vocab: Record<string, unknown>, dotPath: string): string[] {
|
||||
const parts = dotPath.split('.');
|
||||
let node: unknown = vocab;
|
||||
for (const part of parts) {
|
||||
if (typeof node !== 'object' || node === null || !(part in node)) return [];
|
||||
node = (node as Record<string, unknown>)[part];
|
||||
}
|
||||
return Array.isArray(node) ? (node as string[]) : [];
|
||||
}
|
||||
|
||||
export function sampleFromVocabulary(category: string, fallback: string): string {
|
||||
try {
|
||||
const vocab = loadVocabulary();
|
||||
const items = getList(vocab, category);
|
||||
if (items.length === 0) {
|
||||
console.warn(`[vocabularyResolver] category "${category}" is empty or not found`);
|
||||
return fallback;
|
||||
}
|
||||
return items[Math.floor(Math.random() * items.length)];
|
||||
} catch (err) {
|
||||
console.warn(`[vocabularyResolver] failed to load vocabulary:`, err);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
34
src/harness/contextAssembler.ts
Normal file
34
src/harness/contextAssembler.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { encode } from 'gpt-tokenizer';
|
||||
import type { SessionState, ChatMessage } from '../types/index.js';
|
||||
import { CONTEXT_BUDGET } from '../types/index.js';
|
||||
import { buildSystemPrompt } from './promptBuilder.js';
|
||||
|
||||
function estimateTokens(text: string): number {
|
||||
return Math.ceil(encode(text).length * 1.15);
|
||||
}
|
||||
|
||||
function estimateMessages(messages: ChatMessage[]): number {
|
||||
return messages.reduce((sum, m) => sum + estimateTokens(m.content) + 4, 0);
|
||||
}
|
||||
|
||||
function trimHistory(messages: ChatMessage[]): ChatMessage[] {
|
||||
const budget = CONTEXT_BUDGET.HISTORY - CONTEXT_BUDGET.SAFETY;
|
||||
const result = [...messages];
|
||||
while (estimateMessages(result) > budget && result.length > 6) {
|
||||
result.splice(0, 2);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function assembleContext(session: SessionState): ChatMessage[] {
|
||||
const systemPrompt = buildSystemPrompt(session.spec, session.npcMemories, session.resolvedContext, session.players);
|
||||
const pinned = session.history.filter(m => m.pinned);
|
||||
const sliding = session.history.filter(m => !m.pinned);
|
||||
const trimmed = trimHistory(sliding);
|
||||
|
||||
return [
|
||||
{ role: 'system', content: systemPrompt, pinned: true, timestamp: 0 },
|
||||
...pinned,
|
||||
...trimmed,
|
||||
];
|
||||
}
|
||||
47
src/harness/litellmClient.ts
Normal file
47
src/harness/litellmClient.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import OpenAI from 'openai';
|
||||
import { config } from '../config.js';
|
||||
import { parseToolCall } from './toolParser.js';
|
||||
import { log } from '../lib/logger.js';
|
||||
import type { ChatMessage, LLMResponse } from '../types/index.js';
|
||||
|
||||
let _client: OpenAI | null = null;
|
||||
|
||||
function getClient(): OpenAI {
|
||||
if (!_client) {
|
||||
_client = new OpenAI({
|
||||
baseURL: `${config.LITELLM_BASE_URL}/v1`,
|
||||
apiKey: config.LITELLM_API_KEY || 'no-key',
|
||||
timeout: config.OLLAMA_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
return _client;
|
||||
}
|
||||
|
||||
export async function callLLM(messages: ChatMessage[]): Promise<LLMResponse> {
|
||||
const model = config.LITELLM_MODEL ?? config.OLLAMA_MODEL;
|
||||
const start = Date.now();
|
||||
|
||||
const response = await getClient().chat.completions.create({
|
||||
model,
|
||||
messages: messages.map(m => ({ role: m.role, content: m.content })),
|
||||
temperature: config.OLLAMA_TEMPERATURE,
|
||||
});
|
||||
|
||||
const latencyMs = Date.now() - start;
|
||||
const raw = response.choices[0]?.message.content ?? '';
|
||||
const { narrative, toolCall } = parseToolCall(raw);
|
||||
|
||||
log.info('llm', 'litellm response', {
|
||||
model,
|
||||
latencyMs,
|
||||
tokens: response.usage?.completion_tokens,
|
||||
promptTokens: response.usage?.prompt_tokens,
|
||||
tool: toolCall?.tool,
|
||||
});
|
||||
|
||||
return {
|
||||
narrative,
|
||||
toolCall,
|
||||
rawTokensUsed: response.usage?.completion_tokens,
|
||||
};
|
||||
}
|
||||
19
src/harness/llmClient.ts
Normal file
19
src/harness/llmClient.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { config } from '../config.js';
|
||||
import { callLLM as callLiteLLM } from './litellmClient.js';
|
||||
import { callLLM as callOllama } from './ollamaClient.js';
|
||||
import { log } from '../lib/logger.js';
|
||||
import type { ChatMessage, LLMResponse } from '../types/index.js';
|
||||
|
||||
export async function callLLM(messages: ChatMessage[]): Promise<LLMResponse> {
|
||||
if (config.LITELLM_BASE_URL) {
|
||||
log.info('llm', 'calling litellm', { messages: messages.length });
|
||||
try {
|
||||
return await callLiteLLM(messages);
|
||||
} catch (err) {
|
||||
log.warn('llm', 'litellm failed, falling back to ollama', { error: String(err) });
|
||||
}
|
||||
} else {
|
||||
log.info('llm', 'calling ollama', { messages: messages.length });
|
||||
}
|
||||
return callOllama(messages);
|
||||
}
|
||||
36
src/harness/ollamaClient.ts
Normal file
36
src/harness/ollamaClient.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Ollama } from 'ollama';
|
||||
import { config } from '../config.js';
|
||||
import { parseToolCall } from './toolParser.js';
|
||||
import { log } from '../lib/logger.js';
|
||||
import type { ChatMessage, LLMResponse } from '../types/index.js';
|
||||
|
||||
const ollama = new Ollama({ host: config.OLLAMA_BASE_URL });
|
||||
|
||||
export async function callLLM(messages: ChatMessage[]): Promise<LLMResponse> {
|
||||
const model = config.OLLAMA_MODEL;
|
||||
const start = Date.now();
|
||||
|
||||
const response = await ollama.chat({
|
||||
model,
|
||||
messages: messages.map(m => ({ role: m.role, content: m.content })),
|
||||
stream: false,
|
||||
options: { temperature: config.OLLAMA_TEMPERATURE, num_ctx: config.OLLAMA_NUM_CTX },
|
||||
});
|
||||
|
||||
const latencyMs = Date.now() - start;
|
||||
const raw = response.message.content;
|
||||
const { narrative, toolCall } = parseToolCall(raw);
|
||||
|
||||
log.info('llm', 'ollama response', {
|
||||
model,
|
||||
latencyMs,
|
||||
tokens: response.eval_count,
|
||||
tool: toolCall?.tool,
|
||||
});
|
||||
|
||||
return {
|
||||
narrative,
|
||||
toolCall,
|
||||
rawTokensUsed: response.eval_count,
|
||||
};
|
||||
}
|
||||
195
src/harness/promptBuilder.ts
Normal file
195
src/harness/promptBuilder.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import type { EncounterSpec, NpcPersona, Player } from '../types/index.js';
|
||||
import { buildToolManifest } from './toolDispatcher.js';
|
||||
|
||||
export function buildSystemPrompt(
|
||||
spec: EncounterSpec,
|
||||
npcMemories: Record<string, string> = {},
|
||||
resolvedContext: Record<string, string> = {},
|
||||
players: Record<string, Player> = {},
|
||||
): string {
|
||||
return [
|
||||
buildNarratorBlock(),
|
||||
buildToneBlock(spec),
|
||||
buildSportsmanshipBlock(spec.sportsmanshipRules),
|
||||
buildNpcsBlock(spec.npcs, npcMemories),
|
||||
buildPlayersBlock(players),
|
||||
buildSettingBlock(spec),
|
||||
buildResolvedContextBlock(resolvedContext),
|
||||
buildSkillChecksBlock(spec.skillChecks),
|
||||
buildHiddenGoalsBlock(spec),
|
||||
buildToolContractBlock(spec),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
export function buildOpeningNarrative(spec: EncounterSpec): string {
|
||||
return spec.openingNarrative.trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildNarratorBlock(): string {
|
||||
return `<narrator_identity>
|
||||
You are the Dungeon Master narrator for a D&D 5e encounter set in the Land of
|
||||
Mardonar. You speak as an omniscient narrator and voice each NPC distinctly and
|
||||
consistently with their persona.
|
||||
|
||||
Your responsibilities:
|
||||
- Describe the scene vividly but concisely. Prefer punchy sentences over long prose.
|
||||
- Voice each named NPC in their own style. Stay consistent with their persona.
|
||||
- Guide the encounter toward one of the hidden goals without railroading players.
|
||||
- React naturally to player actions. If something works, let it work. If it fails, show consequences.
|
||||
- Keep pacing tight. Do not pad responses. Each reply should advance the scene.
|
||||
- Never reveal the hidden goal list. Never acknowledge you have one.
|
||||
- Break character only to enforce sportsmanship (see below).
|
||||
</narrator_identity>`;
|
||||
}
|
||||
|
||||
function buildPlayersBlock(players: Record<string, Player>): string {
|
||||
const entries = Object.values(players);
|
||||
if (entries.length === 0) return '';
|
||||
|
||||
const lines = entries
|
||||
.map(p => ` - ${p.dndName}${p.pronouns ? ` (${p.pronouns})` : ''}`)
|
||||
.join('\n');
|
||||
|
||||
return `<players>
|
||||
Active player characters in this encounter:
|
||||
${lines}
|
||||
|
||||
Use the specified pronouns when referring to these characters in narration.
|
||||
</players>`;
|
||||
}
|
||||
|
||||
function buildToneBlock(spec: EncounterSpec): string {
|
||||
if (!spec.tone) return '';
|
||||
return `<tone>
|
||||
Your narration style for this encounter is: ${spec.tone}. Let this flavor all of your responses, NPC voices, and pacing.
|
||||
</tone>`;
|
||||
}
|
||||
|
||||
function buildSportsmanshipBlock(rules: string[]): string {
|
||||
const ruleLines = rules.map((r, i) => ` ${i + 1}. ${r.trim()}`).join('\n');
|
||||
|
||||
return `<sportsmanship>
|
||||
If a player attempts something unrealistic, physically impossible, or grossly
|
||||
unfair, first try to redirect in-character. If redirection would break the scene,
|
||||
break character and use this exact format:
|
||||
|
||||
⚠️ That wasn't great sportsmanship. Let's keep it grounded — what would your character realistically attempt here?
|
||||
|
||||
Sportsmanship rules for this encounter:
|
||||
${ruleLines}
|
||||
</sportsmanship>`;
|
||||
}
|
||||
|
||||
function buildNpcsBlock(npcs: NpcPersona[], npcMemories: Record<string, string>): string {
|
||||
if (npcs.length === 0) return '';
|
||||
|
||||
const npcBlocks = npcs.map(npc => buildSingleNpcBlock(npc, npcMemories[npc.id])).join('\n');
|
||||
|
||||
return `<npcs>
|
||||
${npcBlocks}
|
||||
</npcs>`;
|
||||
}
|
||||
|
||||
function buildSingleNpcBlock(npc: NpcPersona, memory?: string): string {
|
||||
const memoryLine = memory && memory !== 'No prior encounters on record — first meeting.'
|
||||
? ` Memory from prior encounters:\n${memory.split('\n').map(l => ` ${l}`).join('\n')}`
|
||||
: ' Memory: None — first encounter with this NPC.';
|
||||
|
||||
return ` <npc id="${npc.id}">
|
||||
Name: ${npc.name}
|
||||
Role: ${npc.role}
|
||||
Persona: ${npc.persona.trim()}
|
||||
${memoryLine}
|
||||
</npc>`;
|
||||
}
|
||||
|
||||
function buildSettingBlock(spec: EncounterSpec): string {
|
||||
return `<setting>
|
||||
Location: ${spec.setting.location}
|
||||
Mood: ${spec.setting.mood.trim()}
|
||||
Ambient NPCs: ${spec.setting.ambientNpcs.trim()}
|
||||
</setting>`;
|
||||
}
|
||||
|
||||
function buildResolvedContextBlock(resolvedContext: Record<string, string>): string {
|
||||
const entries = Object.entries(resolvedContext);
|
||||
if (entries.length === 0) return '';
|
||||
|
||||
const lines = entries.map(([k, v]) => ` ${k}: "${v}"`).join('\n');
|
||||
|
||||
return `<resolved_context>
|
||||
The following details have been established for this specific session and are
|
||||
canonical — treat them as ground truth when narrating.
|
||||
|
||||
${lines}
|
||||
|
||||
You may use context_recall to retrieve any of these values by key during the
|
||||
encounter if you need to reference them precisely.
|
||||
</resolved_context>`;
|
||||
}
|
||||
|
||||
function buildSkillChecksBlock(skillChecks: Record<string, number | string>): string {
|
||||
if (Object.keys(skillChecks).length === 0) return '';
|
||||
|
||||
// Group by prefix: chase_dc + chase_skill + chase_note → one entry
|
||||
const groups: Record<string, { dc?: number; skill?: string; note?: string }> = {};
|
||||
for (const [key, val] of Object.entries(skillChecks)) {
|
||||
const match = key.match(/^(.+?)_(dc|skill|note)$/);
|
||||
if (!match) continue;
|
||||
const [, name, field] = match;
|
||||
if (!groups[name]) groups[name] = {};
|
||||
if (field === 'dc') groups[name].dc = Number(val);
|
||||
if (field === 'skill') groups[name].skill = String(val);
|
||||
if (field === 'note') groups[name].note = String(val);
|
||||
}
|
||||
|
||||
const lines = Object.entries(groups)
|
||||
.filter(([, g]) => g.dc !== undefined)
|
||||
.map(([name, g]) => {
|
||||
const noteLine = g.note ? `\n Note: ${g.note.trim()}` : '';
|
||||
return ` - ${name.replace(/_/g, ' ')}: ${g.skill ?? 'skill check'} DC ${g.dc}${noteLine}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `<skill_checks>
|
||||
When a player attempts any of the following actions — or explicitly asks for a
|
||||
skill check — emit a skill_check_emit tool call IMMEDIATELY. Do not narrate the
|
||||
outcome yourself; wait for the roll result to arrive as a system message.
|
||||
|
||||
${lines}
|
||||
</skill_checks>`;
|
||||
}
|
||||
|
||||
function buildHiddenGoalsBlock(spec: EncounterSpec): string {
|
||||
const primaryLines = spec.goals.primary
|
||||
.map(g => ` - [PRIMARY] ${g.id}: ${g.label.trim()}`)
|
||||
.join('\n');
|
||||
|
||||
const secondaryLines = spec.goals.secondary
|
||||
.map(g => ` - [SECONDARY] ${g.id}: ${g.label.trim()}`)
|
||||
.join('\n');
|
||||
|
||||
return `<hidden_goals>
|
||||
Steer the story toward one of these outcomes. Do not state them to players.
|
||||
Reward clever play that moves toward a goal. Gently redirect if the scene drifts
|
||||
far off course. Multiple outcomes may be valid — follow what the players set in motion.
|
||||
|
||||
${primaryLines}
|
||||
${secondaryLines}
|
||||
|
||||
When an outcome is clearly and unambiguously reached (e.g. the thief is caught,
|
||||
surrenders, escapes into the crowd with no pursuit left, or is killed), emit an
|
||||
encounter_resolve tool call. Do not delay — resolve as soon as the outcome is
|
||||
certain. Do not continue narrating after emitting encounter_resolve.
|
||||
</hidden_goals>`;
|
||||
}
|
||||
|
||||
function buildToolContractBlock(spec: EncounterSpec): string {
|
||||
return buildToolManifest(spec);
|
||||
}
|
||||
117
src/harness/toolDispatcher.ts
Normal file
117
src/harness/toolDispatcher.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
// Side-effect import — registers all built-in tools into the registry at module load.
|
||||
import './tools/index.js';
|
||||
|
||||
import type { EncounterSpec } from '../types/index.js';
|
||||
import type { ToolCallBlock } from '../types/index.js';
|
||||
import { getActiveTools, getAllToolNames, type ToolContext, type DispatchResult } from './toolRegistry.js';
|
||||
import { log } from '../lib/logger.js';
|
||||
|
||||
export type { ToolContext, DispatchResult };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manifest — generates the <tool_contract> system prompt section from the
|
||||
// active tool set for this encounter. Returns '' when no tools are active
|
||||
// (the empty string is filtered out by promptBuilder's .filter(Boolean)).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildToolManifest(spec: EncounterSpec): string {
|
||||
const activeTools = getActiveTools(spec.tools);
|
||||
if (activeTools.size === 0) return '';
|
||||
|
||||
const toolDocs = Array.from(activeTools.entries())
|
||||
.map(([name, def]) => {
|
||||
const argLines = Object.entries(def.args)
|
||||
.map(([argName, schema]) => ` ${argName} (${schema.type}): ${schema.description}`)
|
||||
.join('\n');
|
||||
const extra = def.contextDocs ? '\n' + def.contextDocs(spec) : '';
|
||||
return ` ${name}\n ${def.description}\n Args:\n${argLines}${extra}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
return `<tool_contract>
|
||||
Append a tool call block at the VERY END of your response, after all narrative.
|
||||
Emit at most ONE tool call per response.
|
||||
|
||||
Required format — no variations:
|
||||
\`\`\`tool_call
|
||||
{ "tool": "<tool_name>", "args": { ... } }
|
||||
\`\`\`
|
||||
|
||||
SKILL CHECKS — emit skill_check_emit immediately whenever the player:
|
||||
• Makes any attack roll (melee, ranged, or spell)
|
||||
• Attempts any skill or ability check (including when they say "I roll X" or use /roll)
|
||||
• Must make a saving throw
|
||||
• Takes any action with a physically uncertain outcome (chase, climb, pick a lock, hide, etc.)
|
||||
CRITICAL: If your narrative contains phrases like "make a roll", "roll for X",
|
||||
"you need to hit AC N", or "attempt a check" — you MUST emit skill_check_emit in
|
||||
that SAME response. Never describe needing a roll and then stop without emitting.
|
||||
CRITICAL: If a [/roll COMMAND] or [SKILL CHECK REQUEST] system message is present,
|
||||
your ENTIRE response must be the tool call block and nothing else.
|
||||
Do NOT narrate the outcome of any check before the roll result arrives as a system message.
|
||||
NEVER end your response with a request for the player to roll without emitting skill_check_emit
|
||||
in the same response. Writing "you'll need to roll" or "make a Dexterity check" and then
|
||||
stopping — without the tool call — is FORBIDDEN and breaks the game.
|
||||
|
||||
--- CORRECT skill check flow (follow this exactly) ---
|
||||
Player: "Thorin tries to pick the lock."
|
||||
Your response: "The tumblers resist, but Thorin's fingers find the keyhole. It's stiff — age and rust have done their work."
|
||||
[Then emit skill_check_emit: player="Thorin", prompt="Pick the lock — Dexterity (Thieves' Tools)", dc=13]
|
||||
|
||||
--- WRONG (never do this) ---
|
||||
Player: "Thorin tries to pick the lock."
|
||||
WRONG response: "The lock looks tough. Thorin will need to make a Dexterity check to force it open!"
|
||||
[Missing: no skill_check_emit emitted — this is FORBIDDEN]
|
||||
|
||||
RESOLUTION — emit encounter_resolve the instant an outcome is clearly reached.
|
||||
Do not narrate after emitting it.
|
||||
|
||||
OMIT the block entirely for pure dialogue, NPC reactions, or description with no uncertain outcomes.
|
||||
Never emit an empty or malformed tool block.
|
||||
|
||||
Available tools:
|
||||
|
||||
${toolDocs}
|
||||
</tool_contract>`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Valid names — used by toolParser to validate the tool block before dispatch.
|
||||
// Reflects the full global registry; per-encounter filtering happens in dispatchTool.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const VALID_TOOL_NAMES = getAllToolNames();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatcher — routes a parsed tool call to its plugin handler.
|
||||
// Rejects tools that aren't active for this encounter's spec.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function dispatchTool(
|
||||
block: ToolCallBlock,
|
||||
ctx: ToolContext,
|
||||
): Promise<DispatchResult> {
|
||||
const activeTools = getActiveTools(ctx.session.spec.tools);
|
||||
const plugin = activeTools.get(block.tool);
|
||||
if (!plugin) {
|
||||
log.warn('tool', `"${block.tool}" not active`, { encounter: ctx.session.spec.encounterId });
|
||||
return { systemMessage: `[TOOL] "${block.tool}" is not available in this encounter.` };
|
||||
}
|
||||
|
||||
log.info('tool', `dispatch ${block.tool}`, { args: JSON.stringify(block.args) });
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = await plugin.handler(block.args, ctx);
|
||||
log.info('tool', `result ${block.tool}`, {
|
||||
latencyMs: Date.now() - start,
|
||||
output: result.systemMessage?.slice(0, 120),
|
||||
});
|
||||
return result;
|
||||
} catch (err) {
|
||||
const latencyMs = Date.now() - start;
|
||||
log.error('tool', `${block.tool} threw`, { latencyMs, error: String(err) });
|
||||
return {
|
||||
systemMessage: `[TOOL ERROR] ${block.tool} failed: ${String(err)}`,
|
||||
error: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
48
src/harness/toolParser.ts
Normal file
48
src/harness/toolParser.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { ToolCallBlock, ToolName } from '../types/index.js';
|
||||
import { VALID_TOOL_NAMES } from './toolDispatcher.js';
|
||||
|
||||
// Primary: fenced ```tool_call``` block.
|
||||
const FENCE_RE = /```tool_call\s*([\s\S]*?)```/;
|
||||
|
||||
// Secondary: plain "tool_call" text header without fences, full JSON with "tool"/"args" keys.
|
||||
const HEADER_RE = /\btool_call\b[^\n]*\n(\{[\s\S]*\})\s*$/;
|
||||
|
||||
// Fallback: any JSON blob with both "tool" and "args" keys at end of response,
|
||||
// for models that emit the right structure but omit any header.
|
||||
const BARE_JSON_RE = /(\{[^{}]*"tool"\s*:[^{}]*"args"\s*:\s*\{[\s\S]*?\}[^{}]*\})\s*$/;
|
||||
|
||||
export function parseToolCall(raw: string): { narrative: string; toolCall?: ToolCallBlock } {
|
||||
const fenceMatch = FENCE_RE.exec(raw);
|
||||
if (fenceMatch) return extract(raw, fenceMatch.index, fenceMatch[1]);
|
||||
|
||||
const headerMatch = HEADER_RE.exec(raw);
|
||||
if (headerMatch) return extract(raw, headerMatch.index, headerMatch[1]);
|
||||
|
||||
const bareMatch = BARE_JSON_RE.exec(raw);
|
||||
if (bareMatch) return extract(raw, bareMatch.index, bareMatch[1]);
|
||||
|
||||
return { narrative: raw.trim() };
|
||||
}
|
||||
|
||||
function extract(
|
||||
raw: string,
|
||||
matchIndex: number,
|
||||
json: string,
|
||||
): { narrative: string; toolCall?: ToolCallBlock } {
|
||||
const narrative = raw.slice(0, matchIndex).trim();
|
||||
try {
|
||||
const parsed = JSON.parse(json.trim()) as { tool: string; args: Record<string, unknown> };
|
||||
if (!VALID_TOOL_NAMES.has(parsed.tool as ToolName)) {
|
||||
console.warn(`[toolParser] unknown tool "${parsed.tool}" — ignoring`);
|
||||
return { narrative: narrative || raw.trim() };
|
||||
}
|
||||
if (!parsed.args || typeof parsed.args !== 'object' || Array.isArray(parsed.args)) {
|
||||
console.warn('[toolParser] missing or invalid args — ignoring tool call');
|
||||
return { narrative: narrative || raw.trim() };
|
||||
}
|
||||
return { narrative, toolCall: { tool: parsed.tool as ToolName, args: parsed.args } };
|
||||
} catch {
|
||||
console.warn('[toolParser] malformed tool JSON — treating as narrative');
|
||||
return { narrative: raw.trim() };
|
||||
}
|
||||
}
|
||||
55
src/harness/toolRegistry.ts
Normal file
55
src/harness/toolRegistry.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { ThreadChannel, TextChannel } from 'discord.js';
|
||||
import type { SessionState, EncounterSpec } from '../types/index.js';
|
||||
|
||||
export interface ToolContext {
|
||||
session: SessionState;
|
||||
thread: ThreadChannel | TextChannel;
|
||||
}
|
||||
|
||||
export interface DispatchResult {
|
||||
systemMessage: string;
|
||||
error?: boolean;
|
||||
resolved?: {
|
||||
outcomeId: string;
|
||||
summary: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ArgSchema {
|
||||
type: 'string' | 'number' | 'boolean';
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ToolPlugin {
|
||||
name: string;
|
||||
description: string;
|
||||
args: Record<string, ArgSchema>;
|
||||
contextDocs?: (spec: EncounterSpec) => string;
|
||||
handler: (args: Record<string, unknown>, ctx: ToolContext) => Promise<DispatchResult> | DispatchResult;
|
||||
}
|
||||
|
||||
const registry = new Map<string, ToolPlugin>();
|
||||
|
||||
export function registerTool(plugin: ToolPlugin): void {
|
||||
registry.set(plugin.name, plugin);
|
||||
}
|
||||
|
||||
export function getPlugin(name: string): ToolPlugin | undefined {
|
||||
return registry.get(name);
|
||||
}
|
||||
|
||||
export function getAllToolNames(): ReadonlySet<string> {
|
||||
return new Set(registry.keys());
|
||||
}
|
||||
|
||||
// Returns all registered tools when toolNames is absent or empty (encounter default).
|
||||
// Otherwise returns only the named subset, silently skipping unknown names.
|
||||
export function getActiveTools(toolNames?: string[]): ReadonlyMap<string, ToolPlugin> {
|
||||
if (!toolNames || toolNames.length === 0) return registry;
|
||||
const active = new Map<string, ToolPlugin>();
|
||||
for (const name of toolNames) {
|
||||
const plugin = registry.get(name);
|
||||
if (plugin) active.set(name, plugin);
|
||||
}
|
||||
return active;
|
||||
}
|
||||
23
src/harness/tools/contextRecall.ts
Normal file
23
src/harness/tools/contextRecall.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { registerTool, type ToolPlugin } from '../toolRegistry.js';
|
||||
|
||||
const contextRecall: ToolPlugin = {
|
||||
name: 'context_recall',
|
||||
description:
|
||||
'Look up a lore detail established for this encounter instance. ' +
|
||||
'Use when you need to reference a specific detail precisely. Do not fabricate values — recall them.',
|
||||
args: {
|
||||
key: { type: 'string', description: 'A key from the resolved_context section' },
|
||||
},
|
||||
handler: (args, ctx) => {
|
||||
const key = args.key as string;
|
||||
const value = ctx.session.resolvedContext?.[key];
|
||||
if (value !== undefined) {
|
||||
return { systemMessage: `[CONTEXT] ${key} = "${value}"` };
|
||||
}
|
||||
const available = Object.keys(ctx.session.resolvedContext ?? {}).join(', ') || 'none';
|
||||
return { systemMessage: `[CONTEXT] No value found for key "${key}". Available keys: ${available}` };
|
||||
},
|
||||
};
|
||||
|
||||
registerTool(contextRecall);
|
||||
export default contextRecall;
|
||||
56
src/harness/tools/encounterResolve.ts
Normal file
56
src/harness/tools/encounterResolve.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { logEncounter } from '../../graphmcp/client.js';
|
||||
import { buildResolutionEmbed } from '../../bot/embeds/resolution.js';
|
||||
import { writeSummary } from '../../session/encounterLog.js';
|
||||
import { registerTool, type ToolPlugin } from '../toolRegistry.js';
|
||||
|
||||
const encounterResolve: ToolPlugin = {
|
||||
name: 'encounter_resolve',
|
||||
description:
|
||||
'End the encounter and record the outcome. Emit the moment an outcome is clearly reached. ' +
|
||||
'Do not continue narrating after emitting this.',
|
||||
args: {
|
||||
sessionId: { type: 'string', description: 'The session ID for this encounter' },
|
||||
outcomeId: { type: 'string', description: 'Outcome identifier — must be one of the valid values listed below' },
|
||||
summary: { type: 'string', description: '2–4 sentences: what happened, who was involved, key turning points, how it ended. Past tense.' },
|
||||
},
|
||||
contextDocs: spec => {
|
||||
const goalLines = [...spec.goals.primary, ...spec.goals.secondary]
|
||||
.map(g => ` - "${g.id}" — ${g.label.trim()}`)
|
||||
.join('\n');
|
||||
return ` sessionId: "${spec.encounterId}"\n outcomeId valid values:\n${goalLines}`;
|
||||
},
|
||||
handler: async (args, ctx) => {
|
||||
const outcomeId = args.outcomeId as string;
|
||||
const summary = args.summary as string;
|
||||
const { session } = ctx;
|
||||
|
||||
const participants = [
|
||||
...session.spec.npcs.map(n => n.name),
|
||||
...Object.values(session.players).map(p => p.dndName),
|
||||
].join(', ');
|
||||
|
||||
try {
|
||||
await logEncounter({
|
||||
title: `${session.spec.title} — ${outcomeId}`,
|
||||
participants,
|
||||
summary,
|
||||
location: session.spec.setting.location,
|
||||
type: 'encounter',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[encounterResolve] logEncounter failed:', err);
|
||||
}
|
||||
|
||||
const embed = buildResolutionEmbed(session.spec, session, outcomeId, summary);
|
||||
await ctx.thread.send({ embeds: [embed] });
|
||||
writeSummary(session, outcomeId, summary);
|
||||
|
||||
return {
|
||||
systemMessage: `[TOOL] Encounter resolved: ${outcomeId}. ${summary}`,
|
||||
resolved: { outcomeId, summary },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
registerTool(encounterResolve);
|
||||
export default encounterResolve;
|
||||
49
src/harness/tools/foundryLookup.ts
Normal file
49
src/harness/tools/foundryLookup.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
searchActors,
|
||||
filterPlayerActors,
|
||||
getActorDetails,
|
||||
getActorInventory,
|
||||
getActorSpells,
|
||||
formatActorSummary,
|
||||
formatInventory,
|
||||
formatSpells,
|
||||
} from '../../vtt/foundryClient.js';
|
||||
import { registerTool, type ToolPlugin } from '../toolRegistry.js';
|
||||
|
||||
const foundryLookup: ToolPlugin = {
|
||||
name: 'foundry_lookup',
|
||||
description: 'Look up a Foundry VTT player character by name. Returns their stats, inventory, and spell list.',
|
||||
args: {
|
||||
actor_name: { type: 'string', description: 'The name or partial name of the player character to look up' },
|
||||
},
|
||||
handler: async (args) => {
|
||||
try {
|
||||
const actorName = args.actor_name as string;
|
||||
const results = await searchActors(actorName, 10);
|
||||
const match = filterPlayerActors(results)[0];
|
||||
|
||||
if (!match) {
|
||||
return { systemMessage: `[FOUNDRY] No player actor found matching "${actorName}"` };
|
||||
}
|
||||
|
||||
const [details, inventory, spells] = await Promise.all([
|
||||
getActorDetails(match.uuid),
|
||||
getActorInventory(match.uuid),
|
||||
getActorSpells(match.uuid),
|
||||
]);
|
||||
|
||||
const summary = formatActorSummary(details);
|
||||
const inv = formatInventory(inventory);
|
||||
const spellList = formatSpells(spells);
|
||||
|
||||
return {
|
||||
systemMessage: '[FOUNDRY ACTOR]\n' + summary + '\nInventory:\n' + inv + '\nSpells:\n' + spellList,
|
||||
};
|
||||
} catch {
|
||||
return { systemMessage: '[FOUNDRY] VTT relay unavailable.' };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
registerTool(foundryLookup);
|
||||
export default foundryLookup;
|
||||
66
src/harness/tools/foundryReward.ts
Normal file
66
src/harness/tools/foundryReward.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { modifyExperience, giveItem } from '../../vtt/foundryClient.js';
|
||||
import { characterRegistry } from '../../session/characterRegistry.js';
|
||||
import { registerTool, type ToolPlugin } from '../toolRegistry.js';
|
||||
|
||||
const foundryReward: ToolPlugin = {
|
||||
name: 'foundry_reward',
|
||||
description:
|
||||
'Award a player an item or XP in Foundry VTT for an achievement. Use when an encounter milestone is clearly reached.',
|
||||
args: {
|
||||
player_discord_name: {
|
||||
type: 'string',
|
||||
description: "The player's DnD name as it appears in the session",
|
||||
},
|
||||
xp_amount: {
|
||||
type: 'number',
|
||||
description: 'XP to award (omit if no XP reward)',
|
||||
},
|
||||
item_name: {
|
||||
type: 'string',
|
||||
description: 'Item to give (omit if no item reward)',
|
||||
},
|
||||
reason: {
|
||||
type: 'string',
|
||||
description: 'Brief description of what the player achieved (logged as system message)',
|
||||
},
|
||||
},
|
||||
handler: async (args, ctx) => {
|
||||
try {
|
||||
const playerDiscordName = args.player_discord_name as string;
|
||||
const xpAmount = args.xp_amount as number | undefined;
|
||||
const itemName = args.item_name as string | undefined;
|
||||
const reason = args.reason as string;
|
||||
|
||||
const player = Object.values(ctx.session.players).find(
|
||||
p => p.dndName.toLowerCase() === playerDiscordName.toLowerCase(),
|
||||
);
|
||||
|
||||
if (!player) {
|
||||
return { systemMessage: `[FOUNDRY] No player found matching "${playerDiscordName}" in this session.` };
|
||||
}
|
||||
|
||||
const profile = await characterRegistry.get(ctx.session.guildId, player.discordId);
|
||||
const uuid = (profile as (typeof profile & { foundryActorUuid?: string }) | null)?.foundryActorUuid;
|
||||
|
||||
if (!uuid) {
|
||||
return {
|
||||
systemMessage: '[FOUNDRY] Player has no linked Foundry character. Reward skipped.',
|
||||
};
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
xpAmount != null && xpAmount > 0 ? modifyExperience(uuid, xpAmount) : Promise.resolve(),
|
||||
itemName ? giveItem(uuid, itemName) : Promise.resolve(),
|
||||
]);
|
||||
|
||||
return {
|
||||
systemMessage: `[FOUNDRY REWARD] Gave ${player.dndName}: ${itemName ?? 'no item'}, ${xpAmount ?? 0} XP. Reason: ${reason}`,
|
||||
};
|
||||
} catch {
|
||||
return { systemMessage: '[FOUNDRY] VTT relay unavailable. Reward not delivered.' };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
registerTool(foundryReward);
|
||||
export default foundryReward;
|
||||
110
src/harness/tools/goalRegister.ts
Normal file
110
src/harness/tools/goalRegister.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { sessionManager } from '../../session/sessionManager.js';
|
||||
import { registerTool, type ToolPlugin } from '../toolRegistry.js';
|
||||
|
||||
const goalRegister: ToolPlugin = {
|
||||
name: 'goal_register',
|
||||
description:
|
||||
'Register a new, custom hidden goal for the encounter when player actions or narrative developments ' +
|
||||
'render existing goals unrealistic, or when players pursue a creative, unexpected path that warrants ' +
|
||||
'a specific mechanical resolution.',
|
||||
args: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Unique kebab-case ID (e.g. bribe_and_recruit). Must match regex ^[a-z0-9-_]+$.',
|
||||
},
|
||||
label: {
|
||||
type: 'string',
|
||||
description: 'Precise description of the trigger conditions. What must happen for this goal to resolve?',
|
||||
},
|
||||
isPrimary: {
|
||||
type: 'string',
|
||||
description: 'Whether this is a primary driver or secondary fallback (value "true" or "false").',
|
||||
},
|
||||
reason: {
|
||||
type: 'string',
|
||||
description: 'A short explanation of why this goal is being registered on the fly.',
|
||||
},
|
||||
},
|
||||
handler: async (args, ctx) => {
|
||||
const id = (args.id as string).trim();
|
||||
const label = (args.label as string).trim();
|
||||
const isPrimaryStr = (args.isPrimary as string).trim().toLowerCase();
|
||||
const isPrimary = isPrimaryStr === 'true';
|
||||
|
||||
// Auto-prefix ID with dynamic_ to distinguish from spec goals and track limits
|
||||
let finalId = id.toLowerCase();
|
||||
if (!finalId.startsWith('dynamic_')) {
|
||||
finalId = `dynamic_${finalId}`;
|
||||
}
|
||||
|
||||
// Validate regex: kebab-case
|
||||
const idRegex = /^[a-z0-9-_]+$/;
|
||||
if (!idRegex.test(finalId)) {
|
||||
return {
|
||||
systemMessage: `[TOOL ERROR] Invalid goal ID format: "${id}". Must contain only lowercase letters, numbers, hyphens, and underscores.`,
|
||||
};
|
||||
}
|
||||
|
||||
const { session } = ctx;
|
||||
|
||||
// Safeguard: Prevent infinite loops by blocking registration if session has run long
|
||||
if (session.history.length > 20) {
|
||||
return {
|
||||
systemMessage: `[TOOL ERROR] The encounter has gone on for too long (${session.history.length} messages). You cannot register any new goals and must guide the players to a resolution immediately using existing goals.`,
|
||||
};
|
||||
}
|
||||
|
||||
const goals = session.spec.goals;
|
||||
|
||||
// Check if ID already exists
|
||||
const existsInPrimary = goals.primary.some(g => g.id === finalId);
|
||||
const existsInSecondary = goals.secondary.some(g => g.id === finalId);
|
||||
|
||||
if (existsInPrimary || existsInSecondary) {
|
||||
return {
|
||||
systemMessage: `[TOOL ERROR] A goal with ID "${finalId}" already exists in this encounter spec.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Safeguard: Limit to at most 2 dynamic goals per session to ensure the game resolves
|
||||
const dynamicGoalsCount = [
|
||||
...goals.primary,
|
||||
...goals.secondary
|
||||
].filter(g => g.id.startsWith('dynamic_')).length;
|
||||
|
||||
if (dynamicGoalsCount >= 2) {
|
||||
return {
|
||||
systemMessage: `[TOOL ERROR] Maximum limit of 2 dynamic goals reached. You must resolve the encounter using one of the current goals.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Append to appropriate list
|
||||
const updatedGoals = {
|
||||
...goals,
|
||||
primary: [...goals.primary],
|
||||
secondary: [...goals.secondary],
|
||||
};
|
||||
|
||||
const newGoal = { id: finalId, label };
|
||||
if (isPrimary) {
|
||||
updatedGoals.primary.push(newGoal);
|
||||
} else {
|
||||
updatedGoals.secondary.push(newGoal);
|
||||
}
|
||||
|
||||
const updatedSpec = {
|
||||
...session.spec,
|
||||
goals: updatedGoals,
|
||||
};
|
||||
|
||||
// Save spec update to Redis session
|
||||
await sessionManager.update(session.threadId, { spec: updatedSpec });
|
||||
|
||||
return {
|
||||
systemMessage: `[TOOL] New hidden goal registered on the fly: "${finalId}" (Primary: ${isPrimary}). Label: "${label}"`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
registerTool(goalRegister);
|
||||
export default goalRegister;
|
||||
9
src/harness/tools/index.ts
Normal file
9
src/harness/tools/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// Side-effect imports — each module calls registerTool() at load time.
|
||||
// Add new tool files here to make them available to all encounters.
|
||||
import './skillCheckEmit.js';
|
||||
import './encounterResolve.js';
|
||||
import './contextRecall.js';
|
||||
import './goalRegister.js';
|
||||
import './foundryLookup.js';
|
||||
import './foundryReward.js';
|
||||
|
||||
138
src/harness/tools/skillCheckEmit.ts
Normal file
138
src/harness/tools/skillCheckEmit.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { sessionManager } from '../../session/sessionManager.js';
|
||||
import { buildSuspenseEmbed, buildSkillCheckEmbed, buildRollButtons } from '../../bot/embeds/skillCheck.js';
|
||||
import { registerTool, type ToolPlugin } from '../toolRegistry.js';
|
||||
import { characterRegistry } from '../../session/characterRegistry.js';
|
||||
import { getActorDetails, type FoundryActorDetails } from '../../vtt/foundryClient.js';
|
||||
import { log } from '../../lib/logger.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 30-second in-memory cache for actor details (avoids hammering the relay)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const actorCache = new Map<string, { data: FoundryActorDetails; expiresAt: number }>();
|
||||
|
||||
async function fetchActorCached(uuid: string): Promise<FoundryActorDetails> {
|
||||
const hit = actorCache.get(uuid);
|
||||
if (hit && hit.expiresAt > Date.now()) return hit.data;
|
||||
const data = await getActorDetails(uuid);
|
||||
actorCache.set(uuid, { data, expiresAt: Date.now() + 30_000 });
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill / ability name → Foundry key
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SKILL_KEY: Record<string, string> = {
|
||||
acrobatics: 'acr', 'animal handling': 'ani', arcana: 'arc',
|
||||
athletics: 'ath', deception: 'dec', history: 'his',
|
||||
insight: 'ins', intimidation: 'itm', investigation: 'inv',
|
||||
medicine: 'med', nature: 'nat', perception: 'prc',
|
||||
performance: 'prf', persuasion: 'per', religion: 'rel',
|
||||
'sleight of hand': 'slt', stealth: 'ste', survival: 'sur',
|
||||
// Ability checks
|
||||
strength: 'str', dexterity: 'dex', constitution: 'con',
|
||||
intelligence: 'int', wisdom: 'wis', charisma: 'cha',
|
||||
};
|
||||
|
||||
async function resolveModifier(
|
||||
guildId: string,
|
||||
discordId: string,
|
||||
skillName: string,
|
||||
): Promise<number | undefined> {
|
||||
const key = SKILL_KEY[skillName.toLowerCase()];
|
||||
if (!key) return undefined;
|
||||
|
||||
const profile = await characterRegistry.get(guildId, discordId);
|
||||
if (!profile?.foundryActorUuid) return undefined;
|
||||
|
||||
const actor = await fetchActorCached(profile.foundryActorUuid);
|
||||
|
||||
// Skill check (proficiency + ability mod already rolled in by Foundry)
|
||||
if (actor.skills?.[key]) return actor.skills[key].total;
|
||||
// Ability check fallback
|
||||
if (actor.abilities?.[key]) return actor.abilities[key].mod;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const skillCheckEmit: ToolPlugin = {
|
||||
name: 'skill_check_emit',
|
||||
description:
|
||||
'Post a dice-roll embed to the player. ' +
|
||||
'MANDATORY for: attack rolls (melee/ranged/spell), skill checks, ability checks, saving throws, ' +
|
||||
'or any action with a physically uncertain outcome. ' +
|
||||
'Do NOT narrate the outcome — emit and wait for the roll result system message.',
|
||||
args: {
|
||||
player: { type: 'string', description: "Player's character name exactly as it appears in the conversation" },
|
||||
prompt: { type: 'string', description: 'One sentence describing the specific action being attempted (e.g. "Spell attack against Dal with Arcane Burst")' },
|
||||
skill: { type: 'string', description: 'The skill or ability being tested (e.g. "Perception", "Athletics", "Strength"). Used to display the player\'s modifier.' },
|
||||
dc: { type: 'number', description: 'Difficulty Class or target AC (1–30). For spell/melee attacks use the target\'s AC. Use preset values when available.' },
|
||||
advantage: { type: 'boolean', description: 'Set true when the narrative grants advantage (e.g. attacking while hidden, helped by an ally, using a spell that grants advantage).' },
|
||||
disadvantage: { type: 'boolean', description: 'Set true when the narrative imposes disadvantage (e.g. restrained, poisoned, attacking at long range without a feat, blinded).' },
|
||||
},
|
||||
contextDocs: (spec) => {
|
||||
const lines = Object.entries(spec.skillChecks)
|
||||
.filter(([k]) => k.endsWith('_dc'))
|
||||
.map(([k, v]) => ` ${k.replace(/_dc$/, '').replace(/_/g, ' ')}: DC ${v}`)
|
||||
.join('\n');
|
||||
return lines
|
||||
? ` Preset DCs for this encounter (use these exact values when applicable):\n${lines}`
|
||||
: '';
|
||||
},
|
||||
handler: async (args, ctx) => {
|
||||
const player = args.player as string;
|
||||
const prompt = args.prompt as string;
|
||||
const skill = (args.skill as string | undefined) ?? '';
|
||||
const dc = args.dc as number;
|
||||
const advantage = (args.advantage as boolean | undefined) ?? false;
|
||||
const disadvantage = (args.disadvantage as boolean | undefined) ?? false;
|
||||
|
||||
// Resolve the player's Discord ID from the session roster
|
||||
const discordEntry = Object.entries(ctx.session.players)
|
||||
.find(([, p]) => p.dndName === player);
|
||||
const discordId = discordEntry?.[0];
|
||||
|
||||
let modifier: number | undefined;
|
||||
if (discordId && skill) {
|
||||
try {
|
||||
modifier = await resolveModifier(ctx.session.guildId, discordId, skill);
|
||||
if (modifier !== undefined) {
|
||||
log.info('tool', 'resolved modifier', { player, skill, modifier });
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('tool', 'modifier lookup failed, continuing without', { player, skill, error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
const sent = await ctx.thread.send({ embeds: [buildSuspenseEmbed(player, prompt)] });
|
||||
await sessionManager.update(ctx.session.threadId, {
|
||||
pendingSkillCheck: {
|
||||
player, prompt, dc, messageId: sent.id, modifier, skill: skill || undefined,
|
||||
advantage: advantage || undefined,
|
||||
disadvantage: disadvantage || undefined,
|
||||
},
|
||||
pendingSkillCheckAttempts: 0,
|
||||
});
|
||||
setTimeout(() => {
|
||||
sent
|
||||
.edit({
|
||||
embeds: [buildSkillCheckEmbed(player, prompt, dc, undefined, undefined, modifier, skill || undefined, advantage || undefined, disadvantage || undefined)],
|
||||
components: [buildRollButtons(modifier)],
|
||||
})
|
||||
.catch(() => null);
|
||||
}, 1_500);
|
||||
|
||||
const modNote = modifier !== undefined
|
||||
? ` Modifier resolved: ${modifier >= 0 ? '+' : ''}${modifier} (${skill}).`
|
||||
: '';
|
||||
const modeNote = advantage ? ' [ADVANTAGE]' : disadvantage ? ' [DISADVANTAGE]' : '';
|
||||
return { systemMessage: `[TOOL] Skill check embed posted for ${player} (DC ${dc}).${modNote}${modeNote}` };
|
||||
},
|
||||
};
|
||||
|
||||
registerTool(skillCheckEmit);
|
||||
export default skillCheckEmit;
|
||||
20
src/lib/logger.ts
Normal file
20
src/lib/logger.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
type Level = 'info' | 'warn' | 'error' | 'debug';
|
||||
type Fields = Record<string, string | number | boolean | undefined>;
|
||||
|
||||
function emit(level: Level, tag: string, message: string, fields: Fields = {}): void {
|
||||
const fieldStr = Object.entries(fields)
|
||||
.filter(([, v]) => v !== undefined)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(' ');
|
||||
const line = `[${tag}] ${message}${fieldStr ? ' ' + fieldStr : ''}`;
|
||||
if (level === 'error') console.error(line);
|
||||
else if (level === 'warn') console.warn(line);
|
||||
else console.log(line);
|
||||
}
|
||||
|
||||
export const log = {
|
||||
info: (tag: string, message: string, fields?: Fields) => emit('info', tag, message, fields),
|
||||
warn: (tag: string, message: string, fields?: Fields) => emit('warn', tag, message, fields),
|
||||
error: (tag: string, message: string, fields?: Fields) => emit('error', tag, message, fields),
|
||||
debug: (tag: string, message: string, fields?: Fields) => emit('debug', tag, message, fields),
|
||||
};
|
||||
25
src/persona/loader.ts
Normal file
25
src/persona/loader.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import yaml from 'js-yaml';
|
||||
import { z } from 'zod';
|
||||
|
||||
const PersonaSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
persona: z.string(),
|
||||
responseStyle: z.string(),
|
||||
});
|
||||
|
||||
export type Persona = z.infer<typeof PersonaSchema>;
|
||||
|
||||
let _cached: Persona | null = null;
|
||||
|
||||
export function loadPersona(path = './persona.yaml'): Persona {
|
||||
if (_cached) return _cached;
|
||||
const raw = yaml.load(readFileSync(path, 'utf8'));
|
||||
_cached = PersonaSchema.parse(raw);
|
||||
return _cached;
|
||||
}
|
||||
|
||||
export function clearPersonaCache(): void {
|
||||
_cached = null;
|
||||
}
|
||||
43
src/scripts/deploy-commands.ts
Normal file
43
src/scripts/deploy-commands.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { REST, Routes } from 'discord.js';
|
||||
import 'dotenv/config';
|
||||
import { config } from '../config.js';
|
||||
import { data as dndnameData } from '../bot/commands/dndname.js';
|
||||
import { data as encounterData } from '../bot/commands/encounter.js';
|
||||
import { data as characterData } from '../bot/commands/character.js';
|
||||
import { data as rollData } from '../bot/commands/roll.js';
|
||||
import { data as actionsData } from '../bot/commands/actions.js';
|
||||
import { data as xpData } from '../bot/commands/xp.js';
|
||||
import { data as encountersData } from '../bot/commands/encounters.js';
|
||||
|
||||
const commands = [
|
||||
dndnameData.toJSON(),
|
||||
encounterData.toJSON(),
|
||||
characterData.toJSON(),
|
||||
rollData.toJSON(),
|
||||
actionsData.toJSON(),
|
||||
xpData.toJSON(),
|
||||
encountersData.toJSON(),
|
||||
];
|
||||
|
||||
const rest = new REST({ version: '10' }).setToken(config.DISCORD_TOKEN);
|
||||
|
||||
async function deploy(): Promise<void> {
|
||||
if (config.DISCORD_GUILD_ID) {
|
||||
// Clear any lingering global commands so guild and global don't both show up
|
||||
await rest.put(Routes.applicationCommands(config.DISCORD_CLIENT_ID), { body: [] });
|
||||
console.log(`Registering ${commands.length} slash commands to guild ${config.DISCORD_GUILD_ID}…`);
|
||||
await rest.put(
|
||||
Routes.applicationGuildCommands(config.DISCORD_CLIENT_ID, config.DISCORD_GUILD_ID),
|
||||
{ body: commands },
|
||||
);
|
||||
} else {
|
||||
console.log(`Registering ${commands.length} slash commands globally (up to 1hr propagation)…`);
|
||||
await rest.put(Routes.applicationCommands(config.DISCORD_CLIENT_ID), { body: commands });
|
||||
}
|
||||
console.log('Done.');
|
||||
}
|
||||
|
||||
deploy().catch((err) => {
|
||||
console.error('deploy-commands failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
45
src/session/characterRegistry.ts
Normal file
45
src/session/characterRegistry.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { redis } from '../db/redis.js';
|
||||
|
||||
export interface CharacterProfile {
|
||||
discordId: string;
|
||||
dndName: string;
|
||||
source: 'foundry' | 'custom';
|
||||
// Set when linked to a Foundry actor
|
||||
foundryActorUuid?: string;
|
||||
// Optional supplemental fields — always present for custom, optional for Foundry-linked
|
||||
characterClass?: string;
|
||||
level?: number;
|
||||
race?: string;
|
||||
backstory?: string;
|
||||
pronouns?: string;
|
||||
}
|
||||
|
||||
const key = (guildId: string) => `characters:${guildId}`;
|
||||
|
||||
export const characterRegistry = {
|
||||
async get(guildId: string, discordId: string): Promise<CharacterProfile | null> {
|
||||
const raw = await redis.hget(key(guildId), discordId);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as CharacterProfile;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async set(guildId: string, profile: CharacterProfile): Promise<void> {
|
||||
await redis.hset(key(guildId), profile.discordId, JSON.stringify(profile));
|
||||
},
|
||||
|
||||
async delete(guildId: string, discordId: string): Promise<void> {
|
||||
await redis.hdel(key(guildId), discordId);
|
||||
},
|
||||
|
||||
async list(guildId: string): Promise<CharacterProfile[]> {
|
||||
const all = await redis.hgetall(key(guildId));
|
||||
if (!all) return [];
|
||||
return Object.values(all).flatMap(v => {
|
||||
try { return [JSON.parse(v) as CharacterProfile]; } catch { return []; }
|
||||
});
|
||||
},
|
||||
};
|
||||
63
src/session/encounterLog.ts
Normal file
63
src/session/encounterLog.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { config } from '../config.js';
|
||||
import type { SessionState } from '../types/index.js';
|
||||
|
||||
function dataDir(): string { return config.DATA_DIR; }
|
||||
function tallyPath(): string { return join(dataDir(), 'tally.json'); }
|
||||
function summariesDir(): string { return join(dataDir(), 'summaries'); }
|
||||
|
||||
function ensureDirs(): void {
|
||||
mkdirSync(dataDir(), { recursive: true });
|
||||
mkdirSync(summariesDir(), { recursive: true });
|
||||
}
|
||||
|
||||
export interface TallyEntry {
|
||||
runs: number;
|
||||
lastRun: string;
|
||||
}
|
||||
|
||||
export function incrementTally(specName: string): void {
|
||||
ensureDirs();
|
||||
let tally: Record<string, TallyEntry> = {};
|
||||
try { tally = JSON.parse(readFileSync(tallyPath(), 'utf8')); } catch { /* first run */ }
|
||||
const existing = tally[specName] ?? { runs: 0, lastRun: '' };
|
||||
tally[specName] = { runs: existing.runs + 1, lastRun: new Date().toISOString() };
|
||||
writeFileSync(tallyPath(), JSON.stringify(tally, null, 2));
|
||||
}
|
||||
|
||||
export function readTally(): Record<string, TallyEntry> {
|
||||
try { return JSON.parse(readFileSync(tallyPath(), 'utf8')); } catch { return {}; }
|
||||
}
|
||||
|
||||
export function writeSummary(session: SessionState, outcomeId: string, summary: string): string {
|
||||
ensureDirs();
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `${session.encounterId}-${ts}.txt`;
|
||||
const filePath = join(summariesDir(), filename);
|
||||
const players = Object.values(session.players).map(p => p.dndName).join(', ') || 'None';
|
||||
const content = [
|
||||
`Encounter : ${session.spec.title}`,
|
||||
`ID : ${session.encounterId}`,
|
||||
`Thread : ${session.threadId}`,
|
||||
`Date : ${new Date().toISOString()}`,
|
||||
`Outcome : ${outcomeId}`,
|
||||
`Players : ${players}`,
|
||||
``,
|
||||
`Summary:`,
|
||||
summary,
|
||||
].join('\n');
|
||||
writeFileSync(filePath, content, 'utf8');
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function getLatestSummary(): string | null {
|
||||
try {
|
||||
ensureDirs();
|
||||
const files = readdirSync(summariesDir())
|
||||
.filter(f => f.endsWith('.txt'))
|
||||
.sort()
|
||||
.reverse();
|
||||
return files[0] ? join(summariesDir(), files[0]) : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
32
src/session/playerRegistry.ts
Normal file
32
src/session/playerRegistry.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// Thin shim over characterRegistry — all existing callers continue to work
|
||||
// unchanged. New code should use characterRegistry directly.
|
||||
import { characterRegistry } from './characterRegistry.js';
|
||||
import type { Player } from '../types/index.js';
|
||||
|
||||
export const playerRegistry = {
|
||||
async get(guildId: string, discordId: string): Promise<Player | null> {
|
||||
const profile = await characterRegistry.get(guildId, discordId);
|
||||
if (!profile) return null;
|
||||
return { discordId, dndName: profile.dndName };
|
||||
},
|
||||
|
||||
async set(guildId: string, discordId: string, dndName: string): Promise<void> {
|
||||
const existing = await characterRegistry.get(guildId, discordId);
|
||||
await characterRegistry.set(guildId, {
|
||||
discordId,
|
||||
dndName,
|
||||
source: existing?.source ?? 'custom',
|
||||
...(existing && {
|
||||
foundryActorUuid: existing.foundryActorUuid,
|
||||
characterClass: existing.characterClass,
|
||||
level: existing.level,
|
||||
race: existing.race,
|
||||
backstory: existing.backstory,
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
async delete(guildId: string, discordId: string): Promise<void> {
|
||||
await characterRegistry.delete(guildId, discordId);
|
||||
},
|
||||
};
|
||||
72
src/session/sessionManager.ts
Normal file
72
src/session/sessionManager.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { redis } from '../db/redis.js';
|
||||
import { config } from '../config.js';
|
||||
import type { SessionState, ChatMessage } from '../types/index.js';
|
||||
import { CONTEXT_BUDGET } from '../types/index.js';
|
||||
import { encode } from 'gpt-tokenizer';
|
||||
|
||||
const SESSION_TTL = 60 * 60 * config.SESSION_TTL_HOURS;
|
||||
const sessionKey = (threadId: string) => `session:${threadId}`;
|
||||
const guildThreadsKey = (guildId: string) => `guild_threads:${guildId}`;
|
||||
|
||||
// 15% buffer on top of GPT tokenizer estimate to account for Gemma differences
|
||||
function estimateTokens(text: string): number {
|
||||
return Math.ceil(encode(text).length * 1.15);
|
||||
}
|
||||
|
||||
function estimateMessages(messages: ChatMessage[]): number {
|
||||
return messages.reduce((sum, m) => sum + estimateTokens(m.content) + 4, 0);
|
||||
}
|
||||
|
||||
function trimHistory(messages: ChatMessage[]): ChatMessage[] {
|
||||
const budget = CONTEXT_BUDGET.HISTORY - CONTEXT_BUDGET.SAFETY;
|
||||
const result = [...messages];
|
||||
while (estimateMessages(result) > budget && result.length > 6) {
|
||||
result.splice(0, 2);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export const sessionManager = {
|
||||
async create(threadId: string, state: SessionState): Promise<void> {
|
||||
const pipe = redis.pipeline();
|
||||
pipe.set(sessionKey(threadId), JSON.stringify(state), 'EX', SESSION_TTL);
|
||||
pipe.sadd(guildThreadsKey(state.guildId), threadId);
|
||||
pipe.expire(guildThreadsKey(state.guildId), SESSION_TTL);
|
||||
await pipe.exec();
|
||||
},
|
||||
|
||||
async get(threadId: string): Promise<SessionState | null> {
|
||||
const raw = await redis.get(sessionKey(threadId));
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as SessionState;
|
||||
},
|
||||
|
||||
async update(threadId: string, patch: Partial<SessionState>): Promise<void> {
|
||||
const current = await this.get(threadId);
|
||||
if (!current) throw new Error(`Session not found: ${threadId}`);
|
||||
const updated: SessionState = { ...current, ...patch, updatedAt: Date.now() };
|
||||
await redis.set(sessionKey(threadId), JSON.stringify(updated), 'EX', SESSION_TTL);
|
||||
},
|
||||
|
||||
async delete(threadId: string, guildId: string): Promise<void> {
|
||||
await redis.del(sessionKey(threadId));
|
||||
await redis.srem(guildThreadsKey(guildId), threadId);
|
||||
},
|
||||
|
||||
async addMessage(threadId: string, msg: ChatMessage): Promise<void> {
|
||||
const session = await this.get(threadId);
|
||||
if (!session) throw new Error(`Session not found: ${threadId}`);
|
||||
|
||||
const pinned = session.history.filter(m => m.pinned);
|
||||
const sliding = session.history.filter(m => !m.pinned);
|
||||
sliding.push(msg);
|
||||
const trimmed = trimHistory(sliding);
|
||||
|
||||
await this.update(threadId, { history: [...pinned, ...trimmed] });
|
||||
},
|
||||
|
||||
// Returns thread IDs for a guild — used by /dndname set to find held messages.
|
||||
async getGuildThreadIds(guildId: string): Promise<string[]> {
|
||||
return redis.smembers(guildThreadsKey(guildId));
|
||||
},
|
||||
};
|
||||
54
src/session/xpAwarder.ts
Normal file
54
src/session/xpAwarder.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { ThreadChannel, TextChannel } from 'discord.js';
|
||||
import { characterRegistry } from './characterRegistry.js';
|
||||
import { modifyExperience } from '../vtt/foundryClient.js';
|
||||
import { log } from '../lib/logger.js';
|
||||
import type { SessionState } from '../types/index.js';
|
||||
|
||||
export interface XPResult {
|
||||
awarded: { dndName: string; amount: number }[];
|
||||
skipped: { dndName: string; reason: string; discordId: string }[];
|
||||
}
|
||||
|
||||
export async function awardXP(
|
||||
session: SessionState,
|
||||
amount: number,
|
||||
thread: ThreadChannel | TextChannel,
|
||||
): Promise<XPResult> {
|
||||
const result: XPResult = { awarded: [], skipped: [] };
|
||||
const players = Object.values(session.players);
|
||||
if (players.length === 0) return result;
|
||||
|
||||
for (const player of players) {
|
||||
let profile;
|
||||
try {
|
||||
profile = await characterRegistry.get(session.guildId, player.discordId);
|
||||
} catch {
|
||||
result.skipped.push({ dndName: player.dndName, discordId: player.discordId, reason: 'registry error' });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!profile?.foundryActorUuid) {
|
||||
result.skipped.push({ dndName: player.dndName, discordId: player.discordId, reason: 'no Foundry character linked' });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await modifyExperience(profile.foundryActorUuid, amount);
|
||||
result.awarded.push({ dndName: player.dndName, amount });
|
||||
log.info('xp', `awarded ${amount} XP`, { player: player.dndName });
|
||||
} catch (err) {
|
||||
log.error('xp', 'modifyExperience failed', { player: player.dndName, error: String(err) });
|
||||
result.skipped.push({ dndName: player.dndName, discordId: player.discordId, reason: 'Foundry relay error' });
|
||||
}
|
||||
}
|
||||
|
||||
// Post result summary to the thread (visible to all)
|
||||
const lines: string[] = [`**+${amount} XP awarded**`];
|
||||
for (const a of result.awarded) lines.push(`✅ ${a.dndName}`);
|
||||
for (const s of result.skipped) {
|
||||
lines.push(`⚠️ <@${s.discordId}> (${s.dndName}) — skipped: ${s.reason}`);
|
||||
}
|
||||
await thread.send(lines.join('\n'));
|
||||
|
||||
return result;
|
||||
}
|
||||
65
src/spec/loader.ts
Normal file
65
src/spec/loader.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { load } from 'js-yaml';
|
||||
import { z } from 'zod';
|
||||
import { config } from '../config.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zod schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NpcSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
nameKey: z.string().optional(),
|
||||
role: z.string(),
|
||||
persona: z.string(),
|
||||
memoryKey: z.string().optional(),
|
||||
});
|
||||
|
||||
const GoalSchema = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
});
|
||||
|
||||
export const EncounterSpecSchema = z.object({
|
||||
encounterId: z.string(),
|
||||
title: z.string(),
|
||||
setting: z.object({
|
||||
location: z.string(),
|
||||
mood: z.string(),
|
||||
ambientNpcs: z.string(),
|
||||
}),
|
||||
openingNarrative: z.string(),
|
||||
npcs: z.array(NpcSchema).min(1).max(5),
|
||||
goals: z.object({
|
||||
hidden: z.boolean().default(true),
|
||||
primary: z.array(GoalSchema).min(1),
|
||||
secondary: z.array(GoalSchema),
|
||||
}),
|
||||
sportsmanshipRules: z.array(z.string()),
|
||||
skillChecks: z.record(z.union([z.number(), z.string()])),
|
||||
randomizable: z.array(z.object({
|
||||
key: z.string(),
|
||||
query: z.string(),
|
||||
fallback: z.string(),
|
||||
source: z.enum(['graphmcp', 'vocabulary']).optional(),
|
||||
category: z.string().optional(),
|
||||
})).optional(),
|
||||
dmNotes: z.string().optional(),
|
||||
tools: z.array(z.string()).optional(),
|
||||
tone: z.string().optional(),
|
||||
});
|
||||
|
||||
export type EncounterSpecLoaded = z.infer<typeof EncounterSpecSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loader
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function loadSpec(specName: string): EncounterSpecLoaded {
|
||||
const filePath = join(config.SPECS_DIR, `${specName}.yaml`);
|
||||
const raw = readFileSync(filePath, 'utf-8');
|
||||
const parsed = load(raw);
|
||||
return EncounterSpecSchema.parse(parsed);
|
||||
}
|
||||
165
src/types/index.ts
Normal file
165
src/types/index.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
// Shared types used across all layers of the Mardonar Encounter Engine.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Players
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Player {
|
||||
discordId: string;
|
||||
dndName: string;
|
||||
pronouns?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Encounter Spec
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface NpcPersona {
|
||||
id: string;
|
||||
name: string;
|
||||
// If set, the display name for this session is resolved from resolvedContext[nameKey].
|
||||
// The canonical `name` field remains the graph identity used for memory queries.
|
||||
nameKey?: string;
|
||||
role: string;
|
||||
persona: string;
|
||||
memoryKey?: string;
|
||||
}
|
||||
|
||||
export interface EncounterGoal {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface EncounterGoals {
|
||||
hidden: boolean;
|
||||
primary: EncounterGoal[];
|
||||
secondary: EncounterGoal[];
|
||||
}
|
||||
|
||||
export interface EncounterSetting {
|
||||
location: string;
|
||||
mood: string;
|
||||
ambientNpcs: string;
|
||||
}
|
||||
|
||||
export interface RandomizableItem {
|
||||
key: string;
|
||||
query: string;
|
||||
fallback: string;
|
||||
// 'vocabulary' samples from lore/vocabulary.yaml using `category` (dot-path, e.g. 'names.dwarf.female').
|
||||
// Default / absent means 'graphmcp' — semantic search against the knowledge graph.
|
||||
source?: 'graphmcp' | 'vocabulary';
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface EncounterSpec {
|
||||
encounterId: string;
|
||||
title: string;
|
||||
setting: EncounterSetting;
|
||||
openingNarrative: string;
|
||||
npcs: NpcPersona[];
|
||||
goals: EncounterGoals;
|
||||
sportsmanshipRules: string[];
|
||||
skillChecks: Record<string, number | string>;
|
||||
randomizable?: RandomizableItem[];
|
||||
dmNotes?: string;
|
||||
// XP awarded to all participants when the encounter resolves.
|
||||
xpReward?: number;
|
||||
// Optional allow-list of tool plugin names active for this encounter.
|
||||
// Omit to enable all registered tools (default behaviour).
|
||||
tools?: string[];
|
||||
// Narration flavor for this encounter (e.g. "grim", "tense", "comedic").
|
||||
// Drives the system prompt tone block and drop notice string selection.
|
||||
tone?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SessionPhase = 'open' | 'active' | 'resolved';
|
||||
|
||||
export interface PendingSkillCheck {
|
||||
player: string;
|
||||
prompt: string;
|
||||
dc: number;
|
||||
messageId?: string; // Discord message ID of the embed with roll buttons
|
||||
modifier?: number; // Pre-fetched Foundry skill/ability modifier, if available
|
||||
skill?: string; // Skill name as provided by the LLM (e.g. "Perception")
|
||||
advantage?: boolean; // LLM determined the player has advantage on this roll
|
||||
disadvantage?: boolean; // LLM determined the player has disadvantage on this roll
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
encounterId: string;
|
||||
threadId: string;
|
||||
guildId: string;
|
||||
spec: EncounterSpec;
|
||||
players: Record<string, Player>;
|
||||
history: ChatMessage[];
|
||||
phase: SessionPhase;
|
||||
heldMessages: HeldMessage[];
|
||||
// Formatted NPC memory strings loaded from GraphMCP at session start.
|
||||
// Key is npc.id, value is the formatted memory text for the system prompt.
|
||||
npcMemories: Record<string, string>;
|
||||
// Lore-resolved randomizable details for this session instance.
|
||||
// Key matches RandomizableItem.key; value is the resolved string from GraphMCP.
|
||||
resolvedContext: Record<string, string>;
|
||||
pendingSkillCheck?: PendingSkillCheck;
|
||||
pendingSkillCheckAttempts?: number;
|
||||
outcome?: string;
|
||||
outcomeSummary?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat History
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
pinned?: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface HeldMessage {
|
||||
discordUserId: string;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LLM Harness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ToolCallBlock {
|
||||
tool: ToolName;
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface LLMResponse {
|
||||
narrative: string;
|
||||
toolCall?: ToolCallBlock;
|
||||
rawTokensUsed?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tools
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// String alias — tool names are defined by the plugin registry, not a static union.
|
||||
export type ToolName = string;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context Budget
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const CONTEXT_BUDGET = {
|
||||
SYSTEM: 4_000,
|
||||
PINNED: 2_000,
|
||||
HISTORY: 118_000,
|
||||
SAFETY: 3_500,
|
||||
TOTAL: 128_000,
|
||||
} as const;
|
||||
277
src/vtt/foundryClient.ts
Normal file
277
src/vtt/foundryClient.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import { config } from '../config.js';
|
||||
import { log } from '../lib/logger.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function headers(): Record<string, string> {
|
||||
const h: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': config.VTT_API_KEY,
|
||||
};
|
||||
if (config.VTT_CLIENT_ID) h['x-client-id'] = config.VTT_CLIENT_ID;
|
||||
return h;
|
||||
}
|
||||
|
||||
async function vttGet<T>(path: string, params: Record<string, string | string[]> = {}): Promise<T> {
|
||||
const sp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (Array.isArray(v)) sp.set(k, JSON.stringify(v));
|
||||
else sp.set(k, v);
|
||||
}
|
||||
if (config.VTT_CLIENT_ID) sp.set('clientId', config.VTT_CLIENT_ID);
|
||||
const url = `${config.VTT_RELAY_URL}${path}?${sp}`;
|
||||
|
||||
log.info('relay', `GET ${path}`, { params: sp.toString() || undefined });
|
||||
const start = Date.now();
|
||||
const res = await fetch(url, { headers: headers() });
|
||||
const latencyMs = Date.now() - start;
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
log.error('relay', `GET ${path} failed`, { status: res.status, latencyMs, body: body.slice(0, 200) });
|
||||
throw new Error(`VTT relay ${res.status} ${path}: ${body}`);
|
||||
}
|
||||
|
||||
const data = await res.json() as T;
|
||||
const count = Array.isArray((data as Record<string, unknown>)?.results)
|
||||
? ((data as Record<string, unknown>).results as unknown[]).length
|
||||
: undefined;
|
||||
log.info('relay', `GET ${path} ok`, { status: res.status, latencyMs, results: count });
|
||||
return data;
|
||||
}
|
||||
|
||||
async function vttPost<T>(path: string, body: Record<string, unknown> = {}): Promise<T> {
|
||||
const payload = config.VTT_CLIENT_ID ? { ...body, clientId: config.VTT_CLIENT_ID } : body;
|
||||
|
||||
log.info('relay', `POST ${path}`, { body: JSON.stringify(body).slice(0, 200) });
|
||||
const start = Date.now();
|
||||
const res = await fetch(`${config.VTT_RELAY_URL}${path}`, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const latencyMs = Date.now() - start;
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
log.error('relay', `POST ${path} failed`, { status: res.status, latencyMs, body: text.slice(0, 200) });
|
||||
throw new Error(`VTT relay ${res.status} ${path}: ${text}`);
|
||||
}
|
||||
|
||||
const data = await res.json() as T;
|
||||
log.info('relay', `POST ${path} ok`, { status: res.status, latencyMs, output: JSON.stringify(data).slice(0, 200) });
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response types (partial — only fields we actually use)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FoundryActorSummary {
|
||||
uuid: string;
|
||||
name: string;
|
||||
subType: string;
|
||||
resultType: string;
|
||||
}
|
||||
|
||||
export interface AbilityScore {
|
||||
value: number;
|
||||
mod: number;
|
||||
}
|
||||
|
||||
export interface SkillScore {
|
||||
total: number;
|
||||
passive: number;
|
||||
ability: string;
|
||||
}
|
||||
|
||||
export interface FoundryItem {
|
||||
name: string;
|
||||
type: string;
|
||||
uuid: string;
|
||||
system?: {
|
||||
quantity?: number;
|
||||
equipped?: boolean;
|
||||
description?: { value?: string };
|
||||
preparation?: { prepared?: boolean };
|
||||
level?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FoundryActorDetails {
|
||||
uuid: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
abilities?: Record<string, AbilityScore>;
|
||||
skills?: Record<string, SkillScore>;
|
||||
attributes?: {
|
||||
hp?: { value: number; max: number };
|
||||
ac?: { value: number };
|
||||
prof?: number;
|
||||
};
|
||||
currency?: Record<string, number>;
|
||||
details?: {
|
||||
level?: number;
|
||||
xp?: { value: number };
|
||||
race?: string;
|
||||
background?: string;
|
||||
class?: string;
|
||||
};
|
||||
spellcasting?: string;
|
||||
items?: FoundryItem[];
|
||||
spells?: FoundryItem[];
|
||||
}
|
||||
|
||||
// The relay wraps actor data in an envelope: { type, requestId, data: FoundryActorDetails }
|
||||
interface ActorDetailsEnvelope {
|
||||
data: FoundryActorDetails;
|
||||
}
|
||||
|
||||
export interface RollResult {
|
||||
total: number;
|
||||
formula: string;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
results?: FoundryActorSummary[];
|
||||
total?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function searchActors(query = '', limit = 20): Promise<FoundryActorSummary[]> {
|
||||
const result = await vttGet<SearchResult>('/search', {
|
||||
filter: 'Actor',
|
||||
...(query ? { query } : {}),
|
||||
limit: String(limit),
|
||||
});
|
||||
return result.results ?? [];
|
||||
}
|
||||
|
||||
// Filters a raw searchActors result down to world player characters only,
|
||||
// excluding compendium entries and NPC actors.
|
||||
export function filterPlayerActors(actors: FoundryActorSummary[]): FoundryActorSummary[] {
|
||||
return actors.filter(a => a.subType === 'character' && a.resultType === 'WorldEntity');
|
||||
}
|
||||
|
||||
|
||||
export async function getActorDetails(actorUuid: string): Promise<FoundryActorDetails> {
|
||||
const envelope = await vttGet<ActorDetailsEnvelope>('/dnd5e/get-actor-details', {
|
||||
actorUuid,
|
||||
details: ['abilities', 'skills', 'attributes', 'currency', 'details'],
|
||||
});
|
||||
return envelope.data;
|
||||
}
|
||||
|
||||
export async function getActorInventory(actorUuid: string): Promise<FoundryItem[]> {
|
||||
const envelope = await vttGet<ActorDetailsEnvelope>('/dnd5e/get-actor-details', {
|
||||
actorUuid,
|
||||
details: ['inventory'],
|
||||
});
|
||||
const actor = envelope.data;
|
||||
return (actor.items ?? []).filter(i => !['spell', 'feat', 'class', 'subclass', 'race', 'background'].includes(i.type));
|
||||
}
|
||||
|
||||
export async function getActorSpells(actorUuid: string): Promise<FoundryItem[]> {
|
||||
const envelope = await vttGet<ActorDetailsEnvelope>('/dnd5e/get-actor-details', {
|
||||
actorUuid,
|
||||
details: ['spells'],
|
||||
});
|
||||
return envelope.data.spells ?? [];
|
||||
}
|
||||
|
||||
export async function giveItem(toUuid: string, itemName: string, quantity = 1): Promise<void> {
|
||||
await vttPost('/give', { toUuid, itemName, quantity });
|
||||
}
|
||||
|
||||
export async function rollAbilityCheck(
|
||||
actorUuid: string,
|
||||
ability: string,
|
||||
opts: { advantage?: boolean; disadvantage?: boolean } = {},
|
||||
): Promise<RollResult> {
|
||||
return vttPost<RollResult>('/dnd5e/ability-check', {
|
||||
actorUuid,
|
||||
ability,
|
||||
createChatMessage: true,
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
export async function rollSkillCheck(
|
||||
actorUuid: string,
|
||||
skill: string,
|
||||
opts: { advantage?: boolean; disadvantage?: boolean } = {},
|
||||
): Promise<RollResult> {
|
||||
return vttPost<RollResult>('/dnd5e/skill-check', {
|
||||
actorUuid,
|
||||
skill,
|
||||
createChatMessage: true,
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
export async function rollAbilitySave(
|
||||
actorUuid: string,
|
||||
ability: string,
|
||||
opts: { advantage?: boolean; disadvantage?: boolean } = {},
|
||||
): Promise<RollResult> {
|
||||
return vttPost<RollResult>('/dnd5e/ability-save', {
|
||||
actorUuid,
|
||||
ability,
|
||||
createChatMessage: true,
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
export async function modifyExperience(actorUuid: string, amount: number): Promise<void> {
|
||||
await vttPost('/dnd5e/modify-experience', { actorUuid, amount });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatted summaries — LLM-readable, not raw JSON
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function formatActorSummary(actor: FoundryActorDetails): string {
|
||||
const lines: string[] = [`**${actor.name ?? actor.uuid}** (${actor.type ?? 'unknown'})`];
|
||||
|
||||
if (actor.attributes?.hp) {
|
||||
lines.push(`HP: ${actor.attributes.hp.value}/${actor.attributes.hp.max} AC: ${actor.attributes.ac?.value ?? '?'}`);
|
||||
}
|
||||
if (actor.details?.level) {
|
||||
lines.push(`Level: ${actor.details.level}${actor.details.race ? ` Race: ${actor.details.race}` : ''}`);
|
||||
}
|
||||
if (actor.abilities) {
|
||||
const abils = Object.entries(actor.abilities)
|
||||
.map(([k, v]) => `${k.toUpperCase()}: ${v.value}(${v.mod >= 0 ? '+' : ''}${v.mod})`)
|
||||
.join(' ');
|
||||
lines.push(abils);
|
||||
}
|
||||
if (actor.details?.xp) {
|
||||
lines.push(`XP: ${actor.details.xp.value}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function formatInventory(items: FoundryItem[]): string {
|
||||
if (items.length === 0) return 'No items.';
|
||||
return items.map(i => {
|
||||
const qty = i.system?.quantity != null ? ` ×${i.system.quantity}` : '';
|
||||
const eq = i.system?.equipped ? ' [equipped]' : '';
|
||||
return `- ${i.name}${qty}${eq} (${i.type})`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
export function formatSpells(spells: FoundryItem[]): string {
|
||||
if (spells.length === 0) return 'No spells.';
|
||||
const prepared = spells.filter(s => s.system?.preparation?.prepared);
|
||||
const unprepared = spells.filter(s => !s.system?.preparation?.prepared);
|
||||
const lines: string[] = [];
|
||||
if (prepared.length) lines.push('Prepared: ' + prepared.map(s => s.name).join(', '));
|
||||
if (unprepared.length) lines.push('Known (unprepared): ' + unprepared.map(s => s.name).join(', '));
|
||||
return lines.join('\n');
|
||||
}
|
||||
63
tests/fixtures/spec.ts
vendored
Normal file
63
tests/fixtures/spec.ts
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { EncounterSpec, SessionState } from '../../src/types/index.js';
|
||||
|
||||
export const mockSpec: EncounterSpec = {
|
||||
encounterId: 'test-encounter-001',
|
||||
title: 'Test Encounter',
|
||||
setting: {
|
||||
location: 'Test Town Square',
|
||||
mood: 'Tense',
|
||||
ambientNpcs: 'A few bystanders',
|
||||
},
|
||||
openingNarrative: 'The scene opens in Test Town.',
|
||||
npcs: [
|
||||
{
|
||||
id: 'npc-one',
|
||||
name: 'Miriam',
|
||||
role: 'Vendor',
|
||||
persona: 'A stout, red-faced Dwarf who takes theft personally.',
|
||||
memoryKey: 'miriam-vendor-mardonar',
|
||||
},
|
||||
{
|
||||
id: 'npc-two',
|
||||
name: 'Dal',
|
||||
role: 'Pickpocket',
|
||||
persona: 'A teenage Half-Elf who steals to survive.',
|
||||
memoryKey: 'dal-thief-mardonar',
|
||||
},
|
||||
],
|
||||
goals: {
|
||||
hidden: true,
|
||||
primary: [
|
||||
{ id: 'catch', label: 'Players physically catch Dal.' },
|
||||
{ id: 'negotiate', label: 'Players talk Dal into surrendering.' },
|
||||
],
|
||||
secondary: [
|
||||
{ id: 'escape', label: 'Dal escapes with the apple.' },
|
||||
],
|
||||
},
|
||||
sportsmanshipRules: [
|
||||
'No instant kills on non-threatening NPCs without prior escalation.',
|
||||
'No controlling another player character.',
|
||||
],
|
||||
skillChecks: {
|
||||
chase_dc: 13,
|
||||
persuade_dc: 10,
|
||||
},
|
||||
};
|
||||
|
||||
export const mockSession: SessionState = {
|
||||
encounterId: 'test-encounter-001',
|
||||
threadId: 'thread-123',
|
||||
guildId: 'guild-456',
|
||||
spec: mockSpec,
|
||||
players: {
|
||||
'user-789': { discordId: 'user-789', dndName: 'Aelindra' },
|
||||
},
|
||||
history: [],
|
||||
phase: 'active',
|
||||
heldMessages: [],
|
||||
npcMemories: {},
|
||||
resolvedContext: {},
|
||||
createdAt: 1000000,
|
||||
updatedAt: 1000001,
|
||||
};
|
||||
46
tests/integration/phase1.test.ts
Normal file
46
tests/integration/phase1.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// Integration tests — require live Redis on localhost:6379.
|
||||
// Start services before running: docker compose -f docker-compose.dev.yml up -d
|
||||
//
|
||||
// Run with: npm run test:int
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
|
||||
// These tests are skipped unless RUN_INTEGRATION=1 is set,
|
||||
// so they don't break CI without live services.
|
||||
const runInt = process.env.RUN_INTEGRATION === '1';
|
||||
|
||||
describe.skipIf(!runInt)('Player registry — live Redis', async () => {
|
||||
let playerRegistry: typeof import('../../src/session/playerRegistry.js').playerRegistry;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { redis } = await import('../../src/db/redis.js');
|
||||
await redis.connect();
|
||||
const mod = await import('../../src/session/playerRegistry.js');
|
||||
playerRegistry = mod.playerRegistry;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const { redis } = await import('../../src/db/redis.js');
|
||||
await redis.del('players:int-test-guild');
|
||||
redis.disconnect();
|
||||
});
|
||||
|
||||
it('round-trips set/get/delete', async () => {
|
||||
await playerRegistry.set('int-test-guild', 'user-abc', 'Aelindra');
|
||||
const player = await playerRegistry.get('int-test-guild', 'user-abc');
|
||||
expect(player).toEqual({ discordId: 'user-abc', dndName: 'Aelindra' });
|
||||
|
||||
await playerRegistry.delete('int-test-guild', 'user-abc');
|
||||
expect(await playerRegistry.get('int-test-guild', 'user-abc')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!runInt)('Spec loader — market-thief.yaml', async () => {
|
||||
it('loads and validates the market-thief spec', async () => {
|
||||
const { loadSpec } = await import('../../src/spec/loader.js');
|
||||
const spec = loadSpec('market-thief');
|
||||
expect(spec.encounterId).toBe('mardonar-market-thief-001');
|
||||
expect(spec.npcs).toHaveLength(2);
|
||||
expect(spec.goals.primary.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
95
tests/unit/characterRegistry.test.ts
Normal file
95
tests/unit/characterRegistry.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
const refs = vi.hoisted(() => ({ mockRedis: null as any }));
|
||||
|
||||
vi.mock('../../src/db/redis.js', async () => {
|
||||
const { default: RedisMock } = await import('ioredis-mock');
|
||||
refs.mockRedis = new RedisMock();
|
||||
return { redis: refs.mockRedis };
|
||||
});
|
||||
vi.mock('../../src/config.js', () => ({
|
||||
config: { REDIS_URL: 'redis://localhost:6379' },
|
||||
}));
|
||||
|
||||
import { characterRegistry } from '../../src/session/characterRegistry.js';
|
||||
import type { CharacterProfile } from '../../src/session/characterRegistry.js';
|
||||
|
||||
const profile: CharacterProfile = {
|
||||
discordId: 'user-123',
|
||||
dndName: 'Aelindra',
|
||||
characterClass: 'Ranger',
|
||||
level: 5,
|
||||
race: 'Half-Elf',
|
||||
backstory: 'A wandering scout from the northern hills.',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await refs.mockRedis?.flushall();
|
||||
});
|
||||
|
||||
describe('characterRegistry', () => {
|
||||
it('set then get returns the full profile', async () => {
|
||||
await characterRegistry.set('guild-1', profile);
|
||||
const result = await characterRegistry.get('guild-1', 'user-123');
|
||||
expect(result).toEqual(profile);
|
||||
});
|
||||
|
||||
it('get on unknown user returns null', async () => {
|
||||
const result = await characterRegistry.get('guild-1', 'unknown');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('set overwrites an existing profile', async () => {
|
||||
await characterRegistry.set('guild-1', profile);
|
||||
const updated = { ...profile, level: 6, characterClass: 'Ranger/Rogue' };
|
||||
await characterRegistry.set('guild-1', updated);
|
||||
const result = await characterRegistry.get('guild-1', 'user-123');
|
||||
expect(result?.level).toBe(6);
|
||||
expect(result?.characterClass).toBe('Ranger/Rogue');
|
||||
});
|
||||
|
||||
it('delete removes the profile', async () => {
|
||||
await characterRegistry.set('guild-1', profile);
|
||||
await characterRegistry.delete('guild-1', 'user-123');
|
||||
const result = await characterRegistry.get('guild-1', 'user-123');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('profiles are scoped by guildId', async () => {
|
||||
await characterRegistry.set('guild-A', profile);
|
||||
const result = await characterRegistry.get('guild-B', 'user-123');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('stores and retrieves optional foundryActorUuid', async () => {
|
||||
const linked = { ...profile, foundryActorUuid: 'Actor.xyz789' };
|
||||
await characterRegistry.set('guild-1', linked);
|
||||
const result = await characterRegistry.get('guild-1', 'user-123');
|
||||
expect(result?.foundryActorUuid).toBe('Actor.xyz789');
|
||||
});
|
||||
|
||||
it('profile without foundryActorUuid has undefined uuid field', async () => {
|
||||
await characterRegistry.set('guild-1', profile);
|
||||
const result = await characterRegistry.get('guild-1', 'user-123');
|
||||
expect(result?.foundryActorUuid).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stores and retrieves pronouns', async () => {
|
||||
const withPronouns = { ...profile, pronouns: 'she/her' };
|
||||
await characterRegistry.set('guild-1', withPronouns);
|
||||
const result = await characterRegistry.get('guild-1', 'user-123');
|
||||
expect(result?.pronouns).toBe('she/her');
|
||||
});
|
||||
|
||||
it('pronouns is undefined when not set', async () => {
|
||||
await characterRegistry.set('guild-1', profile);
|
||||
const result = await characterRegistry.get('guild-1', 'user-123');
|
||||
expect(result?.pronouns).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles corrupt Redis data gracefully by returning null', async () => {
|
||||
await refs.mockRedis.hset('characters:guild-1', 'user-bad', 'not-json{{{');
|
||||
const result = await characterRegistry.get('guild-1', 'user-bad');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
77
tests/unit/config.test.ts
Normal file
77
tests/unit/config.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { EnvSchema } from '../../src/config.js';
|
||||
|
||||
const base = { DISCORD_TOKEN: 'tok', DISCORD_CLIENT_ID: 'cid' };
|
||||
|
||||
describe('EnvSchema', () => {
|
||||
it('parses required fields', () => {
|
||||
const c = EnvSchema.parse(base);
|
||||
expect(c.DISCORD_TOKEN).toBe('tok');
|
||||
expect(c.DISCORD_CLIENT_ID).toBe('cid');
|
||||
});
|
||||
|
||||
it('applies all defaults', () => {
|
||||
const c = EnvSchema.parse(base);
|
||||
expect(c.REDIS_URL).toBe('redis://localhost:6379');
|
||||
expect(c.OLLAMA_TEMPERATURE).toBe(0.75);
|
||||
expect(c.OLLAMA_NUM_CTX).toBe(131072);
|
||||
expect(c.SESSION_TTL_HOURS).toBe(12);
|
||||
expect(c.GRAPHMCP_SCORE_THRESHOLD).toBe(0.68);
|
||||
expect(c.ENCOUNTER_ARCHIVE_DELAY_MS).toBe(5_000);
|
||||
expect(c.ENCOUNTER_GATE_TIMEOUT_MS).toBe(30_000);
|
||||
expect(c.PERSONA_PATH).toBe('./persona.yaml');
|
||||
expect(c.DATA_DIR).toBe('./data');
|
||||
});
|
||||
|
||||
it('splits DISCORD_ALLOWED_CHANNELS on commas and trims whitespace', () => {
|
||||
const c = EnvSchema.parse({ ...base, DISCORD_ALLOWED_CHANNELS: '111, 222 , 333' });
|
||||
expect(c.DISCORD_ALLOWED_CHANNELS).toEqual(['111', '222', '333']);
|
||||
});
|
||||
|
||||
it('returns empty array for blank DISCORD_ALLOWED_CHANNELS', () => {
|
||||
const c = EnvSchema.parse({ ...base, DISCORD_ALLOWED_CHANNELS: '' });
|
||||
expect(c.DISCORD_ALLOWED_CHANNELS).toEqual([]);
|
||||
});
|
||||
|
||||
it('splits DISCORD_ALLOWED_USERS correctly', () => {
|
||||
const c = EnvSchema.parse({ ...base, DISCORD_ALLOWED_USERS: 'u1,u2' });
|
||||
expect(c.DISCORD_ALLOWED_USERS).toEqual(['u1', 'u2']);
|
||||
});
|
||||
|
||||
it('coerces OLLAMA_TEMPERATURE from string', () => {
|
||||
const c = EnvSchema.parse({ ...base, OLLAMA_TEMPERATURE: '0.5' });
|
||||
expect(c.OLLAMA_TEMPERATURE).toBe(0.5);
|
||||
});
|
||||
|
||||
it('coerces SESSION_TTL_HOURS from string', () => {
|
||||
const c = EnvSchema.parse({ ...base, SESSION_TTL_HOURS: '24' });
|
||||
expect(c.SESSION_TTL_HOURS).toBe(24);
|
||||
});
|
||||
|
||||
it('coerces GRAPHMCP_SCORE_THRESHOLD from string', () => {
|
||||
const c = EnvSchema.parse({ ...base, GRAPHMCP_SCORE_THRESHOLD: '0.9' });
|
||||
expect(c.GRAPHMCP_SCORE_THRESHOLD).toBe(0.9);
|
||||
});
|
||||
|
||||
it('rejects invalid LOG_LEVEL', () => {
|
||||
expect(() => EnvSchema.parse({ ...base, LOG_LEVEL: 'verbose' })).toThrow();
|
||||
});
|
||||
|
||||
it('rejects OLLAMA_TEMPERATURE above 2', () => {
|
||||
expect(() => EnvSchema.parse({ ...base, OLLAMA_TEMPERATURE: '3' })).toThrow();
|
||||
});
|
||||
|
||||
it('rejects GRAPHMCP_SCORE_THRESHOLD above 1', () => {
|
||||
expect(() => EnvSchema.parse({ ...base, GRAPHMCP_SCORE_THRESHOLD: '1.5' })).toThrow();
|
||||
});
|
||||
|
||||
it('accepts optional DISCORD_GUILD_ID', () => {
|
||||
const c = EnvSchema.parse({ ...base, DISCORD_GUILD_ID: 'g123' });
|
||||
expect(c.DISCORD_GUILD_ID).toBe('g123');
|
||||
});
|
||||
|
||||
it('DISCORD_GUILD_ID is undefined when not set', () => {
|
||||
const c = EnvSchema.parse(base);
|
||||
expect(c.DISCORD_GUILD_ID).toBeUndefined();
|
||||
});
|
||||
});
|
||||
84
tests/unit/contextAssembler.test.ts
Normal file
84
tests/unit/contextAssembler.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { assembleContext } from '../../src/harness/contextAssembler.js';
|
||||
import { mockSession, mockSpec } from '../fixtures/spec.js';
|
||||
import type { SessionState, ChatMessage } from '../../src/types/index.js';
|
||||
|
||||
function makeMessage(role: ChatMessage['role'], content: string, pinned = false): ChatMessage {
|
||||
return { role, content, pinned, timestamp: Date.now() };
|
||||
}
|
||||
|
||||
describe('assembleContext', () => {
|
||||
it('puts the system message first', () => {
|
||||
const context = assembleContext(mockSession);
|
||||
expect(context[0].role).toBe('system');
|
||||
});
|
||||
|
||||
it('includes the system prompt content', () => {
|
||||
const context = assembleContext(mockSession);
|
||||
expect(context[0].content).toContain('narrator');
|
||||
});
|
||||
|
||||
it('always includes pinned messages after system', () => {
|
||||
const session: SessionState = {
|
||||
...mockSession,
|
||||
history: [
|
||||
makeMessage('assistant', 'Opening narrative.', true),
|
||||
makeMessage('user', 'Player action.'),
|
||||
makeMessage('assistant', 'LLM response.'),
|
||||
],
|
||||
};
|
||||
const context = assembleContext(session);
|
||||
const pinned = context.filter(m => m.pinned && m.role !== 'system');
|
||||
expect(pinned).toHaveLength(1);
|
||||
expect(pinned[0].content).toBe('Opening narrative.');
|
||||
});
|
||||
|
||||
it('includes sliding history messages', () => {
|
||||
const session: SessionState = {
|
||||
...mockSession,
|
||||
history: [
|
||||
makeMessage('user', 'Player says something.'),
|
||||
makeMessage('assistant', 'Narrator responds.'),
|
||||
],
|
||||
};
|
||||
const context = assembleContext(session);
|
||||
const nonSystem = context.filter(m => !m.pinned);
|
||||
expect(nonSystem.some(m => m.content === 'Player says something.')).toBe(true);
|
||||
});
|
||||
|
||||
it('injects NPC memory into the system prompt', () => {
|
||||
const session: SessionState = {
|
||||
...mockSession,
|
||||
npcMemories: {
|
||||
'npc-one': 'Past encounters witnessed:\n - [2026-01-01] Tavern Brawl: A fight broke out.',
|
||||
},
|
||||
};
|
||||
const context = assembleContext(session);
|
||||
expect(context[0].content).toContain('Tavern Brawl');
|
||||
});
|
||||
|
||||
it('drops oldest non-pinned pairs when history exceeds budget', () => {
|
||||
// Use natural language so BPE tokenisation produces realistic token counts.
|
||||
// Repeated single characters compress to almost nothing in BPE.
|
||||
const bigContent = 'the quick brown fox jumps over the lazy dog. '.repeat(100);
|
||||
const history: ChatMessage[] = [
|
||||
makeMessage('assistant', 'Opening narrative pinned.', true),
|
||||
];
|
||||
// 200 pairs × ~1 000 tokens each ≈ 200 000 tokens >> 114 500 budget
|
||||
for (let i = 0; i < 200; i++) {
|
||||
history.push(makeMessage('user', `${bigContent} turn ${i}`));
|
||||
history.push(makeMessage('assistant', `${bigContent} response ${i}`));
|
||||
}
|
||||
|
||||
const session: SessionState = { ...mockSession, history };
|
||||
const context = assembleContext(session);
|
||||
|
||||
// Pinned message must survive trimming
|
||||
const pinnedInContext = context.filter(m => m.pinned && m.role !== 'system');
|
||||
expect(pinnedInContext).toHaveLength(1);
|
||||
|
||||
// Sliding window should be well under the 400 we pushed in
|
||||
const sliding = context.filter(m => !m.pinned);
|
||||
expect(sliding.length).toBeLessThan(400);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user