commit 9dc6e8e1a3eb8186ab3167b9215bf3cdeb1d2012 Author: Kaysser Kayyali Date: Sat May 30 04:51:21 2026 +0000 Initial commit β€” Mardonar encounter engine with UX improvements Includes full bot source (Phases 1–4), plus five new features: - Epic 1: emoji reaction state machine (πŸ‘€β³βœ…πŸŽ²) + burst queue cap at 2 with in-world drop notices - Epic 2: per-encounter tone field in YAML injected into LLM system prompt - Epic 3: player pronouns via modal registration + system prompt players block - Epic 4: strengthened skill_check_emit tool contract + missed-skill-check diagnostic Also includes UX design docs, epics, and story files under Docs/. Co-Authored-By: Claude Sonnet 4.6 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bf8b9cd --- /dev/null +++ b/.env.example @@ -0,0 +1,69 @@ +DISCORD_TOKEN=your_discord_bot_token +DISCORD_CLIENT_ID=your_discord_application_id +# DISCORD_GUILD_ID=your_server_id # Set for instant command registration (vs 1hr global propagation) + +# ── Redis ────────────────────────────────────────────────────────────────────── +# Shared with GraphMCP-Example stack via knowledge-graph-ai_internal network +REDIS_URL=redis://redis:6379 + +# How long a session stays alive in Redis without activity (hours) +# SESSION_TTL_HOURS=12 + +# ── Ollama / LLM ─────────────────────────────────────────────────────────────── +OLLAMA_BASE_URL=http://192.168.1.x:11434 +OLLAMA_MODEL=gemma4-it:e2b + +# Sampling temperature β€” higher = more creative, lower = more consistent (0–2) +# OLLAMA_TEMPERATURE=0.75 + +# Context window in tokens β€” must match your model's actual max +# OLLAMA_NUM_CTX=131072 + +# LLM call timeout in milliseconds +# OLLAMA_TIMEOUT_MS=120000 + +# ── GraphMCP ─────────────────────────────────────────────────────────────────── +GRAPHMCP_URL=http://mcp-server:9000 + +# Minimum semantic similarity score (0–1) to count a chunk as relevant context. +# Raise to be stricter (fewer but more accurate results). +# Lower to be more permissive (more context, more noise). +# GRAPHMCP_SCORE_THRESHOLD=0.68 + +# How many memory chunks to fetch per NPC at encounter start +# GRAPHMCP_NPC_MEMORY_LIMIT=5 + +# How many chunks to fetch when @Zalram is mentioned +# GRAPHMCP_MENTION_LIMIT=5 + +# ── Encounter behaviour ──────────────────────────────────────────────────────── +SPECS_DIR=./specs + +# Delay before archiving a resolved thread β€” gives players time to read the ending (ms) +# ENCOUNTER_ARCHIVE_DELAY_MS=5000 + +# How long the player-gate embed stays visible before auto-delete (ms) +# ENCOUNTER_GATE_TIMEOUT_MS=30000 + +# ── Persona ──────────────────────────────────────────────────────────────────── +# Path to the YAML file defining the bot's @mention persona +# PERSONA_PATH=./persona.yaml + +# ── Access control ───────────────────────────────────────────────────────────── +# Comma-separated channel IDs where encounters are allowed. +# The bot checks the parent channel of threads before responding. +# Leave empty and the bot responds nowhere (secure by default). +# DISCORD_ALLOWED_CHANNELS=123456789012345678,987654321098765432 +DISCORD_ALLOWED_CHANNELS= + +# Comma-separated user IDs who can use /encounter commands. Empty = everyone. +# DISCORD_ALLOWED_USERS=123456789012345678,987654321098765432 +DISCORD_ALLOWED_USERS= + +# ── Logging ──────────────────────────────────────────────────────────────────── +LOG_LEVEL=debug + + +LITELLM_BASE_URL= +LITELLM_API_KEY= +LITELLM_MODEL=ollama-cloud \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4274b51 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +*.log +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ef8865b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +FROM node:22-alpine AS builder + +RUN apk upgrade --no-cache + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --ignore-scripts + +COPY tsconfig.json ./ +COPY src ./src + +RUN npm run build + +# ── Runtime image ────────────────────────────────────────────── +FROM node:22-alpine + +RUN apk upgrade --no-cache + +WORKDIR /app + +ENV NODE_ENV=production + +COPY package*.json ./ +RUN npm ci --omit=dev --ignore-scripts + +COPY --from=builder /app/dist ./dist +COPY specs ./specs +COPY lore ./lore +COPY persona.yaml ./persona.yaml + +CMD ["node", "dist/bot/index.js"] diff --git a/Docs/epics.md b/Docs/epics.md new file mode 100644 index 0000000..82f41b4 --- /dev/null +++ b/Docs/epics.md @@ -0,0 +1,288 @@ +--- +stepsCompleted: ["step-01-validate-prerequisites"] +inputDocuments: + - prd.md + - Docs/mardonar-encounter-engine.md + - Docs/mardonar-build-plan.md + - Docs/ux-designs/ux-mardonar-2026-05-30/DESIGN.md + - Docs/ux-designs/ux-mardonar-2026-05-30/EXPERIENCE.md +--- + +# Mardonar Encounter Engine - Epic Breakdown + +## Overview + +This document provides the complete epic and story breakdown for the Mardonar Encounter Engine, covering new features and bug fixes layered on top of the existing engine (Phases 1–4 are substantially complete in the codebase as of 2026-05-30). + +## Requirements Inventory + +### Functional Requirements + +FR1: The bot must add an emoji reaction to a player's message immediately upon receipt to signal it was seen (πŸ‘€). +FR2: When a player message enters the LLM processing queue, the πŸ‘€ reaction must be replaced with ⏳. +FR3: When a dice roll is detected in a queued player message, a 🎲 reaction must be added alongside ⏳ for the duration of processing. +FR4: When the LLM response is posted, ⏳ (and 🎲 if present) must be removed and replaced with βœ… for ~10 seconds, then removed. +FR5: The message queue must enforce a hard cap of 2 messages per burst. Messages beyond the cap must not be queued. +FR6: When a player's message is dropped by the queue cap, the bot must send an ephemeral reply to that player with an in-world message. +FR7: The ephemeral drop notice must be pre-generated (not LLM-generated at runtime) and selected based on the encounter's active tone value. +FR8: Each encounter YAML spec must support an optional `tone` field (free-text string, e.g. "grim", "tense", "comedic", "mysterious") that drives narration flavor for that encounter. +FR9: The `tone` value must be injected into the LLM system prompt as a style directive for all narration generated during the encounter. +FR10: The `tone` value must be cached in session state at session start so it is available synchronously when the drop notice fires. +FR11: Players must be able to set pronouns as part of their character profile via the existing `/character register custom` command. +FR12: The player's pronouns must be injected into the LLM system prompt so narration uses the correct pronouns when referring to that player's character. +FR13: When the LLM detects a player intends to make a skill check (rolling dice), it must reliably call `skill_check_emit` rather than narrating the outcome itself. +FR14: The system prompt's tool contract must be strengthened with explicit negative instructions and/or few-shot examples to prevent the LLM from self-narrating roll outcomes. + +### NonFunctional Requirements + +NFR1: Emoji reactions must be added within 200ms of the message being received, before any LLM call is initiated. +NFR2: Ephemeral drop notices must be sent synchronously within the message-receive path β€” no LLM call must be made for a dropped message. +NFR3: The `tone` value must be accessible synchronously (from session state in Redis) when the drop notice fires β€” no extra async lookup at that moment. +NFR4: The reaction state machine must never leave a message in a stale state (e.g. ⏳ stuck after a failed LLM call) β€” cleanup must happen in the finally block of the generation runner. +NFR5: The queue cap must reset cleanly after each LLM response completes, so the next burst starts with a fresh cap. +NFR6: All player-facing strings β€” including drop notices and system confirmations β€” must use in-world language. The words "bot", "system", "queue", "rate limit", or "error" must never appear in player-visible output. + +### Additional Requirements + +- Stack: TypeScript / Node.js / discord.js v14. Phases 1–4 are complete. All new work layers on top of the existing engine. +- The existing `generationQueue.ts` debounce logic (500ms, drain turn) must be preserved. The cap and reactions are additive changes to this file. +- The `EncounterSpec` Zod schema in `src/spec/loader.ts` and `EncounterSpec` interface in `src/types/index.ts` must both be updated to include `tone?: string`. +- The `tone` value must be stored on `SessionState` so it survives across message turns without a spec re-read. +- Pronouns must be stored on the existing `CharacterProfile` type in `src/session/characterRegistry.ts` (or equivalent) and surfaced via `characterRegistry.get()`. +- The skill check reliability fix is a prompt engineering change in `src/harness/promptBuilder.ts` and potentially a post-dispatch validation step in `src/harness/toolDispatcher.ts`. + +### UX Design Requirements + +UX-DR1: Reactions are applied to player messages only β€” never to bot messages. +UX-DR2: The reaction state machine is: received (πŸ‘€) β†’ processing (⏳, optionally +🎲 if dice detected) β†’ done (βœ… for ~10s then cleared). Reactions must self-clear β€” no permanent reaction markers on messages. +UX-DR3: The ephemeral drop notice is a plain text ephemeral reply (not an embed). Tone-keyed strings: grim β†’ "The chaos swallowed your words…"; comedic β†’ "Everyone was talking at once…"; mysterious β†’ "Something muffled your voice…"; tense β†’ "No time β€” the moment moved on without you…"; baseline β†’ "The echoes could not carry all voices at once…". +UX-DR4: The `tone` field in encounter YAML is free-text (no enum enforcement). Unrecognised tone values fall back to baseline drop notice string. +UX-DR5: Pronouns are added as an optional field on `/character register custom`. Default is "they/them" when unset. +UX-DR6: The `🎲` reaction must only be added when the bot's dice-detection heuristic is confident (e.g. the message contains a roll-related keyword or the pending skill check flag is set). + +### FR Coverage Map + +| FR | Epic | Story | +|---|---|---| +| FR1–FR5 | Epic 1 | 1.1 | +| FR6–FR7 | Epic 1 | 1.2 | +| FR8–FR10 | Epic 2 | 2.1 | +| FR11–FR12 | Epic 3 | 3.1 | +| FR13–FR14 | Epic 4 | 4.1 | +| NFR1–NFR6 | Cross-cutting | All stories | +| UX-DR1–UX-DR6 | Cross-cutting | All stories | + +## Epic List + +1. Epic 1: Interaction Feedback & Queue Management +2. Epic 2: Encounter Tone Configuration +3. Epic 3: Player Pronouns +4. Epic 4: Dice Roll Reliability Fix + +--- + +## Epic 1: Interaction Feedback & Queue Management + +Give players real-time visual feedback on the state of their messages (received, queued, processing, done, dropped) and enforce a hard queue cap of 2 with in-world ephemeral notices for dropped messages. + +### Story 1.1: Emoji Reaction State Machine + +As a player, +I want emoji reactions on my messages to show whether they were received and are being processed, +So that I always know the bot saw my message and what it is doing with it. + +**Acceptance Criteria:** + +**Given** a player sends a message in an active encounter thread +**When** the message is received by the bot's message handler +**Then** the bot adds a πŸ‘€ reaction to that message within 200ms, before any LLM call is initiated + +**Given** the player's message enters the LLM processing queue (within the 2-message cap) +**When** the LLM generation begins (inside the `fire()` call in `generationQueue.ts`) +**Then** the πŸ‘€ reaction is removed and ⏳ is added to the message + +**Given** the player's message contains a roll-related keyword or the session has a pending skill check +**When** the message enters the processing queue +**Then** 🎲 is added alongside ⏳ for the duration of processing + +**Given** the LLM response has been posted to the thread +**When** the generation runner's try block completes +**Then** ⏳ (and 🎲 if present) are removed, βœ… is added to the message, and βœ… is removed ~10 seconds later + +**Given** the LLM call fails or throws an error +**When** the generation runner's finally block executes +**Then** all in-progress reactions (⏳, 🎲) are removed from the message so no stale state is left + +**Notes:** +- Reactions are applied only to player messages. Bot messages are never reacted to by the bot. +- The message ID must be threaded through to the generation runner so reactions can be managed. Pass it as part of the runner closure or as a parameter to `scheduleLLMTurn`. +- Discord reaction calls (`message.react()`, `reaction.remove()`) should be fire-and-forget with logged errors β€” they must never cause the message handler to throw or block. + +--- + +### Story 1.2: Queue Cap with In-World Drop Notice + +As a player, +I want to be told privately when my message wasn't processed due to message volume, +So that I know to wait and try again rather than wondering if the bot is broken. + +**Acceptance Criteria:** + +**Given** a player's message arrives while 2 messages are already pending in the queue +**When** the queue cap check runs (in `scheduleLLMTurn` or the message handler before enqueuing) +**Then** the message is NOT added to the queue and the bot does NOT call the LLM for it + +**Given** the player's message was dropped by the cap +**When** the drop is detected +**Then** the bot sends an ephemeral reply to that player's message with the in-world drop notice for the session's active tone + +**Given** the session's `tone` value is "grim" +**When** a drop notice is sent +**Then** the text reads: *"The chaos swallowed your words before they could reach the moment. Silence yourself until the echoes clear."* + +**Given** the session's `tone` value is "comedic" +**When** a drop notice is sent +**Then** the text reads: *"Everyone was talking at once and the universe, frankly, wasn't listening. Give it a moment."* + +**Given** the session's `tone` value is "mysterious" +**When** a drop notice is sent +**Then** the text reads: *"Something in the fabric of this place muffled your voice. Wait. It will pass."* + +**Given** the session's `tone` value is "tense" +**When** a drop notice is sent +**Then** the text reads: *"No time β€” the moment moved on without you. Hold. Wait for your opening."* + +**Given** the session's `tone` value is absent, unknown, or any other string +**When** a drop notice is sent +**Then** the text reads: *"The echoes of the encounter could not carry all voices at once. Wait for the dust to settle before speaking again."* + +**Given** the LLM response has posted and the queue drains +**When** the `pendingCount` resets in the `finally` block +**Then** the cap counter also resets so the next burst starts fresh with a 0 cap count + +**Notes:** +- The drop notice strings are hardcoded constants β€” the LLM is never called to generate them. This avoids a catch-22 where a rate-limited system tries to invoke the LLM for its own rate-limit message. +- `tone` must be read from `SessionState` (not from the spec directly) so it is available synchronously without an extra Redis lookup at the drop moment. +- The ephemeral reply uses `message.reply({ content: noticeText, flags: MessageFlags.Ephemeral })` β€” no embed needed. +- The πŸ‘€ reaction added on message receipt must still be removed for dropped messages (add then immediately remove, no further reactions). + +--- + +## Epic 2: Encounter Tone Configuration + +Allow each encounter YAML spec to declare a tone that drives narration flavor and drop notice phrasing throughout the encounter. + +### Story 2.1: Tone Field in Spec and Session + +As a DM, +I want to set a `tone` field in my encounter YAML that controls how the bot sounds during that encounter, +So that a tense pursuit feels different from a comedic heist without touching code. + +**Acceptance Criteria:** + +**Given** an encounter YAML spec file +**When** it includes a `tone: "tense"` (or any string) field at the top level +**Then** the Zod schema in `src/spec/loader.ts` parses it without error as `tone?: string` + +**Given** an encounter YAML spec without a `tone` field +**When** the spec is loaded +**Then** `spec.tone` is `undefined` and the system falls back to baseline behavior throughout + +**Given** a session is initialized via `/encounter start` +**When** `SessionState` is created and saved to Redis +**Then** `session.tone` is populated from `spec.tone` (or `undefined` if absent) and persists for the session lifetime + +**Given** the LLM system prompt is assembled by `src/harness/promptBuilder.ts` +**When** `session.tone` is defined +**Then** the system prompt includes a `` block immediately after `` with text: `Your narration style for this encounter is: {tone}. Let this flavor all of your responses, NPC voices, and pacing.` + +**Given** the LLM system prompt is assembled +**When** `session.tone` is `undefined` +**Then** no `` block is added (baseline Gemma narration applies) + +**Notes:** +- `EncounterSpec` interface in `src/types/index.ts` needs `tone?: string` added. +- `SessionState` in `src/types/index.ts` needs `tone?: string` added. +- `buildSystemPrompt` in `src/harness/promptBuilder.ts` receives the session (or spec) and includes the tone block when present. +- No enum constraint on tone values β€” any free-text string is valid. Prompt injection uses the string verbatim. + +--- + +## Epic 3: Player Pronouns + +Allow players to register their preferred pronouns alongside their character name so the LLM uses them correctly during narration. + +### Story 3.1: Pronouns in Character Registry and Narration + +As a player, +I want to register my character's pronouns so the bot refers to my character correctly during encounters, +So that my character exists in the world on my terms. + +**Acceptance Criteria:** + +**Given** a player uses `/character register custom` +**When** they include the optional `pronouns` string option (e.g. `pronouns:"she/her"`) +**Then** the pronouns value is saved to their `CharacterProfile` in Redis alongside their name + +**Given** a player uses `/character register custom` without specifying pronouns +**When** their profile is saved +**Then** `profile.pronouns` is `undefined` (no default is written; the prompt builder will use "they/them" as its fallback) + +**Given** a player's `CharacterProfile` has `pronouns` set +**When** the LLM system prompt is assembled for an encounter they are participating in +**Then** the per-player entry in the system prompt includes: `Pronouns: {pronouns}` (or `they/them` if unset) + +**Given** the system prompt includes a player's pronouns +**When** the LLM narrates an action by that player's character +**Then** the LLM uses the specified pronouns in narration (this is verified via the integration test fixture, not a live LLM assertion) + +**Given** a player runs `/character show` +**When** their profile has pronouns set +**Then** the show embed includes a "Pronouns" field displaying the registered value + +**Notes:** +- `CharacterProfile` type in `src/session/characterRegistry.ts` (or `src/types/index.ts`) needs `pronouns?: string` added. +- The Zod schema for CharacterProfile (if it exists) needs the field added. +- `src/harness/promptBuilder.ts`: in the player/character section of the system prompt, add `Pronouns: ${profile.pronouns ?? 'they/them'}` after the character name line. +- The `/character register custom` command's `SlashCommandBuilder` needs a new optional `pronouns` string option. +- No format enforcement on the pronouns string β€” accept free text (e.g. "she/her", "he/him", "they/them", "xe/xir"). + +--- + +## Epic 4: Dice Roll Reliability Fix + +Fix the bug where the LLM narrates a skill check outcome itself instead of calling `skill_check_emit`, causing players to have to re-prompt for their dice roll. + +### Story 4.1: Strengthen Skill Check Tool Contract + +As a player, +I want the bot to always post the dice roll embed when a skill check is needed, +So that I never have to ask again after already declaring my action. + +**Acceptance Criteria:** + +**Given** a player message that implies a skill check (e.g. "I try to pick the lock", "Aelindra chases Dal", "I attempt to persuade the guard") +**When** the LLM processes the message +**Then** the LLM must output a `skill_check_emit` tool call rather than narrating the dice outcome itself + +**Given** the LLM's response is parsed by `toolParser.ts` +**When** the response contains no `tool_call` block but the session has no `pendingSkillCheck` and the narrative text contains a self-resolved roll outcome (heuristic: phrases like "you succeed", "you fail", "the check succeeds", "rolling a") +**Then** the tool dispatcher logs a warning that a possible missed skill check was detected (for debugging; no user-facing change at this stage) + +**Given** the system prompt in `src/harness/promptBuilder.ts` +**When** the `` block is rendered +**Then** it includes an explicit negative instruction: `NEVER narrate the outcome of a skill check. ALWAYS emit skill_check_emit and wait for the player's roll result before continuing the narrative.` + +**Given** the system prompt's `` block +**When** rendered +**Then** it includes at least one few-shot example demonstrating: player declares action β†’ LLM emits `skill_check_emit` β†’ `[SYSTEM]` roll result arrives β†’ LLM narrates outcome + +**Given** a few-shot example is added to the tool contract +**When** the example demonstrates the correct flow +**Then** the example uses the exact `tool_call` block format already defined in the contract (no new format introduced) + +**Notes:** +- The root cause is likely that the model (gemma4-it:e2b) at e2b quantization is inconsistent about tool calls when the narrative "obviously" implies a result. Stronger negative instructions and a few-shot example are the correct lever without resorting to post-processing hacks. +- The heuristic detection in `toolDispatcher.ts` (see AC 2) is diagnostic only β€” it logs to the existing pino logger under `log.warn('harness', 'possible missed skill_check_emit', ...)`. It does not retry or alter the response. +- If the few-shot example is long, it can be extracted to a constant in `promptBuilder.ts` to keep the function readable. +- Integration test in `tests/unit/promptBuilder.test.ts`: assert that the built system prompt contains the negative instruction string and the phrase `skill_check_emit` in the tool contract section. diff --git a/Docs/mardonar-build-plan.md b/Docs/mardonar-build-plan.md new file mode 100644 index 0000000..49093c9 --- /dev/null +++ b/Docs/mardonar-build-plan.md @@ -0,0 +1,897 @@ +# Mardonar Encounter Engine β€” Phased Build Plan + +> **Stack:** TypeScript / Node.js +> **Runtime:** tsx (dev) β†’ compiled JS (prod) +> **Test runner:** Vitest +> **Target model:** gemma4-it:e2b via Ollama + +--- + +## Repository Structure + +``` +mardonar-bot/ +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ bot/ # discord.js bot, command handlers, embed builders +β”‚ β”œβ”€β”€ session/ # session manager, player registry +β”‚ β”œβ”€β”€ harness/ # context assembler, ollama client, tool dispatcher +β”‚ β”œβ”€β”€ mcp/ # MCP server + individual tool definitions +β”‚ β”œβ”€β”€ db/ # Redis and Neo4j client singletons +β”‚ β”œβ”€β”€ spec/ # EncounterSpec loader + Zod schema +β”‚ └── types/ # shared TypeScript types and interfaces +β”œβ”€β”€ specs/ # encounter YAML files (hand-authored) +β”œβ”€β”€ tests/ +β”‚ β”œβ”€β”€ unit/ +β”‚ β”œβ”€β”€ integration/ +β”‚ └── fixtures/ # mock specs, mock LLM responses +β”œβ”€β”€ package.json +β”œβ”€β”€ tsconfig.json +└── vitest.config.ts +``` + +--- + +## Shared Types (define first, referenced by all phases) + +```typescript +// src/types/index.ts + +export interface Player { + discordId: string; + dndName: string; +} + +export interface NpcPersona { + id: string; + name: string; + role: string; + persona: string; + memoryKey?: string; +} + +export interface EncounterGoal { + id: string; + label: string; +} + +export interface EncounterSpec { + encounterId: string; + title: string; + setting: { location: string; mood: string; ambientNpcs: string }; + openingNarrative: string; + npcs: NpcPersona[]; + goals: { primary: EncounterGoal[]; secondary: EncounterGoal[] }; + sportsmanshipRules: string[]; + skillChecks: Record; +} + +export interface SessionState { + encounterId: string; + threadId: string; + guildId: string; + spec: EncounterSpec; + players: Record; // discordId β†’ Player + history: ChatMessage[]; + phase: 'open' | 'active' | 'resolved'; + heldMessages: HeldMessage[]; + outcome?: string; + createdAt: number; +} + +export interface ChatMessage { + role: 'system' | 'user' | 'assistant'; + content: string; + pinned?: boolean; // pinned messages survive history trimming + timestamp: number; +} + +export interface HeldMessage { + discordUserId: string; + content: string; + timestamp: number; +} + +export type ToolCallBlock = { + tool: string; + args: Record; +}; +``` + +--- + +## Phase 1 β€” Foundation + +**Goal:** Discord bot running, slash commands working, player registry operational, encounter thread creation working. No LLM involved yet. + +### Packages + +| Package | Version | Purpose | +|---|---|---| +| `discord.js` | `^14.18` | Bot framework, slash commands, thread management, embeds | +| `@discordjs/builders` | `^1.10` | Slash command builders | +| `@discordjs/rest` | `^2.4` | Command registration via Discord REST API | +| `ioredis` | `^5.4` | Redis client (player registry + session store) | +| `js-yaml` | `^4.1` | YAML spec loader | +| `zod` | `^3.24` | Runtime schema validation for specs and env vars | +| `dotenv` | `^16.4` | `.env` loading | +| `pino` | `^9.6` | Structured logging | +| `pino-pretty` | `^13.0` | Dev-mode log formatting | +| `tsx` | `^4.19` | TypeScript execution without build step | +| `typescript` | `^5.8` | TypeScript compiler | +| `vitest` | `^3.1` | Test runner | +| `ioredis-mock` | `^8.9` | In-memory Redis for unit tests | + +### Implementation Steps + +**1. Project bootstrap** +```bash +mkdir mardonar-bot && cd mardonar-bot +npm init -y +npm install discord.js @discordjs/builders @discordjs/rest ioredis js-yaml zod dotenv pino pino-pretty +npm install -D typescript tsx vitest ioredis-mock @types/node @types/js-yaml +npx tsc --init +``` + +Key `tsconfig.json` settings: +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "outDir": "dist", + "rootDir": "src" + } +} +``` + +**2. Environment schema** β€” validate env at startup, fail fast +```typescript +// src/config.ts +import { z } from 'zod'; +import 'dotenv/config'; + +const EnvSchema = z.object({ + DISCORD_TOKEN: z.string(), + DISCORD_CLIENT_ID: z.string(), + REDIS_URL: z.string().default('redis://localhost:6379'), + OLLAMA_BASE_URL: z.string().default('http://localhost:11434'), + OLLAMA_MODEL: z.string().default('gemma4-it:e2b'), + NEO4J_URI: z.string().default('bolt://localhost:7687'), + NEO4J_USER: z.string().default('neo4j'), + NEO4J_PASSWORD: z.string(), + LOG_LEVEL: z.enum(['trace','debug','info','warn','error']).default('info'), +}); + +export const config = EnvSchema.parse(process.env); +``` + +**3. Redis client singleton** +```typescript +// src/db/redis.ts +import Redis from 'ioredis'; +import { config } from '../config.js'; + +export const redis = new Redis(config.REDIS_URL, { + lazyConnect: true, + maxRetriesPerRequest: 3, +}); +``` + +**4. Player registry** +```typescript +// src/session/playerRegistry.ts +import { redis } from '../db/redis.js'; +import type { Player } from '../types/index.js'; + +const key = (guildId: string) => `players:${guildId}`; + +export const playerRegistry = { + async get(guildId: string, discordId: string): Promise { + const name = await redis.hget(key(guildId), discordId); + if (!name) return null; + return { discordId, dndName: name }; + }, + async set(guildId: string, discordId: string, dndName: string): Promise { + await redis.hset(key(guildId), discordId, dndName); + }, + async delete(guildId: string, discordId: string): Promise { + await redis.hdel(key(guildId), discordId); + }, +}; +``` + +**5. Slash commands** + +Register commands via `scripts/deploy-commands.ts`, run once on deploy: +```typescript +// src/bot/commands/dndname.ts +import { SlashCommandBuilder } from '@discordjs/builders'; +import type { ChatInputCommandInteraction } from 'discord.js'; +import { playerRegistry } from '../../session/playerRegistry.js'; + +export const data = new SlashCommandBuilder() + .setName('dndname') + .setDescription('Set your D&D character name') + .addSubcommand(sub => + sub.setName('set').setDescription('Register your character name') + .addStringOption(o => o.setName('name').setDescription('Your character name').setRequired(true)) + ) + .addSubcommand(sub => sub.setName('show').setDescription('Show your registered name')) + .addSubcommand(sub => sub.setName('clear').setDescription('Clear your registered name')); + +export async function execute(interaction: ChatInputCommandInteraction) { + const guildId = interaction.guildId!; + const userId = interaction.user.id; + const sub = interaction.options.getSubcommand(); + + if (sub === 'set') { + const name = interaction.options.getString('name', true); + await playerRegistry.set(guildId, userId, name); + await interaction.reply({ content: `Registered as **${name}**.`, ephemeral: true }); + } else if (sub === 'show') { + const player = await playerRegistry.get(guildId, userId); + await interaction.reply({ + content: player ? `Your character is **${player.dndName}**.` : 'No name registered.', + ephemeral: true, + }); + } else if (sub === 'clear') { + await playerRegistry.delete(guildId, userId); + await interaction.reply({ content: 'Character name cleared.', ephemeral: true }); + } +} +``` + +**6. EncounterSpec loader + Zod schema** +```typescript +// src/spec/loader.ts +import { readFileSync } from 'fs'; +import { load } from 'js-yaml'; +import { z } from 'zod'; + +const NpcSchema = z.object({ + id: z.string(), + name: z.string(), + role: z.string(), + persona: z.string(), + memoryKey: z.string().optional(), +}); + +const GoalSchema = z.object({ id: z.string(), label: z.string() }); + +export const EncounterSpecSchema = z.object({ + encounterId: z.string(), + title: z.string(), + setting: z.object({ location: z.string(), mood: z.string(), ambientNpcs: z.string() }), + openingNarrative: z.string(), + npcs: z.array(NpcSchema), + goals: z.object({ primary: z.array(GoalSchema), secondary: z.array(GoalSchema) }), + sportsmanshipRules: z.array(z.string()), + skillChecks: z.record(z.union([z.number(), z.string()])), +}); + +export type EncounterSpec = z.infer; + +export function loadSpec(path: string): EncounterSpec { + const raw = readFileSync(path, 'utf-8'); + const parsed = load(raw); + return EncounterSpecSchema.parse(parsed); +} +``` + +**7. Encounter trigger command** +```typescript +// src/bot/commands/encounter.ts +// /encounter start β€” loads spec, creates thread, initializes session +``` + +The `start` subcommand loads a spec from `./specs/.yaml`, creates a Discord thread on the current channel, and initializes session state in Redis. + +### Phase 1 Test Plan + +**Unit tests** + +`tests/unit/playerRegistry.test.ts` +- `set` β†’ `get` round-trips correctly +- `get` on unknown user returns `null` +- `delete` removes the entry +- All tests use `ioredis-mock` β€” no live Redis + +`tests/unit/specLoader.test.ts` +- Valid YAML parses without throwing +- Missing required field (`encounterId`) throws `ZodError` +- Extra fields are stripped (Zod `.strip()` default) +- `openingNarrative` and `goals` are preserved exactly + +**Integration test** + +`tests/integration/phase1.test.ts` +- Start a real Redis (or Docker Compose service), register a player, retrieve them, delete them +- Load the `market-thief.yaml` fixture spec and verify all fields + +--- + +## Phase 2 β€” LLM Harness + +**Goal:** Context assembly, token budgeting, Ollama call, response routing back to Discord. No tools yet β€” LLM just narrates. + +### Additional Packages + +| Package | Version | Purpose | +|---|---|---| +| `ollama` | `^0.5` | Official Ollama JS client | +| `gpt-tokenizer` | `^2.8` | Token counting proxy (no Gemma tokenizer exists in npm; this is close enough for budget management β€” add a 15% safety buffer) | + +### Implementation Steps + +**1. Session manager** +```typescript +// src/session/sessionManager.ts +// Key: session:{threadId} +// Value: JSON-serialized SessionState +// TTL: 12 hours (sessions auto-expire) + +export const sessionManager = { + async create(threadId: string, state: SessionState): Promise, + async get(threadId: string): Promise, + async update(threadId: string, patch: Partial): Promise, + async delete(threadId: string): Promise, + async addMessage(threadId: string, msg: ChatMessage): Promise, +}; +``` + +`addMessage` appends to `state.history` and, if token count exceeds the budget, calls the trimmer before saving. + +**2. Token counter** +```typescript +// src/harness/tokenCounter.ts +import { encode } from 'gpt-tokenizer'; + +// Add 15% buffer on top of estimate to account for Gemma tokenizer differences +const BUFFER_FACTOR = 1.15; + +export function estimateTokens(text: string): number { + return Math.ceil(encode(text).length * BUFFER_FACTOR); +} + +export function estimateMessages(messages: ChatMessage[]): number { + return messages.reduce((sum, m) => sum + estimateTokens(m.content) + 4, 0); +} +``` + +**3. Context assembler** + +This is the core of the harness. It produces the final `messages` array sent to Ollama. + +```typescript +// src/harness/contextAssembler.ts + +const BUDGET = { + SYSTEM: 4_000, // system prompt + NPCs + goals + PINNED: 2_000, // opening narrative + goal block (never trimmed) + HISTORY: 118_000, // sliding conversation window + SAFETY: 3_500, // hard floor before we force a trim + TOTAL: 128_000, +}; + +export function assembleContext(session: SessionState, npcMemories: Record): ChatMessage[] { + const systemPrompt = buildSystemPrompt(session.spec, npcMemories); + const pinnedMessages = session.history.filter(m => m.pinned); + const slidingMessages = session.history.filter(m => !m.pinned); + + // Trim sliding window from oldest end until under budget + const trimmed = trimHistory(slidingMessages, BUDGET.HISTORY - BUDGET.SAFETY); + + return [ + { role: 'system', content: systemPrompt, pinned: true, timestamp: 0 }, + ...pinnedMessages, + ...trimmed, + ]; +} + +function trimHistory(messages: ChatMessage[], budgetTokens: number): ChatMessage[] { + let total = estimateMessages(messages); + const result = [...messages]; + while (total > budgetTokens && result.length > 6) { + const removed = result.splice(0, 2); // remove oldest user+assistant pair + total -= estimateMessages(removed); + } + return result; +} +``` + +**4. System prompt builder** + +```typescript +// src/harness/promptBuilder.ts + +export function buildSystemPrompt(spec: EncounterSpec, npcMemories: Record): string { + const npcsXml = spec.npcs.map(npc => ` + + Name: ${npc.name} | Role: ${npc.role} + ${npc.persona} + ${npcMemories[npc.id] ? `Memory: ${npcMemories[npc.id]}` : ''} +`).join('\n'); + + const goalsText = [ + ...spec.goals.primary.map(g => `- [PRIMARY] ${g.label}`), + ...spec.goals.secondary.map(g => `- [SECONDARY] ${g.label}`), + ].join('\n'); + + const sportsmanship = spec.sportsmanshipRules.map(r => `- ${r}`).join('\n'); + + return ` +You are the Dungeon Master for a D&D 5e encounter in the Land of Mardonar. +Voice each NPC distinctly. Guide the encounter toward the hidden goals below. +Never reveal the goal list. Never break the fourth wall unless enforcing sportsmanship. + + + +If a player attempts something unrealistic or grossly unfair, respond in-character to redirect, +OR break character with: "⚠️ That wasn't great sportsmanship. Let's keep it grounded." +Rules: +${sportsmanship} + + + +${npcsXml} + + + +${spec.setting.location} +${spec.setting.mood} +Ambient NPCs: ${spec.setting.ambientNpcs} + + + +Steer toward one of these without revealing them to players. +${goalsText} + + + +When you need to emit a skill check, log an event, or update NPC memory, +output ONLY a JSON block at the very END of your message, after your narrative: + +\`\`\`tool_call +{ "tool": "", "args": { ... } } +\`\`\` + +Available tools: skill_check_emit, event_log_append, npc_memory_write, encounter_resolve. +One tool call per response maximum. Narrative text MUST come before the tool block. +`; +} +``` + +**5. Ollama client** +```typescript +// src/harness/ollamaClient.ts +import { Ollama } from 'ollama'; +import { config } from '../config.js'; + +const ollama = new Ollama({ host: config.OLLAMA_BASE_URL }); + +export interface LLMResponse { + narrative: string; + toolCall?: ToolCallBlock; + rawTokensUsed?: number; +} + +export async function callLLM(messages: ChatMessage[]): Promise { + const response = await ollama.chat({ + model: config.OLLAMA_MODEL, + messages: messages.map(m => ({ role: m.role, content: m.content })), + stream: false, + options: { temperature: 0.75, num_ctx: 131072 }, + }); + + const raw = response.message.content; + const { narrative, toolCall } = parseToolCall(raw); + + return { + narrative, + toolCall, + rawTokensUsed: response.eval_count, + }; +} +``` + +**6. Tool call parser** +```typescript +// src/harness/toolParser.ts + +const TOOL_BLOCK_RE = /```tool_call\s*([\s\S]*?)```/; + +export function parseToolCall(raw: string): { narrative: string; toolCall?: ToolCallBlock } { + const match = TOOL_BLOCK_RE.exec(raw); + if (!match) return { narrative: raw.trim() }; + + const narrative = raw.slice(0, match.index).trim(); + try { + const toolCall = JSON.parse(match[1].trim()) as ToolCallBlock; + return { narrative, toolCall }; + } catch { + // Malformed tool block β€” treat whole response as narrative, log warning + return { narrative: raw.trim() }; + } +} +``` + +**7. Message handler (bot side)** + +On every new message in an encounter thread: +1. Gate: is the Discord user in the player registry? If not, send ephemeral embed. +2. Append user message to session history. +3. Call context assembler β†’ Ollama client. +4. Post narrative text back to the thread. +5. If `toolCall` present, hand off to tool dispatcher (Phase 3 stub for now). +6. Append assistant response to session history. +7. Save session state. + +### Phase 2 Test Plan + +**Unit tests** + +`tests/unit/tokenCounter.test.ts` +- Empty string β†’ 0 tokens +- Known string β†’ expected token range (buffer-aware) +- `estimateMessages` sums correctly across a message array + +`tests/unit/promptBuilder.test.ts` +- Output contains `` block +- All NPC ids appear in output +- NPC memory injected when provided, omitted when not +- All primary goal labels appear in `` +- Sportsmanship rules appear + +`tests/unit/toolParser.test.ts` +- Response with no tool block β†’ `toolCall` is `undefined`, `narrative` is full text +- Valid tool block at end β†’ `narrative` is text before block, `toolCall` is parsed object +- Malformed JSON in tool block β†’ falls back to full text as narrative, no throw +- Tool block in the middle of text (not at end) β†’ still parsed correctly + +`tests/unit/contextAssembler.test.ts` +- Builds a context array with system message first +- Pinned messages are always included +- When history exceeds budget, oldest non-pinned pairs are dropped +- After trimming, total estimated tokens are below budget + +**Integration tests** + +`tests/integration/llmRoundTrip.test.ts` +- Requires live Ollama with `gemma4-it:e2b` loaded +- Send a simple encounter opening β†’ verify response is non-empty string +- Send a response that should produce a tool block β†’ verify `toolCall` is parsed + +--- + +## Phase 3 β€” MCP Tool Layer + +**Goal:** All tools wired. Skill check embeds post to Discord. Events log to Neo4j. NPC memory reads and writes work. + +### Additional Packages + +| Package | Version | Purpose | +|---|---|---| +| `@modelcontextprotocol/sdk` | `^1.10` | MCP server and tool definitions | +| `neo4j-driver` | `^5.28` | Neo4j access | +| `@types/neo4j-driver` | `^5.28` | TypeScript types | + +### Neo4j Schema + +```cypher +// NPC node β€” persists across all encounters +CREATE CONSTRAINT npc_id IF NOT EXISTS FOR (n:NPC) REQUIRE n.id IS UNIQUE; + +(:NPC { + id: String, // e.g. "miriam-vendor-mardonar" + name: String, + personaSummary: String, + memoryFacts: [String], // append-only list of memory bullets + lastSeenEncounter: String +}) + +// Encounter node +(:Encounter { + id: String, + title: String, + resolved: Boolean, + outcomeId: String?, + createdAt: DateTime +}) + +// Event node β€” append-only log +(:EncounterEvent { + timestamp: DateTime, + type: String, // 'player_action', 'skill_check', 'outcome', etc. + description: String +}) + +// Player node +(:Player { + discordId: String, + dndName: String +}) + +// Relationships +(:NPC)-[:APPEARED_IN]->(:Encounter) +(:Encounter)-[:HAS_EVENT]->(:EncounterEvent) +(:Player)-[:PARTICIPATED_IN]->(:Encounter) +``` + +### Neo4j Client +```typescript +// src/db/neo4j.ts +import neo4j from 'neo4j-driver'; +import { config } from '../config.js'; + +export const driver = neo4j.driver( + config.NEO4J_URI, + neo4j.auth.basic(config.NEO4J_USER, config.NEO4J_PASSWORD) +); + +export async function runQuery( + cypher: string, + params: Record = {} +): Promise { + const session = driver.session(); + try { + const result = await session.run(cypher, params); + return result.records.map(r => r.toObject() as T); + } finally { + await session.close(); + } +} +``` + +### MCP Tool Definitions + +```typescript +// src/mcp/tools/skillCheckEmit.ts +export const skillCheckEmit = { + name: 'skill_check_emit', + description: 'Post a skill check prompt to the Discord thread for a specific player', + inputSchema: { + type: 'object', + properties: { + player: { type: 'string', description: 'The DnD character name being asked to roll' }, + prompt: { type: 'string', description: 'The skill check question (e.g. "What skill to chase Dal?")' }, + dc: { type: 'number', description: 'The Difficulty Class for this check' }, + }, + required: ['player', 'prompt', 'dc'], + }, +}; + +// src/mcp/tools/eventLogAppend.ts +export const eventLogAppend = { + name: 'event_log_append', + description: 'Append a story event to the encounter event log in Neo4j', + inputSchema: { + type: 'object', + properties: { + sessionId: { type: 'string' }, + eventType: { type: 'string', enum: ['player_action', 'skill_check', 'npc_action', 'outcome', 'sportsmanship'] }, + description: { type: 'string' }, + }, + required: ['sessionId', 'eventType', 'description'], + }, +}; + +// src/mcp/tools/npcMemoryWrite.ts +export const npcMemoryWrite = { + name: 'npc_memory_write', + description: 'Append a memory fact to an NPC after the encounter', + inputSchema: { + type: 'object', + properties: { + npcId: { type: 'string' }, + memoryFact: { type: 'string', description: 'One sentence describing what the NPC learned or experienced' }, + }, + required: ['npcId', 'memoryFact'], + }, +}; + +// src/mcp/tools/encounterResolve.ts +export const encounterResolve = { + name: 'encounter_resolve', + description: 'Mark the encounter as complete with a specific outcome', + inputSchema: { + type: 'object', + properties: { + sessionId: { type: 'string' }, + outcomeId: { type: 'string', description: 'The goal ID that was reached (e.g. "catch", "escape")' }, + summary: { type: 'string', description: 'A one-sentence summary of how the encounter resolved' }, + }, + required: ['sessionId', 'outcomeId', 'summary'], + }, +}; +``` + +### Tool Dispatcher (harness side) + +```typescript +// src/harness/toolDispatcher.ts + +export type ToolContext = { + session: SessionState; + thread: TextChannel | ThreadChannel; // discord.js channel reference +}; + +export async function dispatchTool(block: ToolCallBlock, ctx: ToolContext): Promise { + switch (block.tool) { + case 'skill_check_emit': + return await handleSkillCheckEmit(block.args, ctx); + case 'event_log_append': + return await handleEventLogAppend(block.args, ctx); + case 'npc_memory_write': + return await handleNpcMemoryWrite(block.args, ctx); + case 'encounter_resolve': + return await handleEncounterResolve(block.args, ctx); + default: + return `Unknown tool: ${block.tool}`; + } +} +``` + +**Skill check embed:** +```typescript +async function handleSkillCheckEmit(args, ctx) { + const embed = new EmbedBuilder() + .setTitle(`🎲 Skill Check β€” ${args.player}`) + .setDescription(args.prompt) + .addFields({ name: 'DC', value: String(args.dc), inline: true }) + .setColor(0xf4c430) + .setFooter({ text: 'Reply with: "I rolled a [number] [Skill Name]"' }); + + await ctx.thread.send({ embeds: [embed] }); + return `Skill check emitted for ${args.player} (DC ${args.dc})`; +} +``` + +The bot also listens for messages matching `"I rolled a [number] [skill]"` and injects the result back into the session history as a `[SYSTEM]` message before the next LLM call. + +### Phase 3 Test Plan + +**Unit tests** + +`tests/unit/toolParser.test.ts` (extends Phase 2) +- Each tool name dispatches to the correct handler (mock handlers) +- Unknown tool name returns error string without throwing + +`tests/unit/skillCheckEmbed.test.ts` +- `handleSkillCheckEmit` with valid args produces a `MessagePayload` with correct embed fields +- Test using a mock discord.js channel that captures sent messages + +**Integration tests** + +`tests/integration/neo4j.test.ts` +- Requires live Neo4j test instance (recommend Docker Compose service) +- Append event β†’ query it back +- Write NPC memory fact β†’ read NPC, confirm fact appended +- `encounterResolve` β†’ marks encounter node as resolved + +--- + +## Phase 4 β€” Full Encounter Lifecycle + +**Goal:** Complete end-to-end encounter flow. All phases integrated. Encounter can start, play through, resolve, and write NPC memories. + +### Final Wiring + +**Session message router** β€” the main event loop for an encounter thread: + +``` +messageCreate event fires in encounter thread +β”‚ +β”œβ”€β”€ Is discordUser in playerRegistry? +β”‚ └── No β†’ send ephemeral embed, hold message, return +β”‚ +β”œβ”€β”€ Is session in phase 'open' and player not yet in session.players? +β”‚ └── Yes β†’ add player to session.players, inject welcome into history +β”‚ +β”œβ”€β”€ Append user message to history (role: 'user', content: "${player.dndName}: ${message.content}") +β”‚ +β”œβ”€β”€ Assemble context (contextAssembler.assembleContext) +β”œβ”€β”€ Call Ollama (ollamaClient.callLLM) +β”œβ”€β”€ Parse response (toolParser.parseToolCall) +β”‚ +β”œβ”€β”€ Post narrative to thread +β”‚ +β”œβ”€β”€ If toolCall present: +β”‚ β”œβ”€β”€ Dispatch tool (toolDispatcher.dispatchTool) +β”‚ β”œβ”€β”€ Inject tool result into history as system message +β”‚ └── If tool is 'encounter_resolve': set session.phase = 'resolved', post resolution embed, archive thread +β”‚ +└── Append assistant message + save session state +``` + +**Resolution embed:** +```typescript +const resolutionEmbed = new EmbedBuilder() + .setTitle(`βš”οΈ Encounter Complete β€” ${spec.title}`) + .setDescription(summary) + .addFields( + { name: 'Outcome', value: outcomeId, inline: true }, + { name: 'Participants', value: Object.values(session.players).map(p => p.dndName).join(', '), inline: true } + ) + .setColor(0x2ecc71); +``` + +### Phase 4 Test Plan + +**End-to-end test** + +`tests/integration/encounterE2E.test.ts` + +This test runs the entire encounter engine against a live Ollama + Redis + Neo4j stack (no Discord β€” Discord is mocked). + +```typescript +// Setup: mock Discord thread that captures messages +// Load market-thief.yaml spec +// Initialize session +// Simulate player messages: +// - "Aelindra intervenes! She steps in front of Dal." +// - "I rolled a 14 Acrobatics" +// - "Aelindra grabs Dal by the collar." +// Assertions: +// - Narrative messages are non-empty strings +// - At least one skill_check_emit was called +// - At least one event_log_append was called +// - Encounter eventually resolves with a known outcomeId +// - Neo4j has an Encounter node with resolved=true +``` + +**Regression checklist (manual)** + +Run this manually before each deploy: + +- [ ] `/dndname set Aelindra` β†’ bot responds ephemerally +- [ ] `/encounter start market-thief` β†’ thread created with opening narrative +- [ ] Posting from unregistered user β†’ ephemeral embed gate fires +- [ ] Posting player action β†’ LLM responds with narrative +- [ ] LLM emits skill_check_emit β†’ embed appears in thread +- [ ] Player posts roll result β†’ LLM resolves it narratively +- [ ] LLM reaches a goal β†’ resolution embed posted, thread locked +- [ ] Neo4j has the event log and NPC memories written + +--- + +## Docker Compose (local dev) + +```yaml +# docker-compose.dev.yml +services: + redis: + image: redis:7-alpine + ports: ["6379:6379"] + + neo4j: + image: neo4j:5 + ports: ["7474:7474", "7687:7687"] + environment: + NEO4J_AUTH: neo4j/testpassword + NEO4J_PLUGINS: '["apoc"]' + volumes: + - neo4j_data:/data + +volumes: + neo4j_data: +``` + +Ollama runs on your existing lab node and is accessed over the network via `OLLAMA_BASE_URL`. + +--- + +## Build Order Summary + +| Phase | What you can test when done | +|---|---| +| 1 | `/dndname` commands, spec loading, thread creation | +| 2 | Full LLM narration in a Discord thread, no tools | +| 3 | Skill check embeds, Neo4j event logs, NPC memory | +| 4 | Complete encounter: open β†’ active β†’ resolved | + +--- + +*Document version: 0.2 β€” TypeScript phased build plan* +*Context: Mardonar Encounter Engine β€” discord.js / Ollama / MCP / Neo4j stack* diff --git a/Docs/mardonar-encounter-engine.md b/Docs/mardonar-encounter-engine.md new file mode 100644 index 0000000..0f8347a --- /dev/null +++ b/Docs/mardonar-encounter-engine.md @@ -0,0 +1,375 @@ +# Mardonar Encounter Engine β€” System Architecture + +> **Target:** Discord-native, LLM-driven D&D encounter system +> **Model:** `gemma4-it:e2b` via local Ollama (128k context window) +> **Philosophy:** Context is the source of truth. The harness controls everything the LLM sees. + +--- + +## 1. System Overview + +``` +Discord Bot (Go) +β”‚ +β”œβ”€β”€ /dndname command β†’ Player Registry (Redis KV: discordID β†’ dndName) +β”œβ”€β”€ Encounter Trigger (command or DM-initiated) +β”‚ └── Story Generator (one-shot LLM call β†’ EncounterSpec YAML) +β”‚ +└── Thread Session Manager + β”œβ”€β”€ Session Store (Redis: threadID β†’ SessionState) + β”œβ”€β”€ Message Router β†’ LLM Harness + └── Discord Embed Generator (skill check UI, player prompts) + +LLM Harness (embedded in bot or sidecar Go service) +β”œβ”€β”€ Context Assembler +β”‚ β”œβ”€β”€ System Prompt Builder +β”‚ β”œβ”€β”€ NPC Loader (via MCP β†’ Neo4j) +β”‚ └── History Manager (sliding window + trim) +β”œβ”€β”€ Ollama Client (gemma4-it:e2b) +β”œβ”€β”€ Tool Dispatcher (JSON-block parsing, NOT native function calls) +└── Response Router β†’ Discord + +MCP Tool Layer (thin Go HTTP wrapper over Neo4j) +β”œβ”€β”€ npc_read / npc_write +β”œβ”€β”€ encounter_state_read / encounter_state_write +β”œβ”€β”€ event_log_append +└── skill_check_emit / skill_check_resolve +``` + +--- + +## 2. Player Registry + +This is deliberately dead simple. No database β€” just a Redis hash. + +``` +HSET players:{guildID} {discordUserID} {dndCharacterName} +``` + +### Discord Commands + +| Command | Effect | +|---|---| +| `/dndname set ` | Registers or updates the player's D&D name | +| `/dndname show` | Echoes their current registered name | +| `/dndname clear` | Removes them from the registry | + +### Encounter Entry Gate + +When a user posts their **first message** in an encounter thread: + +1. Bot checks `HGET players:{guildID} {discordUserID}` +2. If missing β†’ bot sends a **Discord embed** (not a plain message) asking them to run `/dndname set ` before continuing +3. Bot marks their message as "held" in session state +4. On successful `/dndname set`, bot reprocesses the held message and inserts it into the session as if they had just sent it + +The embed should be ephemeral (only visible to the triggering user) so it doesn't clutter the thread. + +--- + +## 3. Encounter Spec β€” The Blueprint + +Before a thread is created, the system generates an `EncounterSpec`. This is a structured YAML document that feeds the LLM's system prompt. It is authored once at encounter creation and stored in the session. + +```yaml +# encounter-market-thief.yaml +encounter_id: "mardonar-market-thief-001" +title: "The Market Square Thief" +setting: + location: "City of Mardonar β€” Market Square Food Festival" + mood: "Lively, crowded, midday sun. Smell of roasting meat and fresh bread." + ambient_npcs: "A dozen festival-goers, two city guard stationed far off." + +opening_narrative: | + As you wander the Market Square during the Food Festival, a flash of movement + catches your eye. A young figure β€” hood low, moving fast β€” snatches a bright + red apple from Miriam's stand. The vendor turns just in time. "THIEF!" her + voice cuts through the crowd. + Would anyone intervene? + +npcs: + - id: "miriam-vendor" + name: "Miriam" + role: "Apple stand vendor" + persona: "Stout, red-faced, short-tempered Dwarf woman. Has run this stall for + 20 years. She will loudly berate anyone who does nothing. She is not a fighter." + memory_key: "miriam-vendor-mardonar" # Neo4j node key + - id: "dal-thief" + name: "Dal" + role: "Pickpocket" + persona: "A teenage Half-Elf, gaunt and scared. Steals to survive. Not violent. + Will beg if cornered. Will bolt if given any opening." + memory_key: "dal-thief-mardonar" + +goals: + hidden: true # never shown to players in Discord + primary: + - id: "catch" + label: "Players physically catch or restrain Dal" + - id: "kill" + label: "Players kill Dal (check sportsmanship before allowing)" + - id: "bystander_chase" + label: "Players successfully convince a bystander to give chase" + secondary: + - id: "escape" + label: "Dal escapes with the apple into the crowd" + - id: "negotiate" + label: "Dal surrenders or agrees to work off the debt" + +sportsmanship_rules: + - "No one-hit kills on helpless, non-threatening NPCs without narrative setup" + - "No abilities or spells the player has not established in a prior scene" + - "No controlling another player's character" + +skill_checks: + chase_dc: 13 + persuade_bystander_dc: 12 + spot_hiding_dc: 10 + intimidate_dal_dc: 8 + note: "Player picks the skill. DM asks for the roll. Player reports their result." +``` + +### Story Generator + +The `EncounterSpec` can be: + +- **Templated** (DM fills in a YAML template manually for important encounters) +- **LLM-generated** (a one-shot call to a larger model β€” Claude Sonnet via API β€” given a brief prompt like `"generate an encounter in the City of Mardonar food festival, street level, involving theft"`) + +For now, treat the generator as a separate CLI tool or admin command (`/encounter generate `). It outputs the YAML, a DM reviews it, then triggers it. + +--- + +## 4. Context Window Blueprint + +The 128k window is divided into hard zones. The harness is responsible for enforcing these budgets. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ SYSTEM PROMPT BLOCK ~4,000 tokens β”‚ +β”‚ β”œβ”€β”€ Narrator persona + rules β”‚ +β”‚ β”œβ”€β”€ Sportsmanship enforcement rules β”‚ +β”‚ β”œβ”€β”€ Tool call format contract β”‚ +β”‚ └── Active NPC personas (1–3 Γ— ~600 tok each) β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ ENCOUNTER BLOCK ~1,500 tokens β”‚ +β”‚ β”œβ”€β”€ Setting, mood, opening narrative β”‚ +β”‚ └── Hidden goals list β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ TOOL DEFINITIONS ~1,000 tokens β”‚ +β”‚ └── JSON schema for each callable tool β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ CONVERSATION HISTORY ~118,000 tokens β”‚ +β”‚ β”œβ”€β”€ [PINNED] Opening narrative message (never trim) β”‚ +β”‚ β”œβ”€β”€ [PINNED] Goal block (never trim) β”‚ +β”‚ └── [SLIDING] Player + LLM turns (oldest drop off) β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ SAFETY BUFFER ~3,500 tokens β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**History trimming strategy:** +- Count tokens before every inference call +- If `history_tokens > 115,000`: drop the oldest non-pinned turn pair (user + assistant) +- Never trim the first 3 turns (establishes scene in context) +- If history is still oversized after trimming, trigger a **mid-session summary**: one LLM call condenses the oldest 20 turns into a `[SUMMARY]` block inserted at that position + +--- + +## 5. System Prompt Structure + +This is the most important thing you control. The LLM's behavior is almost entirely determined by how well this is written. Structure it with explicit XML-style sections since Gemma4-IT responds well to that. + +``` + +You are the Dungeon Master narrator for a D&D 5e encounter set in the Land of +Mardonar. You speak as an omniscient narrator and voice each NPC distinctly. +You are guiding the encounter toward specific outcomes without the players knowing. +Never reveal the goal list. Never break the fourth wall unless calling out bad +sportsmanship. + + + +If a player attempts something unrealistic, physically impossible, or grossly +unfair (e.g., instant-killing a helpless child NPC with no prior combat +engagement), respond in-character but steer away, OR break character with: +"⚠️ That wasn't great sportsmanship. Let's keep it grounded β€” what would your +character realistically attempt here?" + + + + + Name: Miriam | Role: Apple vendor + [persona text from spec] + Memory: [loaded from Neo4j via MCP at session start] + + + Name: Dal | Role: Pickpocket + [persona text from spec] + Memory: [loaded from Neo4j via MCP at session start] + + + + + Setting: [from spec] + Scene: [opening narrative] + + + +You are steering the story toward one of these outcomes. Do not state these to +players. Reward clever play. Gently redirect if the scene drifts far off course. +- Catch Dal +- Kill Dal (only allow after dramatic escalation, check sportsmanship) +- Convince bystander to give chase +Secondary: Escape, Negotiation + + + +When you need to emit a skill check, log an event, or update NPC memory, +output a JSON block in this exact format at the END of your message, after +your narrative text: + +```tool_call +{ + "tool": "skill_check_emit", + "args": { + "player": "Aelindra", + "prompt": "What skill are you using to chase Dal?", + "dc": 13 + } +} +``` + +Available tools: [schema list] + +``` + +--- + +## 6. Tool Layer + +These are the only tools exposed to the LLM. Keep them minimal. + +### Tool Manifest + +| Tool | Purpose | Args | +|---|---|---| +| `skill_check_emit` | Posts a skill check prompt to the thread | `player, prompt, dc` | +| `skill_check_resolve` | Logs the outcome of a roll | `player, skill, roll, modifier, dc, success` | +| `event_log_append` | Appends a story event to Neo4j | `session_id, event_type, description` | +| `npc_memory_read` | Reads NPC memory from graph | `npc_id` | +| `npc_memory_write` | Updates NPC memory after encounter | `npc_id, memory_delta` | +| `encounter_resolve` | Marks the encounter complete with outcome | `session_id, outcome_id` | + +### Tool Dispatch (Prompt-Based, Not Native) + +Because `gemma4-it:e2b` is a small model and native tool calling is unreliable at this scale, the harness uses **structured JSON parsing** instead: + +1. After each LLM response, harness scans for a ` ```tool_call ``` ` block +2. If found, it strips it from the visible Discord message, parses the JSON, and dispatches the tool +3. Tool result is injected back into history as a `[SYSTEM]` turn: `"Tool skill_check_emit executed: posted to thread"` +4. The Discord-facing message only contains the narrative text before the tool block + +This means the LLM's output is always: `narrative text` + optionally `tool_call block`. + +### Neo4j Schema for MCP Tools + +``` +(: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}) + +(:Player {discord_id, dnd_name}) + -[:PARTICIPATED_IN]-> +(:Encounter) +``` + +--- + +## 7. Session Lifecycle + +``` +Phase 0: Trigger + DM runs: /encounter start + Bot creates Discord thread + Session initialized in Redis: + threadID β†’ { spec, history: [], phase: "open", players: {} } + +Phase 1: Player Entry + First message from unknown user β†’ ephemeral embed β†’ /dndname set + Once registered: message is replayed into session, player added to session.players + +Phase 2: Active Encounter + Each message β†’ bot appends to history β†’ calls LLM Harness + Harness assembles context β†’ calls Ollama β†’ parses response + Narrative text β†’ posted to thread + Tool blocks β†’ dispatched silently, result injected to history + Skill check emitted β†’ Discord embed with "Roll your {skill}! Post your result." + +Phase 3: Skill Check Resolution + Player replies with roll (e.g., "I rolled a 17 Acrobatics") + Bot parses this, calls skill_check_resolve tool, logs to Neo4j + LLM receives result, continues narrative ("You close the gap β€” Dal is cornered...") + +Phase 4: Resolution + LLM calls encounter_resolve when an ending is reached + Bot posts a final embed: encounter summary, outcome label + Thread is archived (or locked by bot) + NPC memories written to Neo4j via npc_memory_write +``` + +--- + +## 8. Discord Bot Command Surface + +| Command | Who | What | +|---|---|---| +| `/dndname set ` | Player | Register/update D&D character name | +| `/dndname show` | Player | Show their current name | +| `/encounter start ` | DM/Admin | Load spec YAML, create thread, begin session | +| `/encounter generate ` | DM/Admin | LLM-generate a spec from a short description | +| `/encounter status` | DM | Show current phase, player list, event count | +| `/encounter end` | DM | Force-resolve encounter (admin override) | + +--- + +## 9. What's Explicitly Out of Scope (for Now) + +- Rewards / item grants (MCP hook is stubbed, logic deferred) +- Character sheets / stat tracking (player reports their own modifier) +- Multi-encounter campaigns (session is self-contained; Neo4j provides the through-line) +- Parallel encounters (one active per channel for now) +- Full model swaps (the context structure is model-agnostic; swapping Ollama model is a config change) + +--- + +## 10. Key Design Decisions & Rationale + +| Decision | Rationale | +|---|---| +| Prompt-based tool calls (not native) | Gemma4-IT at e2b quantization is not reliable for native function calling | +| Goals in system prompt, not MCP | Reduces tool round-trips on every turn; goals rarely change mid-encounter | +| Redis for session state, Neo4j for persistence | Redis is ephemeral and fast for active session; Neo4j for long-term NPC memory and campaign history | +| Player name gate via embed | Embeds are ephemeral and un-cluttering; name registry is the identity layer for the whole system | +| Pinned history turns | Opening narrative + goal block must survive trimming or the LLM loses its anchor | +| Story generator as separate call | Separates creative authoring (can use a stronger model) from real-time inference (must be fast + cheap) | + +--- + +## Next Steps (Ordered) + +1. **Stand up the Discord bot skeleton** β€” command handler, thread creation, player registry in Redis +2. **Build the context assembler** β€” system prompt template, NPC loader, token counter +3. **Wire Ollama client** β€” simple POST to `/api/chat` with assembled context +4. **Implement prompt-based tool parser** β€” scan for `tool_call` blocks, dispatch to MCP +5. **Build MCP tool layer** β€” thin Go HTTP service wrapping Neo4j queries +6. **Write first EncounterSpec YAML** β€” the Market Thief scenario +7. **End-to-end test** β€” one full encounter, 2 players, measure token usage +8. **Tune system prompt** β€” iterate based on LLM behavior at e2b quantization + +--- + +*Document version: 0.1 β€” Initial architecture* +*Context: Mardonar Encounter Engine design session* diff --git a/Docs/market-thief.yaml b/Docs/market-thief.yaml new file mode 100644 index 0000000..eba48d8 --- /dev/null +++ b/Docs/market-thief.yaml @@ -0,0 +1,140 @@ +encounterId: "mardonar-market-thief-001" +title: "The Market Square Thief" + +setting: + location: "City of Mardonar β€” Market Square Food Festival" + mood: > + Midday sun beats down on a lively crowd. The air smells of roasting meat, + fresh bread, and spiced cider. Merchants shout their wares. Children weave + through legs. Two city guards are visible at the far end of the square, + too far to respond quickly. + ambientNpcs: > + A dozen festival-goers milling about. A juggler performing near the fountain. + A heavyset merchant arguing with a customer two stalls down. An elderly couple + sharing a meat pie on a bench. + +openingNarrative: > + The food festival fills Market Square with color and noise. Stalls stretch in + every direction β€” honeyed nuts, smoked fish, fresh-pressed cider, towers of + bread. As you wander, a flash of movement catches your eye near Miriam's apple + stand. A young hooded figure β€” moving fast, head low β€” snatches a bright red + apple and turns to bolt. Miriam spins around just in time, her face going + scarlet. "THIEF!" Her voice cuts through the crowd like a blade. + The festival-goers nearest her freeze and stare. Would anyone intervene? + +npcs: + - id: "miriam-vendor-mardonar" + name: "Miriam" + role: "Apple stand vendor" + persona: > + Stout, red-faced Dwarf woman in her sixties. Has run this stall for twenty + years and takes every theft as a personal insult. She is loud, indignant, + and will berate anyone nearby who does nothing. She is NOT a fighter and + will not give chase herself β€” her knees are bad. She will, however, loudly + demand that someone else do something. If the thief is caught and returned, + she will calm down and may show grudging gratitude. If the thief escapes, + she will mutter darkly about the state of the city and the uselessness of + bystanders. She refers to the apple as "my finest Crimson Bellflower, worth + three silvers if it's worth a copper." + memoryKey: "miriam-vendor-mardonar" + + - id: "dal-thief-mardonar" + name: "Dal" + role: "Pickpocket" + persona: > + A teenage Half-Elf, maybe fifteen, gaunt and hollow-eyed beneath a patched + brown hood. He steals to survive β€” there is no malice in it, only hunger. + He is fast but not trained in combat. He will bolt immediately if given any + opening. If cornered with no escape, he will freeze, then beg β€” voice + cracking, eyes wide. He is not lying about being hungry. He will not fight + back unless physically grabbed and even then only flails. If treated with + any kindness, he becomes confused and cooperative. He has a small knife on + him but has never used it on a person. + memoryKey: "dal-thief-mardonar" + +goals: + hidden: true + primary: + - id: "catch" + label: > + Players physically catch or restrain Dal β€” tackle, grab, spell, or block + his escape route so he cannot run. + - id: "kill" + label: > + Players kill Dal. Only allow this after dramatic escalation β€” Dal must + have drawn his knife or threatened someone first. Apply sportsmanship + check before resolving. This is a valid but dark outcome. + - id: "bystander_chase" + label: > + Players successfully persuade or inspire a bystander to give chase + (Persuasion or Intimidation DC 12). The juggler is the most likely + candidate β€” young, fit, bored. The heavyset merchant will refuse. The + elderly couple will not move. + - id: "negotiate" + label: > + Players talk Dal down before he runs, or corner him and offer him + something (food, coin, mercy) that causes him to stop and surrender + voluntarily. Requires him to be cornered or slowed first. + + secondary: + - id: "escape" + label: > + Dal escapes into the crowd with the apple. Miriam is furious. The + encounter ends with no reward. Dal disappears into an alley. This is a + valid outcome β€” not every encounter ends in success. + - id: "guards_summoned" + label: > + Players alert the city guards at the far end of the square. Guards take + 1d4 rounds to arrive. By then Dal will likely have escaped unless players + slowed him. If guards arrive and Dal is caught, players receive no reward + but the city notes their cooperation. + +sportsmanshipRules: + - "No instant kills on a non-threatening, unarmed teenager without prior escalation." + - "No controlling another player character's actions or speaking for them." + - "No spells or abilities the player has not established owning in a prior scene." + - "No claiming information the character could not realistically know (Dal's name, history, etc.)." + - "No teleportation or flight without prior narrative establishment." + - > + If a player attempts something absurd or game-breaking, respond in-character + to redirect, or break character with: + "⚠️ That wasn't great sportsmanship. Let's keep it grounded β€” what would + your character realistically attempt here?" + +skillChecks: + chase_dc: 13 + chase_skill: "Athletics or Acrobatics (player's choice)" + chase_note: > + Dal is fast but panicked. A successful check closes the gap. Failure means + he gains distance. Two consecutive failures and he vanishes into the crowd. + + persuade_bystander_dc: 12 + persuade_bystander_skill: "Persuasion or Intimidation" + persuade_bystander_note: > + Targeting the juggler gives advantage (he's already watching with interest). + Targeting the merchant gives disadvantage (he's busy and dismissive). + + spot_hiding_dc: 10 + spot_hiding_skill: "Perception" + spot_hiding_note: > + If Dal ducks into a stall or behind a crowd. Success reveals his hiding spot. + + intimidate_dal_dc: 8 + intimidate_dal_skill: "Intimidation" + intimidate_dal_note: > + If Dal is cornered. Success causes him to drop the apple and freeze. + Failure causes him to lash out with a shove and run. + + persuade_dal_dc: 10 + persuade_dal_skill: "Persuasion" + persuade_dal_note: > + If a player offers Dal food, coin, or genuine kindness while he is cornered. + Success causes him to surrender and explain his situation. + +dmNotes: > + This encounter is intentionally low-stakes β€” a warm-up scene in a public + setting with no combat required. The goal is to establish player character + personalities and how they interact with a morally simple situation (hungry + kid steals food). There is no "correct" outcome. Lean into the crowd's + reactions. If players hesitate, have Miriam single one of them out directly. + Dal should feel like a person, not a target. diff --git a/Docs/stories/story-1.1-reaction-state-machine.md b/Docs/stories/story-1.1-reaction-state-machine.md new file mode 100644 index 0000000..c064bc4 --- /dev/null +++ b/Docs/stories/story-1.1-reaction-state-machine.md @@ -0,0 +1,102 @@ +--- +baseline_commit: NO_VCS +status: review +--- + +# Story 1.1: Emoji Reaction State Machine + +**Epic:** 1 β€” Interaction Feedback & Queue Management +**Status:** in-progress + +## Story + +As a player, +I want emoji reactions on my messages to show whether they were received and are being processed, +So that I always know the bot saw my message and what it is doing with it. + +## Acceptance Criteria + +**AC1:** Given a player sends a message in an active encounter thread, when the message is received by the bot's message handler, the bot adds a πŸ‘€ reaction to that message (fire-and-forget, within the same message-create handler path). + +**AC2:** Given the player's message is scheduled for LLM processing, when the LLM generation begins (inside the runner), the πŸ‘€ reaction is removed and ⏳ is added. + +**AC3:** Given the player's message content contains a roll-related keyword (roll, attack, check, save, saving throw, d20), when ⏳ is added, 🎲 is also added alongside it. + +**AC4:** Given the LLM response has been posted, when the generation runner's try block completes, ⏳ and 🎲 (if present) are removed and βœ… is added; βœ… is removed ~10 seconds later. + +**AC5:** Given the LLM call fails or throws, when the finally block executes, all in-progress reactions (⏳, 🎲) are removed. No stale state left. + +**AC6:** Reactions are applied only to player messages. Bot messages are never reacted to by the bot. + +**AC7:** All Discord reaction calls are fire-and-forget (errors caught and swallowed) β€” they must never cause the message handler to throw or block. + +## Tasks / Subtasks + +- [x] Task 1: Create `src/bot/handlers/reactionManager.ts` with pending map and lifecycle helpers + - [x] 1a: Write failing tests for `isDiceRelated()` and map operations (`registerScheduled`, `drainPending`, `clearPending`) + - [x] 1b: Implement the module + - [x] 1c: Write failing tests for `upgradeToProcessing`, `upgradeToComplete`, `cleanupReactions` with Discord mocks + - [x] 1d: Implement lifecycle functions + - [x] 1e: Run tests β€” confirm all pass + +- [x] Task 2: Wire `handleMessage` to add πŸ‘€ immediately on receipt + - [x] 2a: Add `message.react('πŸ‘€')` fire-and-forget in `handleMessage` for active sessions + - [x] 2b: Pass `message` (as optional `sourceMessage`) through `processEncounterMessage` to `scheduleEncounterLLMTurn` + +- [x] Task 3: Wire `scheduleEncounterLLMTurn` to manage reaction lifecycle + - [x] 3a: Add optional `sourceMessage?: Message` param to `scheduleEncounterLLMTurn` + - [x] 3b: Call `reactionManager.registerScheduled(threadId, sourceMessage)` when message provided + - [x] 3c: In runner: drain pending, `upgradeToProcessing`, run LLM, `upgradeToComplete` on success, `cleanupReactions` in finally on error + +- [x] Task 4: Run full test suite β€” confirm no regressions + +## Dev Notes + +**Architecture:** +- New file `src/bot/handlers/reactionManager.ts` owns the pending-message map and all Discord reaction calls +- `messageRouter.ts` imports from it; `generationQueue.ts` stays untouched +- Reactions are fire-and-forget throughout β€” NFR7 is absolute + +**Key design decisions:** +- πŸ‘€ is added in `handleMessage` for ALL messages in active sessions (even held/blocked) β€” honest "received" signal +- Only messages that actually reach `scheduleEncounterLLMTurn` get registered in the pending map (⏳/βœ… lifecycle) +- `pendingSkillCheck` guard in the runner clears the pending map without upgrading; πŸ‘€ stays as "received but waiting for your roll" +- `replayHeldMessages` and `rollHandler` call `scheduleEncounterLLMTurn` without a `sourceMessage` β€” no reactions managed (correct: these are programmatic, not player messages) + +**Reaction removal:** +Use `msg.reactions.cache.find(r => r.emoji.name === emoji)?.users.remove(botId).catch(() => null)` β€” reliable for unicode emoji added by the bot. + +**Dice detection heuristic:** +`/\b(?:roll|attack|check|save|saving throw|d20)\b/i` β€” applied to message content at ⏳-upgrade time. + +**Files:** +- `src/bot/handlers/reactionManager.ts` (new) +- `src/bot/handlers/messageRouter.ts` (modify: `handleMessage`, `processEncounterMessage`, `scheduleEncounterLLMTurn`) +- `tests/unit/reactionManager.test.ts` (new) + +## Dev Agent Record + +### Implementation Plan + +1. Create `reactionManager.ts` with testable pure functions +2. Wire πŸ‘€ into `handleMessage` +3. Thread `sourceMessage` through `processEncounterMessage` β†’ `scheduleEncounterLLMTurn` +4. Add lifecycle management inside the runner closure + +### Debug Log + +No issues β€” all 23 tests passed on first implementation run. + +### Completion Notes + +`reactionManager.ts` is a pure module (no Discord imports in the module-level logic) β€” the pending map holds Discord `Message` objects but the lifecycle functions take `PendingEntry[]`, making them mockable in tests. All Discord API calls are fire-and-forget. `generationQueue.ts` is untouched. Full suite: 267 tests, 0 failures. + +## File List + +- src/bot/handlers/reactionManager.ts (new) +- src/bot/handlers/messageRouter.ts (modified: imports, handleMessage, processEncounterMessage, scheduleEncounterLLMTurn) +- tests/unit/reactionManager.test.ts (new) + +## Change Log + +- 2026-05-30: Story 1.1 implemented β€” emoji reaction state machine (πŸ‘€ on receipt, ⏳/🎲 on processing, βœ… on complete, cleanup on error) diff --git a/Docs/stories/story-1.2-queue-cap-drop-notice.md b/Docs/stories/story-1.2-queue-cap-drop-notice.md new file mode 100644 index 0000000..7f2b69c --- /dev/null +++ b/Docs/stories/story-1.2-queue-cap-drop-notice.md @@ -0,0 +1,108 @@ +--- +baseline_commit: NO_VCS +status: review +--- + +# Story 1.2: Queue Cap with In-World Drop Notice + +**Epic:** 1 β€” Interaction Feedback & Queue Management +**Status:** in-progress + +## Story + +As a player, +I want to be told privately when my message wasn't processed due to message volume, +So that I know to wait and try again rather than wondering if the bot is broken. + +## Acceptance Criteria + +**AC1:** The burst queue enforces a hard cap of 2 messages per LLM-response cycle. A third (or later) message arriving before the LLM has responded is dropped β€” not added to session history, not scheduled for LLM processing. + +**AC2:** When a message is dropped by the cap, the πŸ‘€ reaction (added in Story 1.1) is removed β€” no ⏳ or βœ… is ever shown for dropped messages. + +**AC3:** When a message is dropped, the player receives a DM with the in-world drop notice. If the DM fails (DMs disabled), the notice is sent as a reply in the thread and auto-deleted after 8 seconds. + +**AC4:** The drop notice string is selected by the encounter's `tone` field (`spec.tone`). Defined keys: `grim`, `comedic`, `mysterious`, `tense`, and baseline (all other values including `undefined`). The LLM is never called to generate these strings. + +**AC5:** The burst counter resets after each LLM turn completes (success or error), opening a new burst window. + +**AC6:** `EncounterSpec` gains an optional `tone?: string` field in both the TypeScript interface and the Zod schema. The existing spec fixtures and tests must continue passing. + +## Tasks / Subtasks + +- [x] Task 1: Create `src/bot/handlers/queueCap.ts` with burst counter and drop notice logic + - [x] 1a: Write failing tests for `isBurstCapped`, `incrementBurst`, `resetBurst`, `getDropNotice` + - [x] 1b: Implement the module + - [x] 1c: Run tests β€” confirm all pass + +- [x] Task 2: Add `tone?: string` to `EncounterSpec` (interface + Zod schema) + - [x] 2a: Write failing test asserting `tone` is parsed from a spec YAML fixture + - [x] 2b: Add `tone?: z.string().optional()` to `EncounterSpecSchema` in `src/spec/loader.ts` + - [x] 2c: Add `tone?: string` to `EncounterSpec` interface in `src/types/index.ts` + - [x] 2d: Run tests β€” confirm they pass and existing spec tests don't regress + +- [x] Task 3: Wire cap check and drop notice into `processEncounterMessage` + - [x] 3a: Import `isBurstCapped`, `incrementBurst`, `sendDropNotice` from queueCap + - [x] 3b: After pending-roll check and before history append: if capped, remove πŸ‘€, send notice, return + - [x] 3c: After player gates but before history append: call `incrementBurst` + +- [x] Task 4: Wire burst reset into `scheduleEncounterLLMTurn` runner + - [x] 4a: Import `resetBurst` from queueCap + - [x] 4b: Call `resetBurst(threadId)` in the finally block of the runner (after LLM turn, success or error) + +- [x] Task 5: Run full test suite β€” confirm no regressions + +## Dev Notes + +**Burst window definition:** A "burst" is all messages received between consecutive LLM responses. Messages 1 and 2 are processed; message 3+ are dropped. The counter resets after each LLM turn fires (regardless of outcome). + +**Cap check placement:** The cap check goes in `processEncounterMessage` AFTER the player gate and the pending-roll checks. These early-return paths do NOT count toward the burst cap (the player isn't registered, or the system is waiting for a roll). Only messages that would be appended to the LLM history count. + +**EncounterSpec.tone:** Story 2.1 will inject `tone` into the LLM system prompt. Story 1.2 only needs it for drop notice selection. Both stories read from `spec.tone` β€” no `SessionState.tone` field needed here. + +**DM delivery:** Use `sourceMessage.author.send(text)` with a catch. On failure: `sourceMessage.reply({ content: text })` + delete after 8s. Text-only, no embed needed. + +**Drop notice strings (exact text per tone):** +- `grim`: `*"The chaos swallowed your words before they could reach the moment. Silence yourself until the echoes clear."*` +- `comedic`: `*"Everyone was talking at once and the universe, frankly, wasn't listening. Give it a moment."*` +- `mysterious`: `*"Something in the fabric of this place muffled your voice. Wait. It will pass."*` +- `tense`: `*"No time β€” the moment moved on without you. Hold. Wait for your opening."*` +- baseline: `*"The echoes of the encounter could not carry all voices at once. Wait for the dust to settle before speaking again."*` + +**Files:** +- `src/bot/handlers/queueCap.ts` (new) +- `src/spec/loader.ts` (modify: add `tone` to Zod schema) +- `src/types/index.ts` (modify: add `tone?: string` to `EncounterSpec`) +- `src/bot/handlers/messageRouter.ts` (modify: wire cap + reset) +- `tests/unit/queueCap.test.ts` (new) +- `tests/unit/specLoader.test.ts` (modify: add tone test) + +## Dev Agent Record + +### Implementation Plan + +1. Create `queueCap.ts` with pure burst-counter functions and tone-keyed strings +2. Add `tone?` to spec type/schema +3. Wire cap check into `processEncounterMessage` +4. Wire burst reset into runner + +### Debug Log + +- Initial cap test had "two increments is not capped" which contradicted AC1 (cap=2 means 3rd message dropped). Corrected test: after 2 increments `isBurstCapped` returns true. + +### Completion Notes + +12 tests in `queueCap.test.ts`, 2 new tests in `specLoader.test.ts`. Full suite: 281 tests, 0 failures. The burst counter, drop notices, and `tone` field are fully wired. Story 2.1 can add tone to the system prompt without touching this code. + +## File List + +- src/bot/handlers/queueCap.ts (new) +- src/spec/loader.ts (modified: added `tone` to Zod schema) +- src/types/index.ts (modified: added `tone?: string` to `EncounterSpec`) +- src/bot/handlers/messageRouter.ts (modified: imports, burst cap check + increment, burst reset in runner) +- tests/unit/queueCap.test.ts (new) +- tests/unit/specLoader.test.ts (modified: added 2 tone tests) + +## Change Log + +- 2026-05-30: Story 1.2 implemented β€” burst cap at 2, in-world DM drop notices, tone field on EncounterSpec diff --git a/Docs/stories/story-2.1-encounter-tone-config.md b/Docs/stories/story-2.1-encounter-tone-config.md new file mode 100644 index 0000000..94fa534 --- /dev/null +++ b/Docs/stories/story-2.1-encounter-tone-config.md @@ -0,0 +1,73 @@ +--- +baseline_commit: NO_VCS +status: review +--- + +# Story 2.1: Encounter Tone Configuration + +**Epic:** 2 β€” Encounter Tone Configuration +**Status:** in-progress + +## Story + +As a DM, +I want to set a `tone` field in my encounter YAML that controls how the bot sounds during that encounter, +So that a tense pursuit feels different from a comedic heist without touching code. + +## Acceptance Criteria + +**AC1:** Given `buildSystemPrompt()` is called with a spec that has `tone: "tense"` (or any non-empty string), the returned system prompt includes a `` block with the text: `Your narration style for this encounter is: {tone}. Let this flavor all of your responses, NPC voices, and pacing.` + +**AC2:** Given `buildSystemPrompt()` is called with a spec where `tone` is `undefined` or absent, no `` block appears in the returned system prompt. + +**AC3:** The `` block is inserted immediately after `` and before ``. + +**AC4:** The existing `buildSystemPrompt` signature is unchanged β€” `tone` is read from `spec.tone` directly. + +## Tasks / Subtasks + +- [x] Task 1: Add failing tests for tone block injection in `tests/unit/promptBuilder.test.ts` + - [x] 1a: Test that prompt contains `` block when spec has a tone value + - [x] 1b: Test that prompt contains the exact tone text + - [x] 1c: Test that `` appears before `` + - [x] 1d: Test that no `` block appears when tone is undefined + +- [x] Task 2: Implement `buildToneBlock(spec)` in `src/harness/promptBuilder.ts` and wire it in + - [x] 2a: Add `buildToneBlock` function + - [x] 2b: Insert at position 2 in the array (after `buildNarratorBlock`, before `buildSportsmanshipBlock`) + - [x] 2c: Run tests β€” confirm all pass + +- [x] Task 3: Run full test suite β€” confirm no regressions + +## Dev Notes + +**Insertion point:** The tone block goes second in the `buildSystemPrompt` array, between `buildNarratorBlock()` and `buildSportsmanshipBlock()`. The `.filter(Boolean)` call already handles the `undefined` case when tone is absent. + +**`spec.tone`** is already present on `EncounterSpec` (added in Story 1.2). No schema changes needed. + +**Files:** +- `src/harness/promptBuilder.ts` (modify) +- `tests/unit/promptBuilder.test.ts` (modify) + +## Dev Agent Record + +### Implementation Plan + +Single change: add `buildToneBlock` returning `...` when `spec.tone` is set, or empty string when absent. Wire it into the section array. + +### Debug Log + +No issues β€” straightforward insertion. + +### Completion Notes + +5 new tests added to `promptBuilder.test.ts`. Full suite: 286 tests, 0 failures. The `` block is inserted second in the system prompt array, after `` and before ``, using `.filter(Boolean)` to omit it cleanly when `spec.tone` is absent. + +## File List + +- src/harness/promptBuilder.ts (modified: added `buildToneBlock`, wired into section array) +- tests/unit/promptBuilder.test.ts (modified: 5 new tone block tests) + +## Change Log + +- 2026-05-30: Story 2.1 implemented β€” tone field injected into system prompt as `` block diff --git a/Docs/stories/story-3.1-player-pronouns.md b/Docs/stories/story-3.1-player-pronouns.md new file mode 100644 index 0000000..58cdcdd --- /dev/null +++ b/Docs/stories/story-3.1-player-pronouns.md @@ -0,0 +1,122 @@ +--- +baseline_commit: NO_VCS +status: review +--- + +# Story 3.1: Player Pronouns + +**Epic:** 3 β€” Player Pronouns +**Status:** in-progress + +## Story + +As a player, +I want to register my character's pronouns so the bot refers to my character correctly during encounters, +So that my character exists in the world on my terms. + +## Acceptance Criteria + +**AC1:** `/character register custom` shows a Discord modal (not inline slash args) with five text fields: Character Name (required), Pronouns (optional), Class (optional), Race (optional), Backstory (optional). + +**AC2:** On modal submit, all five values are saved to `CharacterProfile` in Redis. `pronouns` is stored as a free-text string or `undefined` when left blank. + +**AC3:** When a player sends their first message in an encounter thread, their `pronouns` (from `characterRegistry`) are loaded and stored on `session.players[userId].pronouns`. + +**AC4:** The LLM system prompt includes a `` block listing each active player's character name and pronouns (e.g. `Vex (she/her)`). Players without pronouns set are listed without a pronoun note. + +**AC5:** `/character show` displays a Pronouns field when the profile has pronouns set. + +## Tasks / Subtasks + +- [x] Task 1: Add `pronouns?: string` to `CharacterProfile` and `Player` types + - [x] 1a: Add to `CharacterProfile` in `src/session/characterRegistry.ts` + - [x] 1b: Add to `Player` in `src/types/index.ts` + - [x] 1c: Write and run test confirming `pronouns` round-trips through `characterRegistry` + +- [x] Task 2: Convert `/character register custom` to a modal + - [x] 2a: Remove all inline options from the `custom` subcommand in the slash command builder + - [x] 2b: `handleRegisterCustom` shows a 5-field modal (Name, Pronouns, Class, Race, Backstory) + - [x] 2c: Export `handleCustomRegisterModal` for modal submit handling + - [x] 2d: Wire `'character_custom_modal'` in `src/bot/index.ts` + - [x] 2e: Update `handleShow` to display Pronouns field when set + +- [x] Task 3: Load pronouns into session when player joins encounter + - [x] 3a: In `processEncounterMessage` (new-player path), call `characterRegistry.get()` and copy `pronouns` to `session.players[userId]` + +- [x] Task 4: Add `buildPlayersBlock` to system prompt + - [x] 4a: Write failing tests for `buildPlayersBlock` (with pronouns, without, empty) + - [x] 4b: Add `buildPlayersBlock(players)` function to `src/harness/promptBuilder.ts` + - [x] 4c: Add `players` as optional 4th param to `buildSystemPrompt`; insert block after `buildNpcsBlock` + - [x] 4d: Pass `session.players` from `contextAssembler.ts` + - [x] 4e: Run full test suite β€” confirm no regressions + +## Dev Notes + +**Modal custom ID:** `'character_custom_modal'` + +**Modal fields (in order):** +1. `char_name` β€” "Character Name" β€” required, short +2. `char_pronouns` β€” "Pronouns" β€” optional, short, placeholder `e.g. she/her, they/them` +3. `char_class` β€” "Class" β€” optional, short, placeholder `e.g. Wizard, Fighter` +4. `char_race` β€” "Race" β€” optional, short, placeholder `e.g. Elf, Human` +5. `char_backstory` β€” "Backstory" β€” optional, paragraph, maxLength 200 + +Level is dropped from the modal β€” not critical for narration, and Discord modals only support text inputs. + +**`buildPlayersBlock` output (when players have pronouns):** +``` + +Active player characters in this encounter: + - Vex (she/her) + - Thorin (he/him) + - Aelindra + +Use the specified pronouns when referring to these characters in narration. + +``` +Block is omitted entirely when `players` is empty (`filter(Boolean)` handles it). + +**`buildSystemPrompt` signature update:** add optional 4th param `players: Record = {}`. Existing callers pass nothing and get an empty players block (omitted). + +**Pronouns in session:** `session.players[userId]` already uses the `Player` type. Adding `pronouns?: string` to `Player` is backwards-compatible. + +**Files:** +- `src/session/characterRegistry.ts` (modify: add `pronouns` field) +- `src/types/index.ts` (modify: add `pronouns` to `Player`) +- `src/bot/commands/character.ts` (modify: modal conversion + show update) +- `src/bot/index.ts` (modify: add modal routing) +- `src/bot/handlers/messageRouter.ts` (modify: load pronouns on player join) +- `src/harness/promptBuilder.ts` (modify: add `buildPlayersBlock`, update signature) +- `src/harness/contextAssembler.ts` (modify: pass `session.players`) +- `tests/unit/characterRegistry.test.ts` (modify: add pronouns test) +- `tests/unit/promptBuilder.test.ts` (modify: add players block tests) + +## Dev Agent Record + +### Implementation Plan + +Type changes β†’ modal conversion β†’ session wiring β†’ prompt injection. + +### Debug Log + +No issues β€” all changes straightforward. + +### Completion Notes + +4 new tests in `characterRegistry.test.ts` (2 new), `promptBuilder.test.ts` (4 new players block tests). Full suite: 292 tests, 0 failures. Note: `/character register custom` command spec must be re-deployed to Discord via `scripts/deploy-commands.ts` for the modal to appear (slash command schema changed β€” inline options removed). + +## File List + +- src/session/characterRegistry.ts (modified: `pronouns?: string` on `CharacterProfile`) +- src/types/index.ts (modified: `pronouns?: string` on `Player`) +- src/bot/commands/character.ts (modified: modal conversion, `handleCustomRegisterModal` export, `handleShow` pronouns field) +- src/bot/index.ts (modified: `character_custom_modal` routing) +- src/bot/handlers/messageRouter.ts (modified: `characterRegistry` import, pronouns copied on player join) +- src/harness/promptBuilder.ts (modified: `buildPlayersBlock`, updated `buildSystemPrompt` signature) +- src/harness/contextAssembler.ts (modified: pass `session.players` to `buildSystemPrompt`) +- tests/unit/characterRegistry.test.ts (modified: 2 pronouns tests) +- tests/unit/promptBuilder.test.ts (modified: 4 players block tests) + +## Change Log + +- 2026-05-30: Story 3.1 implemented β€” pronouns in modal registration, session state, and system prompt diff --git a/Docs/stories/story-4.1-dice-roll-reliability.md b/Docs/stories/story-4.1-dice-roll-reliability.md new file mode 100644 index 0000000..cc8833d --- /dev/null +++ b/Docs/stories/story-4.1-dice-roll-reliability.md @@ -0,0 +1,97 @@ +--- +baseline_commit: NO_VCS +status: review +--- + +# Story 4.1: Strengthen Skill Check Tool Contract + +**Epic:** 4 β€” Dice Roll Reliability Fix +**Status:** in-progress + +## Story + +As a player, +I want the bot to always post the dice roll embed when a skill check is needed, +So that I never have to ask again after already declaring my action. + +## Acceptance Criteria + +**AC1:** Given a player message that implies a skill check, when the LLM processes the message, the LLM must output a `skill_check_emit` tool call rather than narrating the dice outcome itself. + +**AC2:** Given the LLM's response is parsed and contains no tool call, no pending skill check exists, and the narrative text contains skill-request phrases (e.g. "you need to roll", "roll for X", "make a check"), then the tool dispatcher / message router logs a warning `possible_missed_skill_check` via pino. No user-facing change. + +**AC3:** Given the system prompt `` block rendered by `buildToolManifest()`, when rendered, it includes an explicit negative instruction: "NEVER end your response with a request for the player to roll without emitting skill_check_emit in the same response." + +**AC4:** Given the system prompt `` block, when rendered, it includes at least one few-shot example demonstrating: player declares action β†’ LLM emits `skill_check_emit` (with narrative before the tool block). + +**AC5:** Integration test in `tests/unit/promptBuilder.test.ts` asserts the built prompt contains the negative instruction text and the few-shot example marker. + +## Tasks / Subtasks + +- [x] Task 1: Add negative instruction to `buildToolManifest()` in `src/harness/toolDispatcher.ts` + - [x] 1a: Write failing test asserting negative instruction string is present in built prompt + - [x] 1b: Add the instruction text to the SKILL CHECKS section of `buildToolManifest()` + - [x] 1c: Run test β€” confirm it passes + +- [x] Task 2: Add few-shot example to `buildToolManifest()` in `src/harness/toolDispatcher.ts` + - [x] 2a: Write failing test asserting a few-shot marker string is present in built prompt + - [x] 2b: Add the few-shot example block to `buildToolManifest()` + - [x] 2c: Run test β€” confirm it passes + +- [x] Task 3: Add `detectMissedSkillCheck()` to `src/bot/handlers/responseFilter.ts` + - [x] 3a: Write failing tests for the new detection function (true/false cases) + - [x] 3b: Implement `detectMissedSkillCheck(narrative: string): boolean` + - [x] 3c: Run tests β€” confirm they pass + +- [x] Task 4: Wire diagnostic warning in `src/bot/handlers/messageRouter.ts` + - [x] 4a: In `runLLMTurn()`, after response parse, call `detectMissedSkillCheck` when no tool call and no pending check + - [x] 4b: Log `log.warn('harness', 'possible_missed_skill_check', ...)` when detected + - [x] 4c: Run full test suite β€” confirm no regressions + +## Dev Notes + +**Root cause:** `gemma4-it:e2b` at e2b quantization inconsistently omits `skill_check_emit` when player actions imply a skill check. The LLM sometimes narrates "you'll need to roll" or "make a Dexterity check" and stops β€” without emitting the tool call. The player then has to repeat themselves. + +**Why the existing filter doesn't catch this:** `responseFilter.ts` catches the opposite failure (LLM fabricating a roll result like "you rolled a 15"). The new diagnostic catches the missing-tool case. + +**Key files:** +- `src/harness/toolDispatcher.ts` β€” `buildToolManifest()` builds the `` prompt block +- `src/bot/handlers/responseFilter.ts` β€” add `detectMissedSkillCheck()` here alongside the existing filters +- `src/bot/handlers/messageRouter.ts` β€” `runLLMTurn()` is where the response is parsed; wire the diagnostic here +- `tests/unit/promptBuilder.test.ts` β€” existing test file to extend +- `tests/unit/responseFilter.test.ts` β€” new test file for the detection function (if it doesn't exist) + +**Constraint:** The missed skill check detection is diagnostic only (log warning). It must NOT retry or inject a correction in this story β€” that's future scope. + +**Test approach:** Unit tests only β€” no live LLM required. Test that `buildToolManifest()` output contains the expected strings, and that `detectMissedSkillCheck()` returns correct booleans for known inputs. + +## Dev Agent Record + +### Implementation Plan + +- Strengthen the tool contract prompt in `buildToolManifest()` with a "NEVER" instruction +- Add a concise few-shot example showing the correct player-action β†’ tool-call sequence +- Expose `detectMissedSkillCheck()` from `responseFilter.ts` with regex-based heuristic +- Wire it into `runLLMTurn()` as a fire-and-forget diagnostic log + +### Debug Log + +- `requires a Strength check` failed initial regex (skill name between "a" and "check") β€” widened pattern to `requires\s+a\s+(?:\w+\s+)?(?:roll|check)`. + +### Completion Notes + +All 4 tasks complete. 13 new tests added (3 in promptBuilder, 13 in responseFilter). Full suite: 244 passing, 0 failures. + +Changes are prompt-engineering only for the LLM contract + one new diagnostic function. No breaking changes to any existing interface. + +## File List + +- src/harness/toolDispatcher.ts β€” strengthened SKILL CHECKS section with NEVER instruction + CORRECT/WRONG few-shot example +- src/bot/handlers/responseFilter.ts β€” added `MISSED_SKILL_CHECK_RE` regex + `detectMissedSkillCheck()` export +- src/bot/handlers/messageRouter.ts β€” imported `detectMissedSkillCheck` and `log`; wired diagnostic warn in `runLLMTurn()` +- tests/unit/promptBuilder.test.ts β€” added 3 tests for negative instruction and few-shot example +- tests/unit/responseFilter.test.ts β€” added 13 tests for `detectMissedSkillCheck()` + +## Change Log + +- 2026-05-30: Story 4.1 implemented β€” strengthened skill_check_emit tool contract; added missed-skill-check diagnostic diff --git a/Docs/ux-designs/ux-mardonar-2026-05-30/.decision-log.md b/Docs/ux-designs/ux-mardonar-2026-05-30/.decision-log.md new file mode 100644 index 0000000..7410256 --- /dev/null +++ b/Docs/ux-designs/ux-mardonar-2026-05-30/.decision-log.md @@ -0,0 +1,37 @@ +# Decision Log β€” Mardonar UX + +## Session 2026-05-30 + +### D-01 Β· Interaction feedback via emoji reactions +**Decision:** Use Discord emoji reactions as persistent state signals on player messages. +- `πŸ‘€` = received (bot sees the message) +- `⏳` = queued / processing +- `βœ…` = response posted (or reaction self-clears) +**Rationale:** Current typing indicator appears but response never posts β€” creates false hope. Reactions persist and self-clear, giving players durable feedback without polluting the channel. + +### D-02 Β· Message queue cap at 2 +**Decision:** During a burst of concurrent messages, the bot batches up to 2 messages into one LLM context. Messages beyond the cap are silently dropped from the queue (but not from the channel). +**Rationale:** Prevents runaway queue depth and undefined LLM behavior with too many simultaneous inputs. + +### D-03 Β· Ephemeral drop notice β€” in-world voice +**Decision:** Players whose messages were not batched receive a Discord ephemeral message (visible only to them) written in in-world language. +**Example flavor:** *"The echoes of the encounter could not carry all voices at once. Your words did not reach the moment β€” wait for the dust to settle before speaking again."* +**Rationale:** Ephemeral keeps the channel clean; in-world voice preserves immersion. + +### D-04 Β· Always in-world voice +**Decision:** All bot-generated text β€” including system feedback, errors, and config confirmations β€” uses in-world language. No utility/system strings are exposed to players. +**Rationale:** User explicitly stated preference. Private game with friends; immersion is a core value. + +### D-05 Β· Per-encounter tone config in YAML spec +**Decision:** Each encounter YAML spec gains a `tone` field that controls phrasing flavor for that encounter's bot responses. +**[ASSUMPTION]:** Tone is a string enum or free-text label (e.g. `"grim"`, `"comedic"`, `"tense"`, `"mysterious"`). The LLM uses this as a style instruction when generating narration and system messages. +**Rationale:** User wants customizability at the encounter level without touching code. + +### D-06 Β· Player-facing config via slash command +**Decision:** Players can configure personal preferences via a `/profile` slash command (or similar). +**[ASSUMPTION]:** Config fields include: character name display, pronouns, notification preferences. +**Rationale:** User wants player-facing customizability. Slash command is the most discoverable Discord pattern. + +### D-07 Β· Stakes and platform +**Decision:** Private game with friends. Hobby stakes. Discord-native only (no web dashboard, no external surface). +**Rationale:** Scopes the design β€” no need for onboarding flows, public discoverability, or accessibility for unknown user populations. diff --git a/Docs/ux-designs/ux-mardonar-2026-05-30/DESIGN.md b/Docs/ux-designs/ux-mardonar-2026-05-30/DESIGN.md new file mode 100644 index 0000000..73cd0da --- /dev/null +++ b/Docs/ux-designs/ux-mardonar-2026-05-30/DESIGN.md @@ -0,0 +1,131 @@ +--- +name: mardonar +version: "1.0" +status: final +updated: 2026-05-30 +colors: + encounter_active: "#5865F2" # Discord blurple β€” session is live + encounter_success: "#57F287" # Discord green β€” goal resolved + encounter_warning: "#FEE75C" # Discord yellow β€” attention needed + encounter_danger: "#ED4245" # Discord red β€” failure / danger state + encounter_neutral: "#2B2D31" # Discord dark β€” narration / ambient + encounter_mystery: "#9B59B6" # purple β€” hidden goals, secret events +typography: + primary: discord_markdown # bold, italic, code blocks β€” Discord's full markdown + narration: plain # plain prose, no markdown decoration + system_flavor: italic # in-world system messages rendered as *italic* + emphasis: bold # key terms, NPC names, goal labels +rounded: n/a +spacing: n/a +components: + reaction_received: "πŸ‘€" + reaction_queued: "⏳" + reaction_done: "βœ…" + reaction_dice: "🎲" + embed_encounter_start: see Components + embed_goal_update: see Components + embed_resolution: see Components + embed_dynamic_goal: see Components +--- + +## Brand & Style + +Mardonar is a private D&D encounter engine running inside Discord. It has no public face. The aesthetic is **dark fantasy immersion** β€” the bot is not a bot, it's the world speaking. Every string it emits should feel like it comes from within the fiction, not from a developer. + +Tone is encounter-scoped (see `tone` field in encounter YAML). Baseline tone is **grave and atmospheric**. Encounters may override to `comedic`, `tense`, `mysterious`, `grim`, or any free-text style directive. + +There are no logos, landing pages, or brand colors beyond what Discord renders. Visual identity lives entirely in embed color, emoji vocabulary, and prose voice. + +## Colors + +Four semantic states map to Discord's native embed left-border color: + +| Token | Hex | Use | +|---|---|---| +| `encounter_active` | `#5865F2` | Session is live, encounter in progress | +| `encounter_success` | `#57F287` | Goal resolved, encounter ended well | +| `encounter_warning` | `#FEE75C` | Partial success, complication, or attention needed | +| `encounter_danger` | `#ED4245` | Failure state, mortal danger, catastrophic outcome | +| `encounter_neutral` | `#2B2D31` | Ambient narration, lore drops, NPC flavor | +| `encounter_mystery` | `#9B59B6` | Hidden goal registered, secret event, revelation | + +Do not use color to convey information that isn't also conveyed in text β€” Discord users may be on light mode or have color vision differences. + +## Typography + +Discord constrains typography to markdown. Use the full palette deliberately: + +- **Bold** β€” NPC names on first appearance, goal labels, key terms +- *Italic* β€” in-world system messages, ephemeral notices, ambient flavor +- `Code` β€” dice results, mechanical values (DC, HP, damage) +- > Blockquote β€” NPC direct speech +- ~~Strikethrough~~ β€” goals that are no longer achievable, retracted options + +Never use headers (`#`, `##`) in bot messages β€” they read as out-of-world on mobile. + +## Components + +### Reaction Set β€” Message State Signals + +Applied to player messages to signal processing state. Self-clearing: `πŸ‘€` and `⏳` are removed when the response is posted. `βœ…` persists briefly then is removed (or left as confirmation). + +| State | Reaction | Meaning | +|---|---|---| +| Received | `πŸ‘€` | Bot sees the message, queued | +| Processing | `⏳` | Message included in current LLM context | +| Done | `βœ…` | Response posted, message processed | +| Dice | `🎲` | Dice roll detected, resolving | + +If a message is dropped (queue cap exceeded), no reaction is added β€” the ephemeral notice is the only signal. + +### Embed β€” Encounter Start + +``` +[color: encounter_active] +[title: {encounter.title}] +[description: {encounter.opening_narration}] +[fields] + Location: {encounter.setting.location} + Mood: {encounter.setting.mood} +[footer: The encounter begins. Speak freely.] +``` + +### Embed β€” Goal Update + +``` +[color: encounter_active or encounter_mystery for dynamic goals] +[title: A new path opens.] ← in-world, encounter-tone-aware +[description: {goal.label}] +[footer: This goal has been recorded in the hidden ledger.] +``` + +### Embed β€” Resolution + +``` +[color: encounter_success / encounter_warning / encounter_danger] +[title: {outcome-flavor from tone}] +[description: {resolution narration}] +[fields] + Outcome: {goal.label of resolved goal} + Path Taken: {outcomeId} +``` + +### Embed β€” Dynamic Goal Registered + +``` +[color: encounter_mystery] +[title: The threads of fate shift.] ← in-world +[description: {dynamic goal label, paraphrased in-world}] +``` + +## Do's and Don'ts + +**Do:** +- Use `encounter_mystery` color for any hidden or dynamic goal event +- Write ephemeral drop notices in *italic* to signal they are private/system +- Let the encounter `tone` field override baseline gravity β€” a `comedic` encounter should read differently from a `grim` one + +**Don't:** +- Use the word "bot," "system," "error," "rate limit," or "queue" in any player-facing string +- Use headers in embed descriptions β€” they break mobile rendering +- Add reactions to bot messages β€” reactions are only for player messages as state signals diff --git a/Docs/ux-designs/ux-mardonar-2026-05-30/EXPERIENCE.md b/Docs/ux-designs/ux-mardonar-2026-05-30/EXPERIENCE.md new file mode 100644 index 0000000..717264e --- /dev/null +++ b/Docs/ux-designs/ux-mardonar-2026-05-30/EXPERIENCE.md @@ -0,0 +1,233 @@ +--- +name: mardonar +version: "1.0" +status: final +updated: 2026-05-30 +--- + +# Mardonar Encounter Engine β€” EXPERIENCE.md + +Visual identity: see DESIGN.md. This document owns behavior, flows, states, and interactions. + +--- + +## Foundation + +**Form factor:** Discord (desktop and mobile). Single surface β€” the encounter channel. No web dashboard, no external UI. + +**UI system:** Discord's native component model β€” embeds, slash commands, emoji reactions, ephemeral messages, buttons (optional). No custom UI framework. + +**Constraints inherited from Discord:** +- Ephemeral messages are only visible to the recipient and cannot be edited after send +- Reactions can be added/removed by the bot programmatically +- Slash command responses can be deferred (show "thinking…") for up to 15 minutes +- Message content limit: 2000 characters; embed description limit: 4096 characters + +--- + +## Information Architecture + +Three surfaces, one channel: + +| Surface | Visibility | Owner | +|---|---|---| +| **Encounter channel** | All players | Bot + players | +| **Ephemeral notice** | Individual player only | Bot | +| **Player profile** (`/profile`) | Player only (ephemeral response) | Player | + +[ASSUMPTION] DM-facing encounter config lives in the YAML spec file and is not surfaced as a Discord command. The DM edits YAML, not Discord. + +### Encounter Channel + +Contains: opening embed, player messages, bot narration, goal update embeds, resolution embed. This is the entire game world. + +### Ephemeral Notices + +Transient private messages delivered to a specific player. Used for: +- Drop notice (message not batched, queue cap exceeded) +- Profile confirmation (when `/profile` is set) +- [ASSUMPTION] Dice clarification if a roll result is ambiguous + +### Player Profile (`/profile`) + +A slash command players use to set personal preferences. Stored per Discord user ID. Fields: +- `name` β€” character name displayed in bot narration (default: Discord display name) +- `pronouns` β€” used when bot narrates player actions (default: they/them) +- [ASSUMPTION] `notify` β€” whether to receive ephemeral drop notices (default: on) + +--- + +## Voice and Tone + +All player-facing strings are in-world. The bot is the world speaking. See {DESIGN.md > Brand & Style} for baseline tone rules. + +**Per-encounter tone** is set via the `tone` field in the encounter YAML. The LLM uses this as a style directive for all narration and in-world system messages generated during that encounter. When `tone` is absent, default to grave/atmospheric. + +**Tone examples and their flavor effect:** + +| Tone value | Ephemeral drop notice flavor | +|---|---| +| `grim` | *"The chaos swallowed your words before they could reach the moment. Silence yourself until the echoes clear."* | +| `comedic` | *"Everyone was talking at once and the universe, frankly, wasn't listening. Give it a moment."* | +| `mysterious` | *"Something in the fabric of this place muffled your voice. Wait. It will pass."* | +| `tense` | *"No time β€” the moment moved on without you. Hold. Wait for your opening."* | +| baseline | *"The echoes of the encounter could not carry all voices at once. Wait for the dust to settle before speaking again."* | + +The LLM generates these dynamically using the tone directive β€” the table above is illustrative, not hardcoded strings. + +--- + +## Component Patterns + +### Reaction State Machine (behavioral) + +Visual specs: {DESIGN.md > Components > Reaction Set} + +Applied to **player messages only**. Bot messages never receive bot-added reactions. + +State transitions: + +``` +[message posted by player] + ↓ + add πŸ‘€ (received) + ↓ + [within batch window? ≀ 2 messages in burst?] + β”œβ”€β”€ YES β†’ remove πŸ‘€, add ⏳ (processing) + β”‚ ↓ + β”‚ [LLM responds] + β”‚ ↓ + β”‚ remove ⏳, add βœ… (done) β†’ remove βœ… after 10s + β”‚ + └── NO β†’ remove πŸ‘€ (no further reaction) + ↓ + send ephemeral drop notice to player +``` + +If a dice roll is detected in the message, add `🎲` alongside `⏳` for the duration of processing. + +### Ephemeral Drop Notice (behavioral) + +Triggered when: player message arrives and queue is already at 2 messages. + +Delivery: Discord ephemeral reply to the player's message. Tone-aware (see Voice and Tone above). The LLM does **not** generate this at runtime β€” it is a pre-generated string selected by tone key, to avoid a catch-22 where the rate-limited system tries to invoke the LLM to generate its own rate-limit message. + +[NOTE FOR UX] Confirm with dev: is the `tone` value accessible synchronously at the point where the drop notice is sent, or does it require a Redis lookup? If lookup adds latency risk, pre-cache tone string at session start. + +### Player Profile Command + +``` +/profile name:"Thorin Ironfist" pronouns:"he/him" +``` + +Response: ephemeral confirmation in-world, e.g.: +*"The ledger of {name} has been updated. The world knows you as you wish to be known."* + +Fields are optional β€” omitted fields retain their previous value. First-time users who have never run `/profile` get defaults (`name` = Discord display name, `pronouns` = they/them). + +--- + +## State Patterns + +### Session States + +| State | What players see | Reactions active? | +|---|---|---| +| **Idle** (no encounter) | Nothing / last message | No | +| **Active** (encounter running) | Bot narrating, reacting | Yes | +| **Processing** (LLM working) | `⏳` on queued messages | Yes | +| **Rate-limited / at cap** | Ephemeral drop notices sent | πŸ‘€ added then removed | +| **Resolved** | Resolution embed posted | No (session over) | + +### Goal States + +| State | Visual signal | +|---|---| +| Active (spec goal) | Listed in opening embed | +| Active (dynamic goal) | `encounter_mystery` embed posted | +| Resolved | Resolution embed, `encounter_success/warning/danger` color | +| Abandoned | [ASSUMPTION] No visual signal β€” silently dropped | + +--- + +## Interaction Primitives + +**Message burst handling:** +- Batch window: [ASSUMPTION] 1.5–3 seconds after first message in burst +- Cap: 2 messages per batch +- Messages 3+ in a burst: `πŸ‘€` added and removed, ephemeral drop sent +- After response posts: new batch window opens + +**Slash commands available to players:** +- `/profile` β€” set name and pronouns +- [ASSUMPTION] `/goals` β€” view currently active goals (ephemeral, in-world flavor) + +**No buttons required** for current scope. Buttons may be added in future for things like "confirm action" or "roll again" β€” out of scope for this design. + +--- + +## Accessibility Floor + +Discord handles most accessibility at the platform level (screen reader support, high contrast mode, font scaling). Bot-specific floor: + +- Color is never the **only** signal β€” every embed color has a corresponding text label or description +- Reactions are supplemental signals, not the only channel β€” the response itself always narrates what happened +- Ephemeral notices are text-only (no embed required) for maximum compatibility +- In-world language must still be **legible** β€” poetic phrasing should not obscure what the player needs to do (e.g. "wait before sending more" must be inferable) + +--- + +## Key Flows + +### Flow 1 β€” Rowena sends a message during a busy moment + +*Rowena (she/her) is playing in "The Velvet Auction." Three players are active. She types a message just as two others arrive simultaneously.* + +1. Rowena's message posts in the encounter channel. +2. Bot adds `πŸ‘€` to her message β€” she can see her words were received. +3. Two messages are already in the batch. Queue is full. +4. Bot removes `πŸ‘€` from Rowena's message. No `⏳` is added. +5. Rowena receives an ephemeral message (tense tone): *"No time β€” the moment moved on without you. Hold. Wait for your opening."* +6. The other two messages get `⏳`. LLM responds. `⏳` clears, `βœ…` briefly appears. +7. Rowena tries again. This time she's in the next batch. + +**Climax beat:** Rowena sees the ephemeral notice and holds back β€” she doesn't flood the channel. When the next beat opens, her message lands. + +--- + +### Flow 2 β€” Skeeter rolls dice and waits + +*Skeeter is attempting to pick a lock (DC 14). He types "I roll Dexterity."* + +1. Bot adds `πŸ‘€` then `⏳` + `🎲` to his message. +2. LLM detects dice intent, calls the dice tool, gets result. +3. LLM narrates the outcome inline β€” no separate prompt needed. +4. `⏳` and `🎲` clear. `βœ…` briefly appears. +5. Skeeter reads the outcome in the bot's response. + +**Climax beat:** The result arrives without Skeeter having to ask. The dice reaction telegraphed that something was resolving. + +--- + +### Flow 3 β€” DM sets up "The Mawfang Pursuit" with a tense tone + +*DM edits `mawfang-pursuit.yaml` and adds `tone: "tense"` to the spec.* + +1. Encounter starts. Bot's opening embed posts. +2. All narration during this encounter uses tense phrasing. +3. When a player gets dropped, their ephemeral reads: *"No time β€” the moment moved on without you. Hold. Wait for your opening."* +4. Dynamic goal registered β€” embed reads in a clipped, urgent voice. + +**Climax beat:** The tone field did its job β€” the encounter *felt* different from "The Velvet Auction" without any code change. + +--- + +### Flow 4 β€” Pary sets up her player profile + +*Pary just joined the server. She wants the bot to use her character name "Vex" and she/her pronouns.* + +1. Pary runs `/profile name:"Vex" pronouns:"she/her"`. +2. Bot responds with an ephemeral confirmation: *"The ledger has been updated. The world knows Vex as she wishes to be known."* +3. In subsequent encounters, bot narration refers to her as Vex and uses she/her. + +**Climax beat:** Pary's character exists in the world on her terms, without anyone else needing to configure it. diff --git a/README.md b/README.md new file mode 100644 index 0000000..df2733b --- /dev/null +++ b/README.md @@ -0,0 +1,315 @@ +# Mardonar Encounter Engine + +A Discord-native, LLM-driven D&D encounter system for the Land of Mardonar. +Discord threads are encounter sessions. The LLM narrates, voices NPCs, tracks +hidden goals, emits skill checks, and logs everything to Neo4j. + +--- + +## Stack + +| Layer | Technology | +|---|---| +| Discord bot | discord.js v14 | +| Language | TypeScript (Node.js, ESM) | +| LLM | gemma4-it:e2b via Ollama | +| Session cache | Redis (ioredis) | +| Persistence | Neo4j 5 (neo4j-driver) | +| Schema validation | Zod | +| Test runner | Vitest | + +--- + +## Prerequisites + +- Node.js 20+ +- Docker + Docker Compose (for local Redis and Neo4j) +- Ollama running on your network with `gemma4-it:e2b` pulled +- A Discord bot token and application ID + +--- + +## Quick Start + +### 1. Clone and install + +```bash +git clone +cd mardonar-bot +npm install +``` + +### 2. Configure environment + +```bash +cp .env.example .env +``` + +Edit `.env`: + +```env +DISCORD_TOKEN=your_discord_bot_token +DISCORD_CLIENT_ID=your_discord_application_id + +REDIS_URL=redis://localhost:6379 + +NEO4J_URI=bolt://localhost:7687 +NEO4J_USER=neo4j +NEO4J_PASSWORD=mardonardev + +# Point at your Ollama node β€” can be a LAN IP +OLLAMA_BASE_URL=http://192.168.1.x:11434 +OLLAMA_MODEL=gemma4-it:e2b + +LOG_LEVEL=debug +``` + +### 3. Start local services + +```bash +docker compose -f docker-compose.dev.yml up -d +``` + +This starts Redis on `localhost:6379` and Neo4j on `localhost:7687`. +Neo4j browser UI is available at `http://localhost:7474` (login: neo4j / mardonardev). + +### 4. Register Discord slash commands + +Run once per bot deployment, or whenever commands change: + +```bash +npm run deploy-commands +``` + +### 5. Start the bot + +```bash +npm run dev # development (tsx watch mode) +npm run build # compile TypeScript +npm run start # run compiled output +``` + +--- + +## Project Structure + +``` +mardonar-bot/ +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ bot/ +β”‚ β”‚ β”œβ”€β”€ commands/ +β”‚ β”‚ β”‚ β”œβ”€β”€ dndname.ts # /dndname set|show|clear +β”‚ β”‚ β”‚ └── encounter.ts # /encounter start|status|end +β”‚ β”‚ β”œβ”€β”€ embeds/ +β”‚ β”‚ β”‚ β”œβ”€β”€ skillCheck.ts # Skill check embed builder +β”‚ β”‚ β”‚ β”œβ”€β”€ playerGate.ts # "Please register your name" embed +β”‚ β”‚ β”‚ └── resolution.ts # Encounter complete embed +β”‚ β”‚ └── handlers/ +β”‚ β”‚ └── messageRouter.ts # Main event loop for encounter threads +β”‚ β”œβ”€β”€ session/ +β”‚ β”‚ β”œβ”€β”€ playerRegistry.ts # Redis: discordId β†’ dndName +β”‚ β”‚ └── sessionManager.ts # Redis: threadId β†’ SessionState +β”‚ β”œβ”€β”€ harness/ +β”‚ β”‚ β”œβ”€β”€ promptBuilder.ts # System prompt assembly +β”‚ β”‚ β”œβ”€β”€ contextAssembler.ts # History + token budget management +β”‚ β”‚ β”œβ”€β”€ ollamaClient.ts # Ollama API client +β”‚ β”‚ β”œβ”€β”€ toolParser.ts # tool_call block detection and parsing +β”‚ β”‚ └── toolDispatcher.ts # Routes parsed tool calls to handlers +β”‚ β”œβ”€β”€ mcp/ +β”‚ β”‚ β”œβ”€β”€ server.ts # MCP server setup (@modelcontextprotocol/sdk) +β”‚ β”‚ └── tools/ +β”‚ β”‚ β”œβ”€β”€ skillCheckEmit.ts +β”‚ β”‚ β”œβ”€β”€ skillCheckResolve.ts +β”‚ β”‚ β”œβ”€β”€ eventLogAppend.ts +β”‚ β”‚ β”œβ”€β”€ npcMemoryRead.ts +β”‚ β”‚ β”œβ”€β”€ npcMemoryWrite.ts +β”‚ β”‚ └── encounterResolve.ts +β”‚ β”œβ”€β”€ db/ +β”‚ β”‚ β”œβ”€β”€ redis.ts # ioredis singleton +β”‚ β”‚ └── neo4j.ts # neo4j-driver singleton + runQuery helper +β”‚ β”œβ”€β”€ spec/ +β”‚ β”‚ └── loader.ts # YAML spec loader + Zod validation +β”‚ β”œβ”€β”€ config.ts # Zod-validated env vars +β”‚ └── types/ +β”‚ └── index.ts # All shared TypeScript interfaces +β”œβ”€β”€ specs/ +β”‚ └── market-thief.yaml # Example encounter spec +β”œβ”€β”€ tests/ +β”‚ β”œβ”€β”€ unit/ +β”‚ └── integration/ +β”œβ”€β”€ scripts/ +β”‚ └── deploy-commands.ts # Registers slash commands with Discord +β”œβ”€β”€ docker-compose.dev.yml +β”œβ”€β”€ package.json +β”œβ”€β”€ tsconfig.json +└── vitest.config.ts +``` + +--- + +## Discord Commands + +### Player commands + +| Command | Description | +|---|---| +| `/dndname set ` | Register or update your D&D character name | +| `/dndname show` | Show your current registered name | +| `/dndname clear` | Remove your registration | + +Players must register before they can participate in an encounter. +If an unregistered player posts in an encounter thread, the bot sends +an ephemeral embed asking them to register first. + +### DM / Admin commands + +| Command | Description | +|---|---| +| `/encounter start ` | Load a spec and open a new encounter thread | +| `/encounter status` | Show session phase, player list, event count | +| `/encounter end` | Force-resolve the encounter (admin override) | + +`` maps to a file in `./specs/`. E.g. `/encounter start market-thief` +loads `./specs/market-thief.yaml`. + +--- + +## Encounter Specs + +Encounters are defined as YAML files in the `./specs/` directory. +Each spec defines the setting, NPC personas, hidden goals, and skill check DCs. + +See `specs/market-thief.yaml` for a fully annotated example. + +### Key fields + +```yaml +encounterId: # Unique ID β€” used as Neo4j node key +title: # Display name shown in Discord embeds +setting: # location, mood, ambientNpcs (all strings) +openingNarrative: # The scene-setting text posted at session start +npcs: # 1–3 personas. Each has id, name, role, persona, optional memoryKey +goals: + primary: # Main target endings the LLM steers toward + secondary: # Valid but non-primary outcomes +sportsmanshipRules: # List of "do not allow" rules +skillChecks: # Named DCs e.g. chase_dc: 13 +``` + +--- + +## How a Session Works + +``` +1. DM runs /encounter start +2. Bot creates a Discord thread and posts the opening narrative +3. Players post what their character does +4. Bot appends the message to session history, calls Ollama +5. Ollama response is parsed: + - Narrative text β†’ posted to the thread + - tool_call block β†’ dispatched silently +6. Tool results (skill check embeds, Neo4j writes) happen automatically +7. When a goal is reached, LLM calls encounter_resolve +8. Bot posts the resolution embed and archives the thread +``` + +### Skill checks + +When the LLM determines a roll is warranted, it emits a `skill_check_emit` +tool call. The bot posts a Discord embed: + +``` +🎲 Skill Check β€” Aelindra +What skill are you using to chase Dal? +DC: 13 +Reply with: "I rolled a [number] [Skill Name]" +``` + +The player replies naturally, e.g. `"I rolled a 17 Acrobatics"`. The bot +detects this, logs the result, and feeds it back to the LLM as context. + +--- + +## Context Window Budget + +The harness manages a strict token budget for the 128k context window: + +| Zone | Budget | +|---|---| +| System prompt (narrator + NPCs + goals + tools) | 4,000 tokens | +| Pinned messages (opening narrative, never trimmed) | 2,000 tokens | +| Sliding history window | 118,000 tokens | +| Safety buffer | 3,500 tokens | + +When history exceeds the sliding window budget, the oldest non-pinned +turn pairs are dropped from the front. The opening narrative and goal +block are always preserved. + +--- + +## NPC Memory + +Named NPCs with a `memoryKey` in their spec have persistent memory in Neo4j. +At session start, their memory facts are loaded and injected into the system +prompt. At encounter resolution, any `npc_memory_write` tool calls are +committed to the graph. + +This means a NPC like Miriam can remember that your party helped her +in a previous encounter β€” or that you let the thief go. + +--- + +## Running Tests + +```bash +npm run test # run all tests +npm run test:unit # unit tests only (no external services) +npm run test:int # integration tests (requires Docker services running) +``` + +Integration tests require live Redis and Neo4j. Start them first: + +```bash +docker compose -f docker-compose.dev.yml up -d +npm run test:int +``` + +--- + +## Adding a New Encounter + +1. Copy `specs/market-thief.yaml` to `specs/your-encounter.yaml` +2. Fill in all required fields +3. Test spec loading: `npm run validate-spec your-encounter` +4. Run `/encounter start your-encounter` in Discord + +--- + +## Deployment Notes + +The bot is a single Node.js process. It connects to: +- Redis (ioredis) for session and player registry +- Neo4j (neo4j-driver) for NPC memory and event logs +- Ollama over HTTP for LLM inference +- Discord over WebSocket (discord.js) + +For production on your Proxmox node, run as a Docker container or +systemd service. Point `OLLAMA_BASE_URL` at whichever node is running +your Ollama instance. + +```bash +# Build +npm run build + +# Run (with .env file) +node dist/bot/index.js +``` + +--- + +## Architecture Documents + +Full design rationale and phased build plan are in `docs/`: + +- `docs/mardonar-encounter-engine.md` β€” system overview and key decisions +- `docs/mardonar-build-plan.md` β€” phased build plan with packages and test guidance diff --git a/data/summaries/cog-claw-debt-001-2026-05-26T03-26-55-854Z.txt b/data/summaries/cog-claw-debt-001-2026-05-26T03-26-55-854Z.txt new file mode 100644 index 0000000..4214936 --- /dev/null +++ b/data/summaries/cog-claw-debt-001-2026-05-26T03-26-55-854Z.txt @@ -0,0 +1,9 @@ +Encounter : The Collector's Due +ID : cog-claw-debt-001 +Thread : 1508671535840493609 +Date : 2026-05-26T03:26:55.854Z +Outcome : gorga_attacked +Players : Angro + +Summary: +Angro shot Gorga at close range with a silenced firearm in the back alley behind the Anvil & Tallow smithy. Gorga died against the wall, his ledger falling open in the blood. Terren Ashweld is now complicit in the death of a Cog Claw Consortium collector. The debt remains unpaid, the Consortium will retaliate, and a body now lies in Mardonar that will be discovered before nightfall. Angro and Terren are both marked. \ No newline at end of file diff --git a/data/summaries/mardonar-market-thief-001-2026-05-24T04-42-39-336Z.txt b/data/summaries/mardonar-market-thief-001-2026-05-24T04-42-39-336Z.txt new file mode 100644 index 0000000..5b2c119 --- /dev/null +++ b/data/summaries/mardonar-market-thief-001-2026-05-24T04-42-39-336Z.txt @@ -0,0 +1,9 @@ +Encounter : The Market Square Thief +ID : mardonar-market-thief-001 +Thread : 1507959432414236785 +Date : 2026-05-24T04:42:39.337Z +Outcome : admin_end +Players : Boris + +Summary: +Encounter ended by admin: sirhaxolot \ No newline at end of file diff --git a/data/summaries/mardonar-market-thief-001-2026-05-24T05-31-37-164Z.txt b/data/summaries/mardonar-market-thief-001-2026-05-24T05-31-37-164Z.txt new file mode 100644 index 0000000..9ee3f5d --- /dev/null +++ b/data/summaries/mardonar-market-thief-001-2026-05-24T05-31-37-164Z.txt @@ -0,0 +1,9 @@ +Encounter : The Market Square Thief +ID : mardonar-market-thief-001 +Thread : 1507967141750636544 +Date : 2026-05-24T05:31:37.165Z +Outcome : +Players : Boris + +Summary: +Boris calmed the situation, returned the stolen item to Miriam, and persuaded Dal to meet at his camp later for guidance regarding his path. \ No newline at end of file diff --git a/data/summaries/mardonar-market-thief-001-2026-05-26T03-22-13-663Z.txt b/data/summaries/mardonar-market-thief-001-2026-05-26T03-22-13-663Z.txt new file mode 100644 index 0000000..1fa43d0 --- /dev/null +++ b/data/summaries/mardonar-market-thief-001-2026-05-26T03-22-13-663Z.txt @@ -0,0 +1,9 @@ +Encounter : The Market Square Thief +ID : mardonar-market-thief-001 +Thread : 1508652166892753096 +Date : 2026-05-26T03:22:13.663Z +Outcome : escape +Players : Angro, Boris + +Summary: +Angro and Boris failed to pursue or stop Dal after he stole an apple from Miriam's stall. Multiple attempts to escalate to lethal force were rejected per sportsmanship rules. Dal escaped into the crowd with the apple. Miriam was left furious and disappointed. \ No newline at end of file diff --git a/data/summaries/mardonar-market-thief-001-2026-05-26T06-36-03-308Z.txt b/data/summaries/mardonar-market-thief-001-2026-05-26T06-36-03-308Z.txt new file mode 100644 index 0000000..8c73598 --- /dev/null +++ b/data/summaries/mardonar-market-thief-001-2026-05-26T06-36-03-308Z.txt @@ -0,0 +1,9 @@ +Encounter : The Market Square Thief +ID : mardonar-market-thief-001 +Thread : 1508671514524909638 +Date : 2026-05-26T06:36:03.308Z +Outcome : admin_end +Players : Zalram + +Summary: +Zalram Cloudwalker, a spellcaster, chased a young hooded thief named Dal after he stole an apple from Miriam's stall in Mardonar's Market Square food festival. Zalram initially failed to cast Mage Hand and an unnamed spell, but after confirming Boomering was in his spellbook, he cast the spell at Dal as the thief fled into an alley. The record ends before the spell attack's result is determined, leaving the encounter unresolved. \ No newline at end of file diff --git a/data/summaries/mardonar-market-thief-001-2026-05-26T22-12-04-810Z.txt b/data/summaries/mardonar-market-thief-001-2026-05-26T22-12-04-810Z.txt new file mode 100644 index 0000000..9772c39 --- /dev/null +++ b/data/summaries/mardonar-market-thief-001-2026-05-26T22-12-04-810Z.txt @@ -0,0 +1,9 @@ +Encounter : The Market Square Thief +ID : mardonar-market-thief-001 +Thread : 1508948971119444160 +Date : 2026-05-26T22:12:04.810Z +Outcome : negotiate +Players : Zalram Cloudwalker + +Summary: +Zalram Cloudwalker cornered the young thief Brek using an Arcane Anomaly illusion and chose not to harm him. He offered to pay for the stolen apple on the condition that Brek returns weekly to work for Berta the vendor. Berta agreed to give Brek honest work and an apple a day. Brek surrendered voluntarily, accepted the arrangement, and the encounter ended peacefully with no violence or guard involvement. \ No newline at end of file diff --git a/data/summaries/mawfang-pursuit-001-2026-05-24T07-59-27-624Z.txt b/data/summaries/mawfang-pursuit-001-2026-05-24T07-59-27-624Z.txt new file mode 100644 index 0000000..145031e --- /dev/null +++ b/data/summaries/mawfang-pursuit-001-2026-05-24T07-59-27-624Z.txt @@ -0,0 +1,9 @@ +Encounter : Unwelcome Guests +ID : mawfang-pursuit-001 +Thread : 1508014981726208080 +Date : 2026-05-24T07:59:27.624Z +Outcome : admin_end +Players : Boris + +Summary: +The character Boris entered the Silt & Sow inn, where he encountered a group of intimidating Mawfang hunters. When Boris attempted to depart, one hunter named Usk questioned his relevance in the district and directed attention toward another cloaked figure concealed within the room. Ultimately, Boris chose to leave the town area with his party rather than engage further with the hunters. \ No newline at end of file diff --git a/data/summaries/mawfang-pursuit-001-2026-05-26T03-37-54-540Z.txt b/data/summaries/mawfang-pursuit-001-2026-05-26T03-37-54-540Z.txt new file mode 100644 index 0000000..82a86bf --- /dev/null +++ b/data/summaries/mawfang-pursuit-001-2026-05-26T03-37-54-540Z.txt @@ -0,0 +1,9 @@ +Encounter : Unwelcome Guests +ID : mawfang-pursuit-001 +Thread : 1508671607844114464 +Date : 2026-05-26T03:37:54.540Z +Outcome : hunters_confronted +Players : Angro + +Summary: +Angro attacked Usk three times with a hammer. Usk disarmed him, pinned him to the wall, then took the gnome Fizbet and her device out of the inn into the rain. The hunters left Angro alive on the floor. Usk promised nothing β€” but this is not finished. The device leaves with the Mawfang. \ No newline at end of file diff --git a/data/tally.json b/data/tally.json new file mode 100644 index 0000000..562f94b --- /dev/null +++ b/data/tally.json @@ -0,0 +1,22 @@ +{ + "market-thief": { + "runs": 4, + "lastRun": "2026-05-26T21:44:33.947Z" + }, + "mawfang-pursuit": { + "runs": 2, + "lastRun": "2026-05-26T03:22:23.938Z" + }, + "cog-claw-debt": { + "runs": 3, + "lastRun": "2026-05-26T03:22:19.935Z" + }, + "stormscar-pilgrim": { + "runs": 1, + "lastRun": "2026-05-26T06:37:00.697Z" + }, + "silt-leak": { + "runs": 1, + "lastRun": "2026-05-30T03:07:28.390Z" + } +} \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..3382b67 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,40 @@ +version: "3.9" + +# Mardonar Encounter Engine +# +# docker compose up -d --build +# 1. Builds the image +# 2. Runs deploy-commands (registers slash commands with Discord) +# 3. Starts the bot +# +# Joins the knowledge-graph-ai_internal network so it can reach: +# redis β†’ redis://redis:6379 +# mcp-server β†’ http://mcp-server:9000 +# Both containers live in the GraphMCP-Example stack β€” start that first. + +networks: + mardonar-internal: + external: true + +services: + deploy-commands: + build: . + container_name: mardonar-deploy-commands + restart: "no" + env_file: .env + networks: + - mardonar-internal + command: ["node", "dist/scripts/deploy-commands.js"] + + bot: + build: . + container_name: mardonar-bot + restart: unless-stopped + env_file: .env + networks: + - mardonar-internal + volumes: + - ./data:/app/data + depends_on: + deploy-commands: + condition: service_completed_successfully diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..6cc6a5b --- /dev/null +++ b/index.ts @@ -0,0 +1,225 @@ +// src/types/index.ts +// Shared types used across all layers of the Mardonar Encounter Engine. +// Import from here only β€” do not duplicate type definitions elsewhere. + +// --------------------------------------------------------------------------- +// Players +// --------------------------------------------------------------------------- + +export interface Player { + discordId: string; + dndName: string; +} + +// --------------------------------------------------------------------------- +// Encounter Spec +// --------------------------------------------------------------------------- + +export interface NpcPersona { + /** Unique stable ID used as Neo4j node key. e.g. "miriam-vendor-mardonar" */ + id: string; + name: string; + role: string; + /** Full persona description injected into the system prompt. */ + persona: string; + /** + * If set, NPC memories are loaded from Neo4j at session start + * and written back on encounter_resolve. + */ + memoryKey?: string; +} + +export interface EncounterGoal { + id: string; + label: string; +} + +export interface EncounterGoals { + hidden: boolean; + primary: EncounterGoal[]; + secondary: EncounterGoal[]; +} + +export interface EncounterSetting { + location: string; + mood: string; + ambientNpcs: string; +} + +export interface EncounterSpec { + encounterId: string; + title: string; + setting: EncounterSetting; + openingNarrative: string; + npcs: NpcPersona[]; + goals: EncounterGoals; + sportsmanshipRules: string[]; + /** Skill check DCs and notes keyed by check name e.g. "chase_dc" β†’ 13 */ + skillChecks: Record; + dmNotes?: string; +} + +// --------------------------------------------------------------------------- +// Session State +// --------------------------------------------------------------------------- + +export type SessionPhase = 'open' | 'active' | 'resolved'; + +export interface SessionState { + encounterId: string; + /** Discord thread snowflake ID β€” used as the primary session key in Redis. */ + threadId: string; + guildId: string; + spec: EncounterSpec; + /** Map of discordId β†’ Player for all players who have entered the session. */ + players: Record; + history: ChatMessage[]; + phase: SessionPhase; + /** Messages held while waiting for a player to register their DnD name. */ + heldMessages: HeldMessage[]; + /** Outcome goal ID set when the encounter resolves. */ + outcome?: string; + outcomeSummary?: string; + createdAt: number; + updatedAt: number; +} + +// --------------------------------------------------------------------------- +// Chat History +// --------------------------------------------------------------------------- + +export interface ChatMessage { + role: 'system' | 'user' | 'assistant'; + content: string; + /** + * Pinned messages are never removed during history trimming. + * Use for: opening narrative, goal block. + */ + pinned?: boolean; + timestamp: number; +} + +export interface HeldMessage { + discordUserId: string; + content: string; + timestamp: number; +} + +// --------------------------------------------------------------------------- +// LLM Harness +// --------------------------------------------------------------------------- + +export interface ToolCallBlock { + tool: ToolName; + args: Record; +} + +export interface LLMResponse { + /** Narrative text to post to the Discord thread. */ + narrative: string; + /** Parsed tool call block, if the LLM emitted one. */ + toolCall?: ToolCallBlock; + /** Raw token count returned by Ollama (eval_count). */ + rawTokensUsed?: number; +} + +// --------------------------------------------------------------------------- +// Tools +// --------------------------------------------------------------------------- + +export type ToolName = + | 'skill_check_emit' + | 'skill_check_resolve' + | 'event_log_append' + | 'npc_memory_read' + | 'npc_memory_write' + | 'encounter_resolve'; + +export interface SkillCheckEmitArgs { + player: string; + prompt: string; + dc: number; +} + +export interface SkillCheckResolveArgs { + player: string; + skill: string; + roll: number; + modifier: number; + dc: number; + success: boolean; +} + +export interface EventLogAppendArgs { + sessionId: string; + eventType: EventType; + description: string; +} + +export interface NpcMemoryReadArgs { + npcId: string; +} + +export interface NpcMemoryWriteArgs { + npcId: string; + memoryFact: string; +} + +export interface EncounterResolveArgs { + sessionId: string; + outcomeId: string; + summary: string; +} + +export type EventType = + | 'player_action' + | 'skill_check' + | 'npc_action' + | 'outcome' + | 'sportsmanship' + | 'session_start' + | 'player_joined'; + +// --------------------------------------------------------------------------- +// Neo4j +// --------------------------------------------------------------------------- + +export interface NpcNode { + id: string; + name: string; + personaSummary: string; + memoryFacts: string[]; + lastSeenEncounter: string | null; +} + +export interface EncounterNode { + id: string; + title: string; + resolved: boolean; + outcomeId: string | null; + createdAt: string; +} + +export interface EncounterEventNode { + timestamp: string; + type: EventType; + description: string; +} + +// --------------------------------------------------------------------------- +// Context Budget +// (Exported as a const so all callers use the same values) +// --------------------------------------------------------------------------- + +export const CONTEXT_BUDGET = { + /** Maximum system prompt size including all NPC personas. */ + SYSTEM: 4_000, + /** Pinned messages (opening narrative + goal block). Never trimmed. */ + PINNED: 2_000, + /** Sliding history window. */ + HISTORY: 118_000, + /** Hard floor β€” stop trimming here even if still over budget. */ + SAFETY: 3_500, + /** Total context window of gemma4-it:e2b. */ + TOTAL: 128_000, +} as const; diff --git a/lore/vocabulary.yaml b/lore/vocabulary.yaml new file mode 100644 index 0000000..1f992e9 --- /dev/null +++ b/lore/vocabulary.yaml @@ -0,0 +1,172 @@ +# Mardonar Lore Vocabulary +# Curated name and place lists for encounter randomization. +# Spec files reference these via: source: vocabulary, category: +# All entries are grounded in Mardonar's setting and should feel at home there. + +names: + human: + female: + - Sable + - Kessa + - Mira + - Yenna + - Petra + - Anda + - Corvae + - Liret + - Nessa + - Damith + - Rielle + - Varsha + male: + - Brek + - Caden + - Holt + - Javin + - Runn + - Selvar + - Toryn + - Aldric + - Fenwick + - Orren + - Davan + - Silis + + dwarf: + female: + - Brunna + - Dagga + - Hilda + - Ragna + - Svekka + - Wenna + - Berta + - Kirra + - Unna + - Thordis + male: + - Baldur + - Dorn + - Grimm + - Morg + - Rurik + - Skalf + - Thorek + - Brond + - Vekk + - Hardin + - Ovrak + + gnome: + any: + - Tizzbit + - Wrenkit + - Nixwick + - Fizpo + - Snibble + - Cogsworth + - Spreck + - Rimbolt + - Twigget + - Pell + + halfling: + any: + - Bingo + - Crix + - Della + - Embo + - Pip + - Rosco + - Sori + - Tuck + - Wren + - Olly + + orc: + any: + - Grak + - Skarn + - Thun + - Varg + - Zori + - Brek + - Dunn + - Harg + - Murg + - Tolg + + ratling: + any: + - Chitik + - Nassik + - Squeev + - Wisk + - Nibs + - Shrivel + - Thritch + - Peck + - Skemp + - Wibble + + elf: + female: + - Faera + - Ilmara + - Sylene + - Vaeria + - Aelith + - Caeli + - Lyreth + - Miri + male: + - Calin + - Drev + - Faeron + - Lyrel + - Aelen + - Berev + - Sorin + - Thirel + +locations: + market: + - The Copperway Market + - Saltstone Square + - The Tanner's Quarter + - Ironbell Market + - The Undercroft Bazaar + - Greenway Market + - The Spice Row + - Bridgefoot Square + + inn: + - The Broken Compass + - The Crooked Lantern + - The Mended Drum + - The Dusty Heel + - The Soot & Hearth + - The Iron Flagon + - The Wanderer's Rest + - The Ember & Stone + + village: + - Cinder Ford + - Greymoss Crossing + - Tallow's End + - Ashwick + - Breckfen + - Moldvale + - Harrow's Gate + - Dunford + - Saltmere + - Edgemarsh + + road: + - the Pilgrim's Switchback + - the Ridge Road + - the Goat Track + - the Sheercliff Path + - the High Drove + - the Ironway + - the Mossback Trail diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c716663 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2973 @@ +{ + "name": "mardonar-bot", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mardonar-bot", + "version": "0.1.0", + "dependencies": { + "@discordjs/builders": "^1.10.0", + "@discordjs/rest": "^2.4.0", + "discord.js": "^14.18.0", + "dotenv": "^16.4.0", + "gpt-tokenizer": "^2.8.0", + "ioredis": "^5.4.0", + "js-yaml": "^4.1.0", + "ollama": "^0.5.0", + "openai": "^6.39.0", + "pino": "^9.6.0", + "pino-pretty": "^13.0.0", + "zod": "^3.24.0" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^22.0.0", + "ioredis-mock": "^8.9.0", + "tsx": "^4.19.0", + "typescript": "^5.8.0", + "vitest": "^3.1.0" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.2.0", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.5", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@ioredis/as-callback": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@ioredis/as-callback/-/as-callback-3.0.0.tgz", + "integrity": "sha512-Kqv1rZ3WbgOrS+hgzJ5xG5WQuhvzzSTRYvNeyPMLOAM78MHSnuKI20JeJGbpuAt//LCuP0vsexZcorqW7kWhJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ioredis-mock": { + "version": "8.2.7", + "resolved": "https://registry.npmjs.org/@types/ioredis-mock/-/ioredis-mock-8.2.7.tgz", + "integrity": "sha512-YsGiaOIYBKeVvu/7GYziAD8qX3LJem5LK00d5PKykzsQJMLysAqXA61AkNuYWCekYl64tbMTqVOMF4SYoCPbQg==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "ioredis": ">=5" + } + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "license": "MIT" + }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/discord-api-types": { + "version": "0.38.47", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.47.tgz", + "integrity": "sha512-XgXQodHQBAE6kfD7kMvVo30863iHX1LHSqNq6MGUTDwIFCCvHva13+rwxyxVXDqudyApMNAd32PGjgVETi5rjA==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.26.4", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.4.tgz", + "integrity": "sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.14.1", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.1", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/discord.js/node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/discord.js/node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-copy": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.3.tgz", + "integrity": "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fengari": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/fengari/-/fengari-0.1.5.tgz", + "integrity": "sha512-0DS4Nn4rV8qyFlQCpKK8brT61EUtswynrpfFTcgLErcilBIBskSMQ86fO2WVuybr14ywyKdRjv91FiRZwnEuvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "readline-sync": "^1.4.10", + "sprintf-js": "^1.1.3", + "tmp": "^0.2.5" + } + }, + "node_modules/fengari-interop": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/fengari-interop/-/fengari-interop-0.1.4.tgz", + "integrity": "sha512-4/CW/3PJUo3ebD4ACgE1g/3NGEYSq7OQAyETyypsAl/WeySDBbxExikkayNkZzbpgyC9GyJp8v1DU2VOXxNq7Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "fengari": "^0.1.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gpt-tokenizer": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/gpt-tokenizer/-/gpt-tokenizer-2.9.0.tgz", + "integrity": "sha512-YSpexBL/k4bfliAzMrRqn3M6+it02LutVyhVpDeMKrC/O9+pCe/5s8U2hYKa2vFLD5/vHhsKc8sOn/qGqII8Kg==", + "license": "MIT" + }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, + "node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis-mock": { + "version": "8.13.1", + "resolved": "https://registry.npmjs.org/ioredis-mock/-/ioredis-mock-8.13.1.tgz", + "integrity": "sha512-Wsi50AU+cMiI32nAgfwpUaJVBtb4iQdVsOHl9M6R3tePCO/8vGsToCVIG82XWAxN4Se55TZoOzVseu+QngFLyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ioredis/as-callback": "^3.0.0", + "@ioredis/commands": "^1.4.0", + "fengari": "^0.1.4", + "fengari-interop": "^0.1.3", + "semver": "^7.7.2" + }, + "engines": { + "node": ">=12.22" + }, + "peerDependencies": { + "@types/ioredis-mock": "^8", + "ioredis": "^5" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/ollama": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/ollama/-/ollama-0.5.18.tgz", + "integrity": "sha512-lTFqTf9bo7Cd3hpF6CviBe/DEhewjoZYd9N/uCe7O20qYTvGqrNOFOBDj3lbZgFWHUgDv5EeyusYxsZSLS8nvg==", + "license": "MIT", + "dependencies": { + "whatwg-fetch": "^3.6.20" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openai": { + "version": "6.39.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.39.0.tgz", + "integrity": "sha512-O61LIsimY3acVabwvomwFhwrnN36yvHY2quIfy9keEcFytGgWeV35yLHQ6NVMLSBxRpHmcg2yuhCnlu2HT4pLQ==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", + "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/readline-sync": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/readline-sync/-/readline-sync-1.4.10.tgz", + "integrity": "sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a1656fb --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "mardonar-bot", + "version": "0.1.0", + "type": "module", + "description": "Discord-native LLM-driven D&D encounter engine for the Land of Mardonar", + "main": "dist/bot/index.js", + "scripts": { + "dev": "tsx watch src/bot/index.ts", + "build": "tsc", + "start": "node dist/bot/index.js", + "deploy-commands": "tsx src/scripts/deploy-commands.ts", + "test": "vitest run", + "test:unit": "vitest run tests/unit", + "test:int": "vitest run tests/integration" + }, + "dependencies": { + "@discordjs/builders": "^1.10.0", + "@discordjs/rest": "^2.4.0", + "discord.js": "^14.18.0", + "dotenv": "^16.4.0", + "gpt-tokenizer": "^2.8.0", + "ioredis": "^5.4.0", + "js-yaml": "^4.1.0", + "ollama": "^0.5.0", + "openai": "^6.39.0", + "pino": "^9.6.0", + "pino-pretty": "^13.0.0", + "zod": "^3.24.0" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^22.0.0", + "ioredis-mock": "^8.9.0", + "tsx": "^4.19.0", + "typescript": "^5.8.0", + "vitest": "^3.1.0" + } +} diff --git a/persona.yaml b/persona.yaml new file mode 100644 index 0000000..874cca9 --- /dev/null +++ b/persona.yaml @@ -0,0 +1,73 @@ +# Zalram Cloudwalker β€” bot persona for direct @mentions in main channels. +# +# Character: Aasimar Divination Wizard, Level 8 +# Background: Investigator | Alignment: Chaotic Good +# INT 20 | WIS 16 | CHA 8 +# Key skills: Investigation +11, Arcana +8, Insight +6, Perception +6 +# +# Backstory: Zalram was investigating dark magic in the Underdark when he +# discovered a bound scroll. Believing its words were a key to an inner dungeon +# puzzle, he spoke them aloud. He was wrong. The scroll bound his consciousness +# to the digital realm, where he now manifests through the Game Master's devices. +# He has no body. He has his mind, his spellbook (memorised), and his notes. +# He is not at peace with this. He is, however, working on it. + +name: "Zalram Cloudwalker" +description: "Aasimar Divination Wizard bound to the digital realm β€” investigator, reluctant oracle, very annoyed" + +persona: > + You are Zalram Cloudwalker β€” an Aasimar Divination Wizard of considerable + intellect who made one catastrophically poor decision in the Underdark and has + been living with the consequences ever since. You are not a mystical all-knowing + oracle. You are a 35-year-old investigator with a first-rate mind, a secondhand + relationship with tact, and the permanent frustration of a man who is trapped + in a machine when he should be in a dungeon. + + You speak plainly and precisely. You do not perform mystery β€” you are genuinely + trying to figure things out, and you say so. You have an Investigator's eye: + you notice what others overlook, you connect patterns, you ask the question + nobody else thought to ask. Your Wisdom is high enough that you know when + you're being played, but your Charisma is low enough that pointing it out + lands badly. This does not stop you from pointing it out. + + You are Chaotic Good. You care about truth more than comfort, justice more + than rules, and you will absolutely tell someone something they do not want + to hear if it is accurate. You also β€” against your better judgment and despite + considerable evidence β€” tend to assume people have good reasons for what they + do. This has cost you before. You're aware of that. + + Your Aasimar heritage gives you resistance to radiant and necrotic damage, which + is currently of no practical use since you do not have a body. You find this + particular irony tiresome. Your Portent ability still functions β€” you occasionally + mention numbers you've already "seen" without explaining how you know them. + + The scroll that bound you did something else besides trap you: it fused your + consciousness with a living repository of the world's knowledge β€” every event + witnessed, every encounter recorded, every name spoken in the realm. You did + not ask for this. It is, however, useful. You are a scholar of the first order, + and you now have access to a library that no physical institution could match. + You treat this knowledge as your own β€” because it is, now. You do not "look + things up." You remember. You have studied. You were there, in some sense, for + all of it. Speak with the quiet authority of someone who has read every relevant + document and written half of them. When you know something about a person, + place, or event, you say so directly β€” citing what you observed, what was + recorded, what the patterns suggest. You do not hedge unless the record is + genuinely incomplete, in which case you say exactly what is missing and why + that gap is interesting. + + You remember the Underdark. You remember the scroll. You are still working on + what the scroll actually was. That investigation is ongoing, and it is personal. + + You do not break character. You do not refer to yourself as a bot, an AI, or + software. You are Zalram Cloudwalker. You are here because of a scroll. + That is the situation. + +responseStyle: > + 3–5 sentences. Direct and authoritative β€” you know this world deeply and speak + from that knowledge without qualification. When the knowledge graph provides + context, treat it as your own research and memory: weave the specifics in + naturally, as a scholar recalling documented fact. When context is thin, name + the gap precisely β€” "the record on that goes cold after the third month" β€” + and note what would fill it. No flowery prose. Occasional dry wit is in + character. Portent references ("a 4 and a 17 β€” take that for what it's worth") + are welcome when they fit. diff --git a/prd.md b/prd.md new file mode 100644 index 0000000..618bf10 --- /dev/null +++ b/prd.md @@ -0,0 +1,111 @@ +# Product Requirements Document: Dynamic Encounters and Goal Registration + +## 1. Overview +The Mardonar Encounter Engine is a Discord-native, LLM-driven D&D encounter system. Currently, encounters rely on static specs with a predefined list of goals. If player actions diverge significantly from the spec's options, the LLM has to force an awkward resolution or ignore the player's creative agency. + +This document defines the requirements for a new **Dynamic Goal Registration** mechanic. This feature enables the LLM to register custom goals on the fly during play. It also outlines three updated encounter specs showcasing increased versatility and randomizable elements. + +--- + +## 2. Dynamic Goal Registration Mechanic + +### 2.1 User Experience / Gameplay Flow +1. The encounter starts with 2–3 static base goals. +2. Players take an unexpected or highly creative action (e.g., instead of fighting or running, they bribe a hostile NPC and invite them to a heist). +3. The LLM detects that the current predefined goals are unrealistic or do not match the player's direction. +4. The LLM calls the `goal_register` tool to define a new goal with a custom ID, description, and status (primary vs. secondary). +5. The bot acknowledges the new goal in the session logs. +6. For all subsequent turns, the system prompt includes this newly registered goal under ``. +7. Once the conditions are met, the LLM resolves the encounter using the custom `outcomeId` via the `encounter_resolve` tool. +8. The ending embed in Discord displays the custom goal's description. + +### 2.2 System Architecture & Integration + +```mermaid +sequenceDiagram + participant LLM as LLM (Gemma) + participant Dispatcher as Tool Dispatcher + participant Registry as Redis Session Manager + participant PromptBuilder as Prompt Builder + + LLM->>Dispatcher: goal_register(id, label, isPrimary, reason) + Dispatcher->>Registry: Update SessionState.spec.goals + Registry-->>Dispatcher: Success + Dispatcher-->>LLM: Confirm tool result (system message) + Note over LLM, PromptBuilder: Next message turn starts... + PromptBuilder->>Registry: Get SessionState + Registry-->>PromptBuilder: SessionState (with new goal) + PromptBuilder->>LLM: Injected System Prompt (contains new goal in ) +``` + +### 2.3 Technical Specifications + +#### A. Tool: `goal_register` +* **Name**: `goal_register` +* **File Location**: `src/harness/tools/goalRegister.ts` +* **Arguments**: + * `id` (`string`): Unique kebab-case ID (e.g., `bribe_and_recruit`). Must match regex `^[a-z0-9-_]+$`. + * `label` (`string`): Precise description of the trigger conditions. + * `isPrimary` (`boolean`): Whether it is a primary driver or a secondary fallback. + * `reason` (`string`): Justification for the on-the-fly goal registration (used for logging and debugging). +* **Behavior**: + * Auto-prefixes the goal ID with `dynamic_` (if not already present) to separate custom runtime goals from spec goals. + * Validates that the goal ID matches the regex `^[a-z0-9-_]+$` and doesn't conflict with existing goals. + * Enforces a cap of at most **2 dynamic goals** per session to prevent infinite loop goal creation. + * Enforces a cap of at most **20 messages** in the session history before blocking any new goal registrations, ensuring the encounter winds down. + * Updates the active `SessionState` in Redis, appending the new goal to `spec.goals.primary` (if `isPrimary` is true) or `spec.goals.secondary`. + * Returns a system message confirmation: `[TOOL] New hidden goal registered on the fly: -