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>
6.7 KiB
Data Models
Persistent and transient data shapes in the Mardonar Encounter Engine. Generated 2026-06-19.
The bot's data lives in three places: Redis (transient session state), the filesystem (data/, runtime artifacts), and the GraphMCP-backed Neo4j graph (long-term NPC memory + encounter history). The bot does not query Neo4j directly — it goes through the GraphMCP JSON-RPC client.
Encounter spec (YAML → Zod → TypeScript)
Defined by EncounterSpecSchema in src/spec/loader.ts. Loaded by /encounter start <spec-name>. Stored in SessionState.spec.
{
encounterId: string, // unique ID — also Neo4j node key
title: string, // display name in Discord embeds
tone?: string, // "tense" | "comedic" | ... optional flavor block
setting: {
location: string,
mood: string, // multi-line OK
ambientNpcs: string, // multi-line OK
},
openingNarrative: string, // multi-line; can reference {{nameKey}} placeholders
npcs: [{ // 1–5 entries
id: string, // unique stable ID
name: string,
nameKey?: string, // placeholder for randomizable substitution
role: string,
persona: string, // multi-line
memoryKey?: string, // if set, memory is loaded from / written to graph
}],
goals: {
hidden: boolean, // default true
primary: [{ id: string, label: string }], // min 1
secondary: [{ id: string, label: string }],
},
sportsmanshipRules: string[],
skillChecks: Record<string, number | string>, // grouped as <name>_dc / <name>_skill / <name>_note
randomizable?: [{ // optional
key: string,
source?: 'graphmcp' | 'vocabulary',
category?: string, // e.g. "names.dwarf.female"
query: string, // free-text query
fallback: string, // always available
}],
dmNotes?: string,
tools?: string[], // active tool plugin names; empty/undefined = all
}
tone and tools are read by the harness but not in the Zod schema (see architecture.md §9 for the schema-vs-types drift).
SessionState (Redis)
Stored as JSON under key session:{threadId}. Schema in src/types/index.ts:
{
encounterId: string,
threadId: string, // Discord thread snowflake
guildId: string,
spec: EncounterSpec,
players: Record<discordId, Player>,
history: ChatMessage[], // pinned + sliding mix
phase: 'open' | 'active' | 'resolved',
heldMessages: HeldMessage[], // for unregistered players
outcome?: string, // goal ID when resolved
outcomeSummary?: string,
npcMemories?: Record<npcId, string>, // injected into system prompt
resolvedContext?: Record<key, string>, // canonical session facts (context_recall)
pendingSkillCheck?: {
player: string,
prompt: string,
dc: number,
messageId: string, // Discord message ID of the dice embed
modifier?: number,
skill?: string,
advantage?: boolean,
disadvantage?: boolean,
},
pendingSkillCheckAttempts?: number,
createdAt: number,
updatedAt: number,
}
ChatMessage
{
role: 'system' | 'user' | 'assistant',
content: string,
pinned?: boolean, // never trimmed by contextAssembler
timestamp: number,
}
System messages are emitted by the harness for tool results, filter corrections, and join events. Assistant messages contain the LLM's narrative.
Player
{
discordId: string,
dndName: string,
pronouns?: string, // populated from characterRegistry if set
}
pronouns is added on first appearance in an encounter thread if the player has a characterRegistry profile.
Character profile (characterRegistry)
{
discordId: string,
guildId: string,
dndName: string,
pronouns?: string,
characterClass?: string,
race?: string,
level?: number,
backstory?: string,
foundryActorUuid?: string, // link to Foundry VTT actor
inventory?: unknown[], // populated from /character view
spells?: unknown[], // populated from /character view
// ... additional Foundry-derived fields
}
Neo4j graph (via GraphMCP)
The bot does not directly define the Neo4j schema — it consumes whatever GraphMCP returns. The conceptual model based on the GraphMCP client types and the legacy design doc:
(:NPC {id, name, persona_summary, memory: [], last_seen_encounter})
-[:APPEARED_IN]->
(:Encounter {id, title, resolved, outcome_id, created_at})
-[:HAS_EVENT]->
(:EncounterEvent {timestamp, type, description})
-[:FEATURED]->
(:Entity {name, kind})
(:Player {discord_id, dnd_name})
-[:PARTICIPATED_IN]->
(:Encounter)
The bot writes to the graph via log_encounter (one encounter node + participants). It reads NPC memory via query_as_npc and the broader corpus via semantic_search.
File system (data/)
data/
├── tally.json // { [specName]: { runs: number, lastRun: ISO8601 } }
└── summaries/
└── {encounterId}-{ISO8601-with-dashes}.txt
// human-readable per-encounter summary
// header: Encounter, ID, Thread, Date, Outcome, Players
// body: free-text Summary
tally.json is rewritten atomically on each encounter start. Summary files are append-only.
Tool call payloads
// What the LLM emits
type ToolCallBlock = {
tool: ToolName,
args: Record<string, unknown>,
}
// What the harness parses back from the LLM response
type LLMResponse = {
narrative: string,
toolCall?: ToolCallBlock,
rawTokensUsed?: number,
}
Tool names (src/types/index.ts):
type ToolName =
| 'skill_check_emit'
| 'skill_check_resolve' // (defined in types but no longer registered — see architecture.md §9)
| 'event_log_append' // (defined in types but no longer registered)
| 'npc_memory_read' // (defined in types but no longer registered)
| 'npc_memory_write' // (defined in types but no longer registered)
| 'encounter_resolve'
| 'goal_register'
| 'context_recall'
| 'foundry_lookup'
| 'foundry_reward';
The four *_resolve / *_read / *_write entries are dead in the current implementation — replaced by GraphMCP log_encounter and other RPC calls. They should be removed from the type union (or actually re-implemented) to avoid confusion.
Context budget (compile-time const)
src/types/index.ts:
export const CONTEXT_BUDGET = {
SYSTEM: 4_000,
PINNED: 2_000,
HISTORY: 118_000,
SAFETY: 3_500,
TOTAL: 128_000,
} as const;
Used by contextAssembler and sessionManager to enforce the trimming policy.