Kaysser Kayyali 37a1a3d421 feat(specs): velvet-auction exercises group tools; FU-9 playtest gate; docs drift fix
FU-12 — velvet-auction.yaml now uses the group-encounter tools:
- minPlayers: 3 (lobby-gated party heist, matches PRD UJ-1)
- passiveReveals: Insight/15 (notices Karr's tell — Feature B)
- group_stealth skillChecks entry (group Stealth, successRule: majority,
  durationSeconds: 60) + skill_check_group_emit and character_status added
  to the tools list.
- specsToolsConsistency: emptied the NOT_YET_REFERENCED allowlist
  (skill_check_group_emit + character_status are now referenced); all 8
  registered tools are reachable from specs. Validated: specLoader +
  specsToolsConsistency + full unit suite (527) pass.

FU-9 — docs/release-playtest-checklist.md: the 7-step manual pre-release
  multi-player playtest checklist checked into the repo as a release gate
  (was buried only in the arch doc). Includes pass criteria (no orphaned
  thread / lost roll / raw-JSON leak) + the NFR-3/NFR-4 latency checklist.

docs/project-overview.md drift fix: pino -> src/lib/logger.ts (custom
  plaintext, ADR-002); primary LLM -> minimax-m3 via LiteLLM
  (LITELLM_MODEL); test count 22 -> 58; lib/ description; relabel dynamic
  goal registration as delivered.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 16:02:00 +00:00
2026-06-20 06:52:19 +00:00
2026-06-20 06:52:19 +00:00
2026-06-20 00:32:18 +00:00
2026-06-20 00:32:18 +00:00

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:e2b pulled
  • 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:               # 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, 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

  1. Copy specs/market-thief.yaml to specs/your-encounter.yaml
  2. Fill in all required fields (see EncounterSpec in src/spec/loader.ts)
  3. Run npm run build to confirm the spec passes Zod validation
  4. Run /encounter start your-encounter in Discord
  5. 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 log
  • docs/development-guide.md — local development + test conventions
  • docs/deployment-guide.md — Docker, env vars, deploy-commands flow
  • docs/api-contracts.md — slash command and tool interface contracts

Note: Docs/mardonar-encounter-engine.md and Docs/mardonar-build-plan.md (capital D) 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.

Description
No description provided
Readme 1.2 MiB
Languages
TypeScript 99.5%
JavaScript 0.3%
Dockerfile 0.2%