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:
2026-05-30 04:51:21 +00:00
commit 9dc6e8e1a3
121 changed files with 16424 additions and 0 deletions

69
.env.example Normal file
View 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 (02)
# 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 (01) 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
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
.env
*.log
.DS_Store

32
Dockerfile Normal file
View 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
View 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 14 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 14 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 |
|---|---|---|
| FR1FR5 | Epic 1 | 1.1 |
| FR6FR7 | Epic 1 | 1.2 |
| FR8FR10 | Epic 2 | 2.1 |
| FR11FR12 | Epic 3 | 3.1 |
| FR13FR14 | Epic 4 | 4.1 |
| NFR1NFR6 | Cross-cutting | All stories |
| UX-DR1UX-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
View 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*

View 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 (13 × ~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
View 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.

View 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)

View 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

View 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

View 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

View 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

View 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.

View 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

View 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.53 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
View 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: # 13 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

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

38
package.json Normal file
View 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
View 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: >
35 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
View 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 23 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 targets 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
View 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>`;
}

View 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
View 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 24 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 35 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 35 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 | 810 |
| Moderate | 1213 |
| Hard | 1416 |
| Very hard | 1820 |
| Near-impossible | 2530 |
**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
View 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
View 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
View 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
View 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'.

View 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 811 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
View 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
View 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
View 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');
}

View 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)}`,
});
}
}

View 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 });
}
}

View 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 24 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 });
}

View 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
View 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
View 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.`);
}

View 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;
}

View 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;
}

View 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.' });
}

View 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.' });
}

View 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),
);
}

View 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);
}
}
}

View 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] });
}

View 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);
}
}
}

View 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);
}
}

View 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, '🎲');
}
}

View 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,
});
}

View 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
View 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
View 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
View 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 01. 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
View 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
View 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
View 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,
);
}

View 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;
}

View 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;
}
}

View 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,
];
}

View 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
View 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);
}

View 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,
};
}

View 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);
}

View 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
View 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() };
}
}

View 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;
}

View 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;

View 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: '24 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;

View 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;

View 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;

View 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;

View 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';

View 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 (130). 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
View 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
View 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;
}

View 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);
});

View 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 []; }
});
},
};

View 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; }
}

View 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);
},
};

View 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
View 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
View 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
View 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
View 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
View 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,
};

View 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);
});
});

View 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
View 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();
});
});

View 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