Kaysser Kayyali e2c92e854f
Some checks failed
tests / Unit tests (Node 22) (push) Failing after 2m13s
Add unit tests for LLM clients, persona loader, and XP/Foundry rewards
Expands the unit test suite from 320 to 380 tests (+60) and adds a
Gitea Actions CI workflow. Closes all six follow-up recommendations
from the test-architecture validation report.

New tests (tests/unit/):
  - ollamaClient.test.ts          — Ollama SDK wrapper, options passthrough
  - litellmClient.test.ts         — OpenAI SDK wrapper, model fallback
  - personaLoader.test.ts         — Zod validation + cache invalidation
  - foundryReward.test.ts         — Tool plugin: lookup, errors, partial grants
  - xpAwarder.test.ts             — Bulk XP awards + per-player skip reasons
  - redisErrorPath.test.ts        — Singleton error handler does not crash
  - messageRouterRunLLMTurn.test.ts — 18 cases for the runtime heart:
    narrative-only path, tool dispatch, filter correction, retry loop
    guard, missed-skill-check heuristic, typing indicator interval,
    LLM error fallback, archive on resolve.

Coverage (line %):
  - harness/litellmClient.ts      0 → 100
  - harness/ollamaClient.ts       0 → 100
  - harness/tools/foundryReward.ts 0 → 100
  - session/xpAwarder.ts          0 → 100
  - persona/loader.ts             0 → 100
  - db/redis.ts                   0 → 100
  - bot/handlers/messageRouter.ts 0 → 39.86 (runLLMTurn now covered)

Tooling:
  - package.json: + test:coverage, test:watch scripts
  - devDep: @vitest/coverage-v8@^3.1.0
  - tests/README.md: conventions, anti-patterns, template map
  - .gitignore: exclude coverage/
  - .gitea/workflows/test.yml: Node 22, npm cache, tsc --noEmit gate

Documentation (from earlier /bmad-document-project run, now committed):
  - docs/index.md
  - docs/project-overview.md
  - docs/architecture.md
  - docs/deployment-guide.md
  - docs/api-contracts.md
  - docs/data-models.md
  - docs/source-tree-analysis.md
  - docs/component-inventory.md
  - docs/development-guide.md
  - _bmad-output/test-artifacts/automate-validation-report.md

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-19 05:59:13 +00:00
2026-06-19 04:50:13 +00:00
2026-06-19 04:50:13 +00:00
2026-06-19 04:50:13 +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 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

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:               # 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

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

  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.

# 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
Description
No description provided
Readme 1.2 MiB
Languages
TypeScript 99.5%
JavaScript 0.3%
Dockerfile 0.2%