Files
zalbot/docs/development-guide.md
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

9.3 KiB
Raw Blame History

Development Guide

How to set up, run, test, and develop the Mardonar Encounter Engine. Generated 2026-06-19.

Prerequisites

  • Node.js 22+ (matches the Dockerfile runtime)
  • Docker + Docker Compose (for local Redis and Neo4j)
  • Ollama running somewhere reachable, with gemma4-it:e2b pulled — or a LiteLLM proxy (preferred, set LITELLM_BASE_URL)
  • A Discord bot token and application ID with a registered bot user
  • npm 10+

First-time setup

git clone <your-repo>
cd mardonar-npcs
npm install
cp .env.example .env
# Edit .env — at minimum set DISCORD_TOKEN, DISCORD_CLIENT_ID

The .env file is validated by Zod (src/config.ts) at import time. A missing required var (e.g. DISCORD_TOKEN) will crash the bot on startup with a clear error.

Local services

docker compose -f docker-compose.dev.yml up -d

This starts:

  • Redis on localhost:6379
  • Neo4j on localhost:7687 (browser UI at http://localhost:7474, login neo4j / mardonardev)

The mardonar-internal Docker network is declared as external: true — it expects to be created by the GraphMCP-Example stack. If you run just the bot without GraphMCP, you can remove that network reference, but /encounter start will fail at NPC memory lookup.

Register slash commands

Run once per bot deployment, or whenever commands change:

npm run deploy-commands

If DISCORD_GUILD_ID is set, registers to that guild instantly. If unset, registers globally (up to 1h propagation delay). The deploy script also clears any lingering global commands first, to avoid double-registration.

Run the bot

npm run dev          # development: tsx watch mode (auto-reload)
npm run build        # compile TypeScript to dist/
npm run start        # run the compiled output

The bot logs to stdout (pino with pino-pretty in dev). Set LOG_LEVEL=debug for verbose output.

Testing

npm run test         # all tests
npm run test:unit    # unit only (no external services)
npm run test:int     # integration (requires docker compose up)

Test layout:

  • tests/unit/ — 21 fast unit tests with no external dependencies
  • tests/integration/phase1.test.ts — requires running Redis + Neo4j
  • tests/fixtures/spec.ts — shared spec fixture

Vitest is configured with v8 coverage. The vitest.config.ts includes src/**/*.ts for coverage and tests/**/*.test.ts for the test pattern.

Adding a new encounter

  1. Copy specs/market-thief.yaml to specs/your-encounter.yaml
  2. Fill in: encounterId, title, tone, setting, openingNarrative, npcs[] (with optional memoryKey and nameKey), goals, sportsmanshipRules, skillChecks (group as <name>_dc / <name>_skill / <name>_note triples), and the tools: list
  3. Add randomizable[] entries if you want parts of the spec (e.g. NPC names, item descriptions) to be filled from GraphMCP vocabulary at load time
  4. In Discord: /encounter start your-encounter

See specs/SPEC_FORMAT.md for the canonical reference.

Adding a new slash command

  1. Create src/bot/commands/<name>.ts exporting data (SlashCommandBuilder) and execute(interaction, client)
  2. Register it in src/bot/index.ts (commands.set('<name>', ...))
  3. Add it to src/scripts/deploy-commands.ts (commands.push(data.toJSON()))
  4. Run npm run deploy-commands

Adding a new LLM tool

  1. Create src/harness/tools/<name>.ts with a ToolPlugin definition and call registerTool(plugin) at the bottom
  2. Import the file in src/harness/tools/index.ts (side-effect import)
  3. Reference it from any spec's tools: [...] array to make it active
  4. Add a unit test in tests/unit/

The tool's args schema (string / number / boolean) is surfaced to the LLM via buildToolManifest, so the model sees typed arg descriptions in the system prompt. Use contextDocs(spec) to inject spec-specific guidance (e.g. preset DCs).

Adding a new event handler

  1. Create the handler in src/bot/handlers/<name>.ts
  2. Wire it from src/bot/index.ts or another handler (e.g. messageRouter)
  3. Prefer pure functions for transforms; reserve stateful modules for cross-call persistence

Environment configuration reference

Var Default Purpose
DISCORD_TOKEN (required) Bot user token
DISCORD_CLIENT_ID (required) Application ID
DISCORD_GUILD_ID unset If set, instant guild-scoped command registration
DISCORD_ALLOWED_CHANNELS empty → no channels Comma-separated channel IDs the bot will respond in
DISCORD_ALLOWED_USERS empty → all users Comma-separated user IDs allowed to run /encounter
REDIS_URL redis://localhost:6379 ioredis connection string
SESSION_TTL_HOURS 12 Session TTL in Redis
LITELLM_BASE_URL (recommended) LiteLLM proxy URL — preferred LLM client
LITELLM_API_KEY unset Optional API key for the proxy
LITELLM_MODEL falls back to OLLAMA_MODEL Model name as configured in LiteLLM
OLLAMA_BASE_URL http://localhost:11434 Ollama HTTP endpoint (fallback)
OLLAMA_MODEL gemma4-it:e2b Ollama model name
OLLAMA_TEMPERATURE 0.75 Sampling temperature (02)
OLLAMA_NUM_CTX 131072 Context window in tokens
OLLAMA_TIMEOUT_MS 120000 LLM call timeout
GRAPHMCP_URL http://localhost:9000 GraphMCP JSON-RPC endpoint
GRAPHMCP_SCORE_THRESHOLD 0.68 Min similarity for NPC memory chunks
GRAPHMCP_NPC_MEMORY_LIMIT 5 Max memory chunks per NPC
GRAPHMCP_MENTION_LIMIT 5 Max chunks for @mention search
GRAPHMCP_INGEST_STREAM raw.messages Redis stream name for encounter ingest
SPECS_DIR ./specs Encounter YAML directory
ENCOUNTER_ARCHIVE_DELAY_MS 5000 Delay before archiving resolved thread
ENCOUNTER_GATE_TIMEOUT_MS 30000 Player-gate embed auto-delete delay
PERSONA_PATH ./persona.yaml @mention persona YAML
DATA_DIR ./data Tally + summary directory
VTT_RELAY_URL https://vtt-relay.damascusfront.net Foundry VTT relay endpoint
VTT_API_KEY empty → VTT disabled API key for the relay
VTT_CLIENT_ID empty Client ID for the relay
VTT_FOUNDRY_URL empty Foundry URL for headless spin-up
VTT_USERNAME empty Foundry username
VTT_PASSWORD empty Foundry password (encrypted with RSA-OAEP for handoff)
VTT_WORLD empty Foundry world to launch
LOG_LEVEL info trace / debug / info / warn / error

Common tasks

View current encounter state

In Discord, in an encounter thread: /encounter status

List active encounters in the guild

/encounter list

Search past encounters

/encounters then use the modal

Force-end an encounter

/encounter end [notes]

Inspect the most recent encounter summary

/encounter audit (DMs the file) — or read data/summaries/ directly

Tail the bot log

With pino-pretty in dev, logs are pretty-printed to stdout. In prod, pipe container stdout to your log shipper.

Reset Redis state

docker compose -f docker-compose.dev.yml down -v
docker compose -f docker-compose.dev.yml up -d

Troubleshooting

Symptom Likely cause
ZodError at startup Missing or malformed env var. Check .env against .env.example.
DISCORD_ALLOWED_CHANNELS empty → bot never responds The bot refuses to respond outside allowed channels by design. Set the env var.
ECONNREFUSED to Redis docker compose -f docker-compose.dev.yml up -d not run, or wrong REDIS_URL.
ECONNREFUSED to GraphMCP GraphMCP-Example stack not running, or wrong GRAPHMCP_URL. Encounter start will fail at NPC memory fetch.
LLM never responds LiteLLM down → falls back to Ollama. Check OLLAMA_BASE_URL and that the model is pulled.
Tool call never fires LLM emitted a tool_call block but the tool name is misspelled or not in the spec's tools: list. Check toolParser warnings.
Skill check embed buttons do nothing PENDING_ROLL_LIMIT (5) reached; encounter auto-fails. Look for the [SKILL CHECK RESULT] ... auto-cancelled system message.
VTT integration silently skipped VTT_API_KEY empty. Set the var to enable.
Spec fails to load Run /encounter spec for the YAML. Schema is in src/spec/loader.ts.
High latency on LLM calls Likely under-sized OLLAMA_NUM_CTX vs. assembled context. Check CONTEXT_BUDGET in src/types/index.ts.

Project conventions

  • TypeScript strict mode, ESM modules, NodeNext resolution. All imports use .js extensions even for .ts source.
  • Shared types live only in src/types/index.ts. Do not duplicate definitions elsewhere.
  • Tool plugins are self-registering — each harness/tools/<name>.ts calls registerTool() at load. The index.ts aggregator imports them for side effects.
  • Discord embeds are pure builders — no I/O, no await. Pass typed args, return an embed.
  • Event handlers live in src/bot/handlers/. The runtime heart is messageRouter.ts.
  • In-world voice for player-facing strings — see feedback-in-world-voice memory. No utility terms like "session", "user", "ephemeral" in bot messages.
  • All env access goes through import { config } from src/config.ts — never read process.env directly.
  • Tests use Vitest globals — no explicit import { describe, it, expect } in test files.