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>
8.9 KiB
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:e2bpulled - A Discord bot token and application ID
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
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
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:
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/
│ │ │ ├── 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
encounterId: # Unique ID — used as Neo4j node key
title: # Display name shown in Discord embeds
setting: # location, mood, ambientNpcs (all strings)
openingNarrative: # The scene-setting text posted at session start
npcs: # 1–3 personas. Each has id, name, role, persona, optional memoryKey
goals:
primary: # Main target endings the LLM steers toward
secondary: # Valid but non-primary outcomes
sportsmanshipRules: # List of "do not allow" rules
skillChecks: # Named DCs e.g. chase_dc: 13
How a Session Works
1. DM runs /encounter start <spec>
2. Bot creates a Discord thread and posts the opening narrative
3. Players post what their character does
4. Bot appends the message to session history, calls Ollama
5. Ollama response is parsed:
- Narrative text → posted to the thread
- tool_call block → dispatched silently
6. Tool results (skill check embeds, Neo4j writes) happen automatically
7. When a goal is reached, LLM calls encounter_resolve
8. Bot posts the resolution embed and archives the thread
Skill checks
When the LLM determines a roll is warranted, it emits a skill_check_emit
tool call. The bot posts a Discord embed:
🎲 Skill Check — Aelindra
What skill are you using to chase Dal?
DC: 13
Reply with: "I rolled a [number] [Skill Name]"
The player replies naturally, e.g. "I rolled a 17 Acrobatics". The bot
detects this, logs the result, and feeds it back to the LLM as context.
Context Window Budget
The harness manages a strict token budget for the 128k context window:
| Zone | Budget |
|---|---|
| System prompt (narrator + NPCs + goals + tools) | 4,000 tokens |
| Pinned messages (opening narrative, never trimmed) | 2,000 tokens |
| Sliding history window | 118,000 tokens |
| Safety buffer | 3,500 tokens |
When history exceeds the sliding window budget, the oldest non-pinned turn pairs are dropped from the front. The opening narrative and goal block are always preserved.
NPC Memory
Named NPCs with a memoryKey in their spec have persistent memory in Neo4j.
At session start, their memory facts are loaded and injected into the system
prompt. At encounter resolution, any npc_memory_write tool calls are
committed to the graph.
This means a NPC like Miriam can remember that your party helped her in a previous encounter — or that you let the thief go.
Running Tests
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:
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
- Test spec loading:
npm run validate-spec your-encounter - Run
/encounter start your-encounterin 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.
# 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 decisionsdocs/mardonar-build-plan.md— phased build plan with packages and test guidance