Files
zalbot/docs/data-models.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

6.7 KiB
Raw Blame History

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: [{                        // 15 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.