Files
zalbot/docs/spec-authoring-guide.md
Kaysser Kayyali 9f401692c8 feat(spec): add minPlayers/maxPlayers/passiveReveals to EncounterSpecSchema (Story 5.1)
CAP-17 minimal schema additions — unblocks Features A-E:
- minPlayers (int, min 1, default 1): party-size gating; omit/1 = solo-able.
- maxPlayers (int, optional): party-size cap.
- passiveReveals (optional array of {skill, threshold, revealText}):
  bot-applied passive-skill reveals at encounter start (Feature B).
  Group-visible only — no private delivery path (no interaction in flight
  at start to carry an ephemeral). threshold is a DC integer.

Also: declare explicit tools: list in specs/the-clock-maker.yaml (was
omitted; specsToolsConsistency requires explicit declaration). Update
docs/spec-authoring-guide.md (minPlayers/maxPlayers/passiveReveals docs
+ new pitfalls: no dice in revealText, threshold is a DC int, successRule
is a tool arg not a spec field, story-status never in spec prose).

Tests: 426 unit pass; tsc clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-20 23:21:58 +00:00

9.1 KiB
Raw Permalink Blame History

Encounter Spec Authoring Guide

How to author a YAML encounter spec the engine's LLM can read and drive. This is the canonical contract surface: hand-authors follow it, and the separate encounter-builder tool targets it. Field semantics live in data-models.md; the runtime validator is EncounterSpecSchema in src/spec/loader.ts.

Where specs live

The production spec corpus is decoupled from core code: it lives in a Gitea repo and is pulled into the image at Docker build time (see specs-pipeline.md). This repo ships only the loader/schema contract, clean example specs (in ./specs/), and this guide. At runtime the bot reads config.SPECS_DIR (default ./specs) — the same dir whether it holds the bundled examples or the build-time-pulled corpus.

The contract model

A spec is the LLM's instruction sheet. Every field maps to either something the LLM reads in its system prompt (setting, NPCs, goals, tone, tools, memory) or something the bot enforces (skill-check DCs, party-size gate, campaign link, randomizable draws). The LLM never sees engine internals — it sees prose directives. Write for a narrator, not a programmer.

Minimal anatomy

A spec must validate against EncounterSpecSchema in src/spec/loader.ts. The required spine:

encounterId: market-thief        # kebab-case; session key
title: The Market Thief          # shown in Discord embeds
setting:
  location: Lower Mardonar market square
  mood: tense, crowded, late afternoon
  ambientNpcs: hawkers, a city guard patrol, fleeing urchins
openingNarrative: |
  The square bustles... (this is pinned for the whole encounter — never trimmed)
npcs:
  - id: dal
    name: Dal the Quick
    role: pickpocket
    persona: |
      Nervous, fast, talks too much when cornered. Will bargain before fighting.
goals:
  primary:
    - id: thief_caught
      label: Dal is caught and the purse is returned
  secondary:
    - id: thief_escaped
      label: Dal escapes into the crowd
sportsmanshipRules:
  - No PvP between players
  - No auto-hitting named NPCs
skillChecks:
  chase_dc: 13
  perception_dc: 11

Writing what the LLM reads

  • setting — three strings. location grounds the scene; mood is a style directive (the LLM leans into it); ambientNpcs gives background life without persona overhead. Keep mood to a few adjectives.
  • openingNarrative — the scene-setting text posted at session start. It is pinned (never trimmed from context), so put everything the LLM must keep in frame here. Write it in-world.
  • npcs — 13 personas. persona is a voice/behavior directive, not a stat block: how they speak, what they want, how they react under pressure. One to three sentences beats a paragraph. Give each a stable id and a name. Add memoryKey only for NPCs that should remember across encounters; leave it off for one-off NPCs.
  • goalsprimary is what the LLM steers toward; secondary is valid-but-not-main. Each goal needs a stable id and a label (the label becomes the closing embed's Outcome text). 23 primary goals is the sweet spot. The LLM may register more mid-encounter via goal_register.
  • tone (optional) — free-text narration flavor (grim, tense, comedic, mysterious). Also selects the in-world drop-notice string when the burst cap drops a message. Unrecognised tones fall back to a baseline notice.

Writing what the bot enforces

  • skillChecks — a map of named DCs (chase_dc: 13). The LLM references these by name when it emits skill_check_emit. Name them for the action, not the stat (chase_dc, not dex_dc) — the LLM picks the skill, the spec supplies the target number. Free-text companion keys (chase_skill, chase_note) ride alongside the DC as LLM-read context; they never become dice.
  • randomizable (optional) — declare fields that vary per run so the same spec file yields different substance/complications:
randomizable:
  leak_substance:
    - Caustic Rust-Blight Silt
    - Sleeping Ether-Vapor
    - Wild-Magic Slurry
  leak_complication:
    - mutated_silt_rats
    - corroded_lock
    - greedy_scavenger
  • tools — the tool plugins active for this encounter. Declare explicitlytests/unit/specsToolsConsistency.test.ts fails the build if a spec omits the tools: list. List the full set you want active (commonly all six: skill_check_emit, encounter_resolve, context_recall, goal_register, foundry_lookup, foundry_reward); narrow it for encounters that don't need a tool (e.g. a no-combat encounter dropping skill_check_emit). Every name must be a registered plugin.
  • xpReward (optional) — flat XP awarded to every participant when the encounter resolves, regardless of which goal/outcome fired. Omit for encounters that grant no XP.
  • minPlayers (optional, default 1) — minimum party size to start the encounter. Omit (or 1) for a solo-able encounter anyone can start. Set ≥ 2 to require an encounter lobby that fills before start (Feature D). 0 is invalid — omit the field to mean solo-able.
  • maxPlayers (optional) — party-size cap. Absent means no cap. At the cap, lobby Join is disabled.
  • passiveReveals (optional) — hidden scene details the bot auto-surfaces at encounter start when a player's passive score meets a threshold. Group-visible, attributed to the qualifying player; there is no private delivery path.
passiveReveals:
  - skill: Perception
    threshold: 16
    revealText: >-
      Zara notices a small button set into the wall behind the tapestry.

Planned, not yet enforced

campaignId (campaign continuity) is part of the engine vision (CAP-13) but is not yet in EncounterSpecSchema. Zod silently strips unknown keys, so writing it today does nothing. Do not add it expecting campaign linkage; wait until the field lands.

Validation

The runtime type is EncounterSpec = z.infer<typeof EncounterSpecSchema> (src/spec/loader.ts) — the static type and the runtime validator cannot drift. Validate before deploying:

npm run build     # tsc compiles + Zod validates on load

A spec that fails Zod validation is rejected at /encounter start with an in-world error, never a stack trace.

A clean reference example

specs/market-thief.yaml is the fully-annotated reference spec — copy it as your starting point. It exercises every common field (encounterId, title, tone, setting, openingNarrative, npcs with nameKey/memoryKey, goals, sportsmanshipRules, skillChecks with _skill/_note companions, randomizable, tools, dmNotes) with inline # comments explaining each field's LLM/bot role. The optional xpReward is documented above and intentionally omitted from this example (some live tests rely on this spec having no XP default; add it to your own spec when you want flat resolution XP).

Pitfalls — the LLM contract

  • Don't put dice results in the spec. The bot controls dice. The LLM narrates outcomes only after the [SKILL CHECK RESULT] message. A spec that pre-declares "the thief rolls a 15" teaches the LLM to fabricate rolls.
  • Don't put system tags or tool_call syntax in prose. openingNarrative and persona are player-facing prose; any tool_call, [TOOL], [SKILL CHECK], or fenced JSON will either be stripped or, if the LLM echoes it, suppressed by the response filter. Keep prose clean in-world text.
  • Personas are voice, not stats. A spec that reads like a character sheet (HP, AC, spell lists) wastes context the LLM can't use — Foundry owns stats. Describe how an NPC behaves and sounds.
  • openingNarrative is pinned. It stays in context for the whole encounter, so keep it tight and load-bearing; don't bury the scene's core tension in flavor.
  • id fields are stable. Goal and NPC ids are referenced by goal_register/encounter_resolve/memory across encounters; never rename a live id.
  • No dice in passiveReveals.revealText. Same rule as spec prose — the bot owns dice. revealText is outcome prose only (what the player notices), never a roll result.
  • passiveReveals.threshold is a DC integer, not a modifier or a "DC 15" string.
  • successRule is a tool arg, not a spec field. Group checks are invoked by the LLM via skill_check_group_emit with a successRule argument (majority / all / n_of_m / sum_threshold) — do not put successRule: in your YAML. The spec only declares the skillChecks DCs; the LLM decides to call the group variant with a rule when the fiction warrants.
  • Story-status is never in spec prose. Story-status (sick, cursed, disguised, etc.) is engine-tracked at runtime (DM command / LLM character_status tool), Redis-TTL'd ~24h — it must not appear in openingNarrative, persona, revealText, or reward blocks.

Authoring tooling

Hand-editing YAML is fine; the separate encounter-builder tool (its own project) is a guided form that outputs contract-adherent specs — it targets this guide and EncounterSpecSchema, never re-implementing them. On-the-spot AI generation (/encounter generate) was retired to keep AI output out of canonical lore.