Untrack the 9 committed data/summaries/*.txt files (runtime encounter summaries from 2026-05-24..05-30). They were committed before the data/ gitignore rule took effect; data/ is already ignored, so untracking brings tracking in line with the ignore and stops runtime artifacts from living in version control. Working copies are kept on disk. Co-Authored-By: Claude <noreply@anthropic.com>
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 GraphMCP.
Stack
| Layer | Technology |
|---|---|
| Discord bot | discord.js v14 |
| Language | TypeScript (Node.js, ESM) |
| LLM | gemma4-it:e2b via Ollama (with LiteLLM as optional primary) |
| Session cache | Redis (ioredis) |
| NPC memory / events | GraphMCP (external JSON-RPC server) |
| Schema validation | Zod |
| Test runner | Vitest |
Prerequisites
- Node.js 20+
- Docker + Docker Compose (for local Redis)
- Ollama running on your network with
gemma4-it:e2bpulled - A Discord bot token and application ID
- A reachable GraphMCP JSON-RPC server (separate stack — see
docs/architecture.md§2)
Quick Start
1. Clone and install
git clone <your-repo>
cd mardonar-bot
npm install
2. Configure environment
cp .env.example .env
Edit .env:
DISCORD_TOKEN=your_discord_bot_token
DISCORD_CLIENT_ID=your_discord_application_id
REDIS_URL=redis://localhost:6379
# Point at your Ollama node — can be a LAN IP
OLLAMA_BASE_URL=http://192.168.1.x:11434
OLLAMA_MODEL=gemma4-it:e2b
3. Start local services
docker compose -f docker-compose.dev.yml up -d
This starts Redis on localhost:6379. GraphMCP is expected to be reachable
on the mardonar-internal Docker network — see docs/deployment-guide.md.
4. Register Discord slash commands
Run once per bot deployment, or whenever commands change:
npm run deploy-commands
5. Start the bot
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/ # Slash command modules (data + execute)
│ │ │ ├── dndname.ts # /dndname set|show|clear
│ │ │ ├── encounter.ts # /encounter start|status|end
│ │ │ ├── character.ts # /character register|show|clear
│ │ │ ├── roll.ts # /roll <dice>
│ │ │ ├── actions.ts # /action <verb>
│ │ │ ├── turn.ts # /turn pass|list
│ │ │ ├── xp.ts # /xp grant|show
│ │ │ └── encounters.ts # /encounters list|info
│ │ ├── embeds/ # Discord embed builders
│ │ │ ├── skillCheck.ts
│ │ │ ├── playerGate.ts
│ │ │ ├── resolution.ts
│ │ │ ├── encounterDiscovery.ts
│ │ │ └── loreAnswer.ts
│ │ ├── handlers/ # Event handlers and queues
│ │ │ └── messageRouter.ts # Main event loop for encounter threads
│ │ └── index.ts # discord.js client + startup
│ ├── session/ # Redis-backed registries and state
│ │ ├── playerRegistry.ts
│ │ ├── characterRegistry.ts
│ │ ├── sessionManager.ts
│ │ ├── encounterLog.ts
│ │ └── xpAwarder.ts
│ ├── harness/ # LLM orchestration
│ │ ├── promptBuilder.ts
│ │ ├── contextAssembler.ts
│ │ ├── llmClient.ts # LiteLLM primary, Ollama fallback
│ │ ├── litellmClient.ts
│ │ ├── ollamaClient.ts
│ │ ├── toolParser.ts
│ │ ├── toolDispatcher.ts
│ │ ├── toolRegistry.ts
│ │ └── tools/ # Tool plugin implementations
│ ├── graphmcp/ # GraphMCP JSON-RPC client (NPC lore, events)
│ │ ├── client.ts
│ │ ├── ingest.ts
│ │ ├── loreResolver.ts
│ │ └── vocabularyResolver.ts
│ ├── vtt/ # Optional Foundry VTT relay
│ │ ├── foundryClient.ts
│ │ └── relaySession.ts
│ ├── db/
│ │ └── redis.ts # ioredis singleton
│ ├── spec/loader.ts # YAML spec loader + Zod validation
│ ├── persona/loader.ts # persona.yaml loader for @mention
│ ├── lib/
│ │ ├── logger.ts # Custom plaintext stdout logger
│ │ └── historyTrim.ts # Shared chat-history trimmer
│ ├── scripts/deploy-commands.ts # Slash command registration (REST v10)
│ ├── config.ts # Zod-validated env vars
│ └── types/index.ts # All shared TypeScript types
├── specs/
│ └── market-thief.yaml # Example encounter spec
├── tests/
│ ├── unit/ # 33 unit test files
│ └── integration/ # Phase1 integration tests
├── 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
encounterId: # Unique ID — used as the encounter session key
title: # Display name shown in Discord embeds
setting: # location, mood, ambientNpcs (all strings)
openingNarrative: # The scene-setting text posted at session start
npcs: # 1–3 personas. Each has id, name, role, persona, optional memoryKey
goals:
primary: # Main target endings the LLM steers toward
secondary: # Valid but non-primary outcomes
sportsmanshipRules: # List of "do not allow" rules
skillChecks: # Named DCs e.g. chase_dc: 13
How a Session Works
1. DM runs /encounter start <spec>
2. Bot creates a Discord thread and posts the opening narrative
3. Players post what their character does
4. Bot appends the message to session history, calls Ollama
5. Ollama response is parsed:
- Narrative text → posted to the thread
- tool_call block → dispatched silently
6. Tool results (skill check embeds, GraphMCP 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 the
GraphMCP-backed graph (long-term NPC lore, prior encounter history, etc.).
At session start, their memory facts are loaded and injected into the system
prompt. At encounter resolution, the foundry_reward tool call commits new
facts 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
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 a reachable GraphMCP endpoint. Start Redis first:
docker compose -f docker-compose.dev.yml up -d
npm run test:int
Adding a New Encounter
- Copy
specs/market-thief.yamltospecs/your-encounter.yaml - Fill in all required fields (see
EncounterSpecinsrc/spec/loader.ts) - Run
npm run buildto confirm the spec passes Zod validation - Run
/encounter start your-encounterin Discord - Re-deploy slash commands:
npm run deploy-commands
Deployment Notes
The bot is a single Node.js process. It connects to:
- Redis (ioredis) for session and player registry
- GraphMCP JSON-RPC server 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.
# Build
npm run build
# Run (with .env file)
node dist/bot/index.js
Architecture Documents
Full design rationale and the phased build plan are in docs/:
docs/architecture.md— system overview, design decisions, drift logdocs/development-guide.md— local development + test conventionsdocs/deployment-guide.md— Docker, env vars, deploy-commands flowdocs/api-contracts.md— slash command and tool interface contracts
Note:
Docs/mardonar-encounter-engine.mdandDocs/mardonar-build-plan.md(capitalD) are historical documents from an earlier Go-based design and are not kept in sync. They are retained as a record of the project's evolution.