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>
9.1 KiB
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.locationgrounds the scene;moodis a style directive (the LLM leans into it);ambientNpcsgives background life without persona overhead. Keepmoodto 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— 1–3 personas.personais 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 stableidand aname. AddmemoryKeyonly for NPCs that should remember across encounters; leave it off for one-off NPCs.goals—primaryis what the LLM steers toward;secondaryis valid-but-not-main. Each goal needs a stableidand alabel(thelabelbecomes the closing embed's Outcome text). 2–3 primary goals is the sweet spot. The LLM may register more mid-encounter viagoal_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 emitsskill_check_emit. Name them for the action, not the stat (chase_dc, notdex_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 explicitly —tests/unit/specsToolsConsistency.test.tsfails the build if a spec omits thetools: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 droppingskill_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, default1) — minimum party size to start the encounter. Omit (or1) for a solo-able encounter anyone can start. Set≥ 2to require an encounter lobby that fills before start (Feature D).0is 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_callsyntax in prose.openingNarrativeandpersonaare player-facing prose; anytool_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.
openingNarrativeis 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.idfields are stable. Goal and NPC ids are referenced bygoal_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.revealTextis outcome prose only (what the player notices), never a roll result. passiveReveals.thresholdis a DC integer, not a modifier or a"DC 15"string.successRuleis a tool arg, not a spec field. Group checks are invoked by the LLM viaskill_check_group_emitwith asuccessRuleargument (majority/all/n_of_m/sum_threshold) — do not putsuccessRule:in your YAML. The spec only declares theskillChecksDCs; 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_statustool), Redis-TTL'd ~24h — it must not appear inopeningNarrative,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.