Files
zalbot/Docs/epics.md
Kaysser Kayyali 9dc6e8e1a3 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 <noreply@anthropic.com>
2026-05-30 04:51:21 +00:00

17 KiB
Raw Permalink Blame History

stepsCompleted, inputDocuments
stepsCompleted inputDocuments
step-01-validate-prerequisites
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 14 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 14 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
FR1FR5 Epic 1 1.1
FR6FR7 Epic 1 1.2
FR8FR10 Epic 2 2.1
FR11FR12 Epic 3 3.1
FR13FR14 Epic 4 4.1
NFR1NFR6 Cross-cutting All stories
UX-DR1UX-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 <tone> block immediately after <narrator_identity> 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 <tone> 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 <tool_contract> 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 <tool_contract> 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.