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>
9.3 KiB
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:e2bpulled — or a LiteLLM proxy (preferred, setLITELLM_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 athttp://localhost:7474, loginneo4j/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 dependenciestests/integration/phase1.test.ts— requires running Redis + Neo4jtests/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
- Copy
specs/market-thief.yamltospecs/your-encounter.yaml - Fill in:
encounterId,title,tone,setting,openingNarrative,npcs[](with optionalmemoryKeyandnameKey),goals,sportsmanshipRules,skillChecks(group as<name>_dc / <name>_skill / <name>_notetriples), and thetools:list - 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 - In Discord:
/encounter start your-encounter
See specs/SPEC_FORMAT.md for the canonical reference.
Adding a new slash command
- Create
src/bot/commands/<name>.tsexportingdata(SlashCommandBuilder) andexecute(interaction, client) - Register it in
src/bot/index.ts(commands.set('<name>', ...)) - Add it to
src/scripts/deploy-commands.ts(commands.push(data.toJSON())) - Run
npm run deploy-commands
Adding a new LLM tool
- Create
src/harness/tools/<name>.tswith aToolPlugindefinition and callregisterTool(plugin)at the bottom - Import the file in
src/harness/tools/index.ts(side-effect import) - Reference it from any spec's
tools: [...]array to make it active - 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
- Create the handler in
src/bot/handlers/<name>.ts - Wire it from
src/bot/index.tsor another handler (e.g.messageRouter) - 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 (0–2) |
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
.jsextensions even for.tssource. - Shared types live only in
src/types/index.ts. Do not duplicate definitions elsewhere. - Tool plugins are self-registering — each
harness/tools/<name>.tscallsregisterTool()at load. Theindex.tsaggregator 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 ismessageRouter.ts. - In-world voice for player-facing strings — see
feedback-in-world-voicememory. No utility terms like "session", "user", "ephemeral" in bot messages. - All env access goes through
import { config }fromsrc/config.ts— never readprocess.envdirectly. - Tests use Vitest globals — no explicit
import { describe, it, expect }in test files.