Files
zalbot/docs/development-guide.md
Kaysser Kayyali fbd991a2b0
Some checks failed
tests / Unit tests (Node 22) (push) Failing after 28s
feat: docs pass, test fixes, advanced review
2026-06-19 16:15:06 +00:00

9.4 KiB
Raw Permalink 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 via a small custom logger (src/lib/logger.ts): plaintext [tag] message key=value lines. There is no LOG_LEVEL env knob — callers pick the level per call (log.info / log.warn / log.error / log.debug).

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

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

The custom logger writes plaintext [tag] message key=value lines 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.