fix wizard for new stories
Some checks failed
tests / Unit tests (Node 22) (push) Failing after 28s

This commit is contained in:
2026-06-20 06:52:19 +00:00
parent d19278cd11
commit b884a13d98
27 changed files with 1265 additions and 1 deletions

View File

@@ -0,0 +1,90 @@
---
name: mardonar-encounter-wizard
description: Guided, section-by-section wizard for authoring a Mardonar encounter spec. Opens by collecting the encounter's "vibe", then walks each formal spec section one at a time — each section has its own detailed skill file under sections/ and its own specialized lore-options tool that returns canon-grounded, contextual choices the wizard presents via the question tool. The USER authors (picks/writes); the wizard scaffolds and grounds in canon — it never drafts the scene, and never puts named canon NPCs in a random encounter. MUST run with the engine repo (mardonar-npcs) as cwd.
---
# Mardonar Encounter Authoring Wizard
A guided, section-by-section authoring flow. You open by collecting the encounter's **vibe** (a mood, a place, a situation in the user's words), then walk each formal spec section in order. For each section you read that section's own skill file (`sections/<section>/SKILL.md`) and follow it — it tells you what to describe, which specialized lore-options tool to run, how to frame the question, and what to collect.
## The two non-negotiable rules
1. **You scaffold; the user authors.** Every section offers real, lore-grounded options as *inspiration to pick from or riff on*. The user makes the choices and writes the prose. **Never** draft the scene, NPCs, goals, or opening for them — not even "as a proposal to edit." A wizard that hands the user a pre-written scene is the same failure mode as the retired `/encounter generate`.
2. **Random encounters use archetypal NPCs with per-run `randomizable` names**, not named canon NPCs. Canon places/factions/traditions may be the *backdrop*; named canon characters belong in scripted one-off encounters only. Confirm which kind this is before walking sections.
## Guardrails (in force the whole run)
- **In-world voice** for all player-facing prose (`setting`, `openingNarrative`, `persona`, goal `label`s, `dmNotes`) — no utility terms ("session", "user", "bot", "system", "queue", "error"). The skill's own agent-facing instructions are exempt.
- **Single-source the contract.** Never re-derive the schema or the tool list from memory. Validate via `scripts/validate-spec.ts`; list tools via `scripts/list-tools.ts`. The type is `z.infer<typeof EncounterSpecSchema>`. Re-read each section's skill file fresh per run — do not cache it.
- **GraphMCP host override:** every lore-options tool call must be prefixed `GRAPHMCP_URL=http://localhost:9000` (the engine `.env` sets `GRAPHMCP_URL` to the Docker hostname `mcp-server`, unreachable from the host). `dotenv` never clobbers a command-line env, so the prefix wins.
## Setup (do once at the start)
1. **Run guard:** confirm `src/spec/loader.ts` exists in cwd. If not, stop: *"Run this from the Mardonar engine repo (mardonar-npcs) root — the wizard validates against `src/spec/loader.ts`."*
2. **Read the contract surface:** `docs/spec-authoring-guide.md` (the "Pitfalls — the LLM contract" section is your linter rule set — re-read each run) and `specs/market-thief.yaml` (the reference shape — copy its shape, not its content).
3. **Random or scripted one-off?** Ask the user. Default to random (replayable → archetypal NPCs + `randomizable` names). This answer changes how the `npcs` and `randomizable` sections behave.
4. **Collect the vibe.** Ask the user to describe the feel of the encounter in a sentence or two — mood, place, situation, what the party walks into. This is the **vibe seed**; it flavors every section's lore-options query so the choices you show are contextual, not generic. Do **not** turn the vibe into a scene — it's query fuel, not authored content. If the user gave a seed when invoking the wizard, reuse it.
## Walk the sections
Walk these in order. For each, **read its skill file fresh** and follow it. The section file owns the detail (what to describe, which tool, how to frame the question, what to collect, which pitfalls apply). Your job per section is: describe → run the section's lore-options tool (if it has one) → present the real options via the question tool (`AskUserQuestion`) → collect the user's choice/prose → record → next section.
1. `sections/identity/SKILL.md``encounterId` + `title`
2. `sections/setting/SKILL.md``location` / `mood` / `ambientNpcs` (tool: `lore-locations`)
3. `sections/tone/SKILL.md``tone`
4. `sections/opening/SKILL.md``openingNarrative` (tool: `lore-atmosphere`)
5. `sections/npcs/SKILL.md``npcs` (tool: `lore-archetypes`)
6. `sections/goals/SKILL.md``goals` (tool: `lore-hooks`)
7. `sections/sportsmanship/SKILL.md``sportsmanshipRules`
8. `sections/skillChecks/SKILL.md``skillChecks`
9. `sections/randomizable/SKILL.md``randomizable` (tool: `lore-vocabulary`)
10. `sections/tools/SKILL.md``tools` (tool: `list-tools`)
11. `sections/dmNotes/SKILL.md``dmNotes`
12. `sections/xpReward/SKILL.md``xpReward`
### How to present options (the question tool)
When a section has real, canon-grounded options, use `AskUserQuestion` to present them — that's the "guided system" feel. Rules:
- One question per section call (unless the section file says otherwise), with **24 options**. The tool auto-adds an "Other" path so the user can always write their own — lean on that.
- Each option needs a short `label` (≤5 words) and a one-line `description` grounded in what the lore tool returned. Don't invent canon for the description — quote/paraphrase the chunk.
- If the lore tool returned nothing useful (or GraphMCP is unreachable), say so explicitly and fall back to an open prompt for that section. Never present a fabricated option as if it came from canon.
- After the user picks (or writes "Other"), record it and move on. Do not re-ask.
If GraphMCP is unreachable for any tool, tell the user once ("GraphMCP unreachable — authoring without canon grounding for this run") and continue with open prompts. Do not silently present empty results as "nothing exists."
## Assemble + validate (after all sections are collected)
1. **Assemble** the collected answers into loader-shaped YAML (copy `market-thief.yaml`'s shape — prose fields use `>` folded scalars, `#` comments only on their own lines between fields, never inside a `>` block). Use the user's prose verbatim in LLM-read fields; structure-only fields (`id`s, DCs, `tools`, `randomizable` keys) are yours.
2. **Write** the draft to `./specs/<encounterId>.yaml`.
3. **Validate** — the acceptance test: `npx tsx scripts/validate-spec.ts <encounterId>`.
- **OK** → passes `EncounterSpecSchema` AND a real `loadSpec()` round-trip. Proceed.
- **FAIL** → prints field-named Zod issues. Fix each with the user and re-run. Never hand over a spec that hasn't passed.
4. **Enforce the pitfalls** (EB-4) — re-read the "Pitfalls" section of `docs/spec-authoring-guide.md` and check: no dice results in prose, no system tags / `tool_call` syntax, personas are voice not stats, `openingNarrative` is pinned and tight, `id`s are stable. Fix any with the user; re-validate.
5. **Write the final** spec to `./specs/<encounterId>.yaml` (or a path the user chooses).
6. **Give the commit command** for the Gitea corpus — the wizard never pushes itself (credential-free):
```
cp ./specs/<encounterId>.yaml /path/to/mardonar-specs/<encounterId>.yaml
cd /path/to/mardonar-specs && git add <encounterId>.yaml && git commit -m "Add <encounterId>" && git push
```
## Constraints (always in force)
- **Never mutate `specs/market-thief.yaml`.** It's the annotated reference and a live-test fixture. Copy its shape into *new* files.
- **Never add `xpReward` to `specs/market-thief.yaml`** (a live test depends on it staying xpReward-free). For a new spec, add `xpReward` only on the user's explicit request.
- **Deterministic output.** Stable key ordering, no incidental whitespace, so the Gitea diff stays clean.
- **No AI lore authoring.** You refine the user's story; you don't invent canon. Lore tools surface existing canon; they never write new lore into the graph.
## Helper scripts (all run from the repo root)
Lore-options tools (each takes the vibe seed as its first arg; host override required):
- `GRAPHMCP_URL=http://localhost:9000 npx tsx scripts/lore-locations.ts "<vibe>" [limit]`
- `GRAPHMCP_URL=http://localhost:9000 npx tsx scripts/lore-atmosphere.ts "<vibe>" [limit]`
- `GRAPHMCP_URL=http://localhost:9000 npx tsx scripts/lore-archetypes.ts "<vibe>" [limit]`
- `GRAPHMCP_URL=http://localhost:9000 npx tsx scripts/lore-hooks.ts "<vibe>" [limit]`
- `GRAPHMCP_URL=http://localhost:9000 npx tsx scripts/lore-vocabulary.ts "<vibe>" [limit]`
Other:
- `npx tsx scripts/list-tools.ts` — the real registered tool-plugin list (for the `tools` section).
- `npx tsx scripts/validate-spec.ts <slug-or-path>` — the acceptance test (real `loadSpec` round-trip).
- `GRAPHMCP_URL=http://localhost:9000 npx tsx scripts/search-lore.ts search "<query>" [limit]` — the generic ad-hoc lookup, if a section needs something its tool doesn't cover.

View File

@@ -0,0 +1,22 @@
---
name: encounter-wizard-dmnotes
description: Wizard section 11 — author dmNotes (LLM-read author framing for the DM's intent — stakes, tone calibration, the feel). Not rules the LLM mechanically follows. No lore tool.
---
# Section 11 — `dmNotes`
## Describe
LLM-reads; author-only framing for the DM's intent — stakes, tone calibration, the "feel." **Not** rules the LLM mechanically follows (those go in `sportsmanshipRules` / `skillChecks`). In-world-ish, no utility jargon. 24 lines.
## Options
What the scene is really about and how to run it.
## Tool
None.
## Ask
**Open prompt only.** *"What's this scene really about, and what should the LLM lean into? (24 lines of framing for you, the author.)"* If the user says skip, omit the field. Record verbatim.
## Pitfalls
- Utility jargon ("session", "user", "bot") → rework in-world with the user.
- Drifts into mechanics the LLM should enforce → move those to `sportsmanshipRules` / `skillChecks`.

View File

@@ -0,0 +1,25 @@
---
name: encounter-wizard-goals
description: Wizard section 6 — author goals (hidden default true, primary min 1, secondary optional). Each goal id is the encounter_resolve outcomeId; label becomes the closing embed's Outcome text. Lore tool: lore-hooks.
---
# Section 6 — `goals` (`hidden`, `primary`, `secondary`)
## Describe
LLM-reads; steers toward primary, secondary are valid-but-not-main. `hidden` (default `true`) means players don't see them. Each goal needs a stable `id` (the `encounter_resolve` outcomeId) and a `label` (becomes the closing embed's Outcome text). 23 primary is the sweet spot; the LLM may register more mid-encounter via `goal_register`. **Not every encounter ends in success** — include at least one valid "loss" secondary (see `market-thief`'s `escape`).
## Options
23 primary outcomes + any secondary, each with `id` + `label`.
## Tool
```
GRAPHMCP_URL=http://localhost:9000 npx tsx scripts/lore-hooks.ts "<vibe>" 8
```
Read the ranked chunks. Pick the **top 34 distinct hooks/conflicts** and present them as `AskUserQuestion` options — `label` = the outcome in ≤5 words, `description` = one-line paraphrase of the canon tension (do not invent canon). If nothing useful, ask openly.
## Ask
One `AskUserQuestion` call — **question:** *"What does this encounter steer toward? Pick a primary outcome (or write your own)."* — with 34 hook options. After they pick, follow with an **open prompt** for any additional primary + secondary goals (including a loss secondary). The user writes the `label`s; you assign stable `id`s. Record `hidden` (default true), `primary`, `secondary`.
## Pitfalls
- No valid loss/secondary path → suggest one with the user (keeps encounters from feeling on-rails).
- `label` in utility voice → rework in-world with the user.

View File

@@ -0,0 +1,23 @@
---
name: encounter-wizard-identity
description: Wizard section 1 — author the encounterId (stable kebab slug) and title (in-world, shown in Discord embeds). No lore tool.
---
# Section 1 — `encounterId` + `title`
## Describe
- **`encounterId`** — bot-enforces. Kebab-case, **stable forever once live**, unique across the corpus. Goal and NPC ids are referenced by `goal_register` / `encounter_resolve` / memory across encounters, so **never reuse or rename a live id**.
- **`title`** — shown in Discord embeds and read by the LLM. In-world voice, no utility terms.
## Options
A slug + a short title. Offer to derive the slug from the title.
## Tool
None.
## Ask
Open prompt: *"What's this encounter called? Give me a short in-world title, and I'll suggest a slug — or give me both."* If they give a title, propose the kebab slug and confirm. Record both.
## Pitfalls
- `encounterId` collides with an existing spec in `./specs/` or the corpus → reject and ask for a new one.
- Title contains utility jargon → rework in-world with the user.

View File

@@ -0,0 +1,29 @@
---
name: encounter-wizard-npcs
description: Wizard section 5 — author npcs (1-5). persona is voice/behavior, not a stat block. RANDOM encounters use archetypal NPCs with randomizable name draws, never named canon NPCs. Lore tool: lore-archetypes.
---
# Section 5 — `npcs` (15)
## Describe
LLM-reads. `persona` is **voice/behavior, not a stat block** (Foundry owns stats — HP/AC/spell lists waste context the LLM can't use). `id` stable kebab; `name` displayed; `role` a one-line cue; `memoryKey` only for NPCs who remember across encounters; `nameKey` binds the displayed name to a `randomizable` draw.
## Random-encounter rule (from setup)
If this is a **random** encounter: on-stage NPCs are **archetypes** ("a blood mage at a rite," "a bound sacrifice," "a cult acolyte") with `nameKey`/`randomizable` draws for names — **never named canon NPCs**. Canon places/factions/traditions are backdrop only. (Scripted one-off: named canon NPCs are fine.)
## Options
How many (13 sweet spot; max 5), and for each: an archetype, a `role`, and a voice/behavior persona.
## Tool
```
GRAPHMCP_URL=http://localhost:9000 npx tsx scripts/lore-archetypes.ts "<vibe>" 8
```
Read the ranked chunks. Pick the **top 34 distinct archetypes/roles** and present them as `AskUserQuestion` options — `label` = the archetype (≤5 words), `description` = one-line paraphrase of how such a figure talks/wants/acts (do not invent canon, and do not pull a named canon NPC onto the stage for a random encounter). If nothing useful returned, ask openly.
## Ask
One `AskUserQuestion` call — **question:** *"Who's on stage? Pick an archetype (or write your own / add more)."* — with 34 archetype options. After they pick an archetype, follow with an **open prompt** for that NPC's `role` and persona prose (the user writes the persona). Ask how many NPCs total and repeat per NPC. You assign the `id`, and (random encounter) the `nameKey`.
## Pitfalls
- Persona is a stat block → rework into voice/behavior with the user.
- Named canon NPC in a random encounter → swap to an archetype + `nameKey` draw.
- More than 5 NPCs → the schema rejects it; trim with the user.

View File

@@ -0,0 +1,27 @@
---
name: encounter-wizard-opening
description: Wizard section 4 — author openingNarrative (pinned, posted verbatim at session start). The user writes it; the wizard only offers atmosphere as flavor. Lore tool: lore-atmosphere.
---
# Section 4 — `openingNarrative` (pinned)
## Describe
LLM-reads **and pinned for the whole encounter** (never trimmed). Posted verbatim at session start. Put the core tension and the trigger here — tight and load-bearing. **The user writes this.** You do not.
## Options
The trigger the party walks into, in the user's own prose. May contain `{{placeholder}}` slots for `randomizable` draws (e.g. `{{vendor_name}}`).
## Tool
```
GRAPHMCP_URL=http://localhost:9000 npx tsx scripts/lore-atmosphere.ts "<vibe>" 8
```
This grounds *examples* of the place's atmosphere / recent events / live tensions. Surface 23 as **flavor the user may fold in** — explicitly as inspiration, not content to copy. **Do not** turn this into an `AskUserQuestion` of pre-written openings (that would author the scene for them).
## Ask
**Open prompt only.** *"Now write the opening — the trigger the party walks into, in your words. It's pinned for the whole encounter, so keep it tight and load-bearing. I can pull more atmosphere on <X> while you write if you want."* Wait for the user's prose. Record `openingNarrative` verbatim.
## Pitfalls
- **No dice results** ("the thief rolls a 15") — the bot controls dice; the LLM narrates only after `[SKILL CHECK RESULT]`.
- **No system tags / `tool_call` syntax** (`[TOOL]`, `[SKILL CHECK]`, fenced JSON) — the response filter strips/suppresses them.
- Too long / flavor burying the tension → it's pinned all encounter; trim with the user.
- Out-of-world voice → rework with the user.

View File

@@ -0,0 +1,25 @@
---
name: encounter-wizard-randomizable
description: Wizard section 9 — author randomizable (fields that vary per run). For random encounters, at minimum a name draw per on-stage NPC. Lore tool: lore-vocabulary.
---
# Section 9 — `randomizable` (especially for random encounters)
## Describe
Bot-enforces fields that vary per run so the same spec yields different substance. `key` binds the draw to a `{{placeholder}}` in `openingNarrative` or an `npc.nameKey`; `query` is the GraphMCP vocabulary prompt; `fallback` is used if the lookup fails; `source: vocabulary` + `category` routes to the vocabulary namespace.
## Options
Per-run name draws for the archetypal NPCs, plus 12 varying details (the specific item, a complication, a background detail). For a **random** encounter, at minimum a name draw per on-stage NPC.
## Tool
```
GRAPHMCP_URL=http://localhost:9000 npx tsx scripts/lore-vocabulary.ts "<vibe>" 8
```
The `query` strings themselves are lore prompts — use the chunks to help the user phrase good `query` values (e.g. "Mardonar common personal names," "small valuable goods a thief lifts"). This is **inspiration for the query text**, not a copy.
## Ask
Open prompt: *"Which fields should vary per run? For a random encounter I'd expect at least a name draw per NPC. Give me a key, what it stands for, and I'll help phrase the query."* For each, record `key`, the GraphMCP `query`, a `fallback`, and `source: vocabulary` / `category`. Bind the `key` to the matching `npc.nameKey` / `{{placeholder}}`.
## Pitfalls
- A `randomizable` key with no `{{placeholder}}` or `nameKey` consuming it → it draws but never shows; either bind it or drop it.
- `fallback` missing → the run breaks if GraphMCP is down; always provide one.

View File

@@ -0,0 +1,27 @@
---
name: encounter-wizard-setting
description: Wizard section 2 — author setting (location, mood, ambientNpcs). LLM-reads all three. Lore tool: lore-locations surfaces canon places as "which location?" options.
---
# Section 2 — Setting (`location`, `mood`, `ambientNpcs`)
## Describe
LLM-reads all three. `location` grounds the scene; `mood` is a style directive (a few adjectives — it eats context every turn, so keep it tight); `ambientNpcs` is background life without persona overhead (a passing drunk, a knot of laborers — flavor, not actors).
## Options
- A canon place (from the tool) vs. a new place the user names.
- A mood in a few adjectives.
- 12 ambient figures.
## Tool
```
GRAPHMCP_URL=http://localhost:9000 npx tsx scripts/lore-locations.ts "<vibe>" 8
```
Read the ranked chunks. Pick the **top 34 distinct named places** and present them as `AskUserQuestion` options — each `label` is the place name (≤5 words), `description` is a one-line paraphrase of the chunk (do not invent canon). Add no fabricated option; if the tool returned nothing useful, skip the question and ask openly.
## Ask
One `AskUserQuestion` call — **question:** *"Where does this happen?"* — with the 34 canon place options. The tool auto-adds "Other" so the user can name a new place. After they pick (or write a new one), follow with an **open prompt** for the mood adjectives and the ambient life. Record `location`, `mood`, `ambientNpcs`.
## Pitfalls
- `mood` longer than a few adjectives → trim with the user (it's per-turn context).
- `ambientNpcs` written like full NPCs (persona, goals) → those belong in `npcs`, not here.

View File

@@ -0,0 +1,22 @@
---
name: encounter-wizard-skillchecks
description: Wizard section 8 — author skillChecks (named DCs the LLM references by name) plus _skill/_note companions. Name checks for the action, not the stat. No lore tool.
---
# Section 8 — `skillChecks` (+ `_skill`/`_note` companions)
## Describe
Bot-enforces a map of named DCs the LLM references **by name** when it emits `skill_check_emit`. **Name checks for the action, not the stat** (`chase_dc`, not `dex_dc`). Free-text `_skill`/`_note` companions are LLM-read context that ride alongside — they never become dice. **Never put a dice result here** (the bot rolls; the LLM narrates only after the `[SKILL CHECK RESULT]`).
## Options
The uncertain actions in the scene, each with a DC + skill + optional note.
## Tool
None. You **propose the check names** from what the user has described so far (the scene's actions), then the user supplies the numbers + skill + note.
## Ask
Open prompt: *"Here are the uncertain actions I see in your scene: <list>. For each, what DC and skill? Add any I missed."* You propose the names; the user owns the DCs. Record the map (`chase_dc: 13`, `chase_skill: Dexterity`, `chase_note: ...`).
## Pitfalls
- Check named for a stat (`dex_dc`) → rename for the action with the user.
- A numeric value that looks like a roll result in a `_note` → it's context, not a result; rework.

View File

@@ -0,0 +1,26 @@
---
name: encounter-wizard-sportsmanship
description: Wizard section 7 — author sportsmanshipRules. LLM-reads hard guardrails that redirect absurd/game-breaking actions in-character. No lore tool; options are a template adapted from market-thief.
---
# Section 7 — `sportsmanshipRules`
## Describe
LLM-reads; hard guardrails on narration the LLM uses to redirect absurd or game-breaking actions **in-character**. Short in-world-ish directives. These are the rules the LLM enforces in fiction, not bot mechanics.
## Options
Adapt `market-thief.yaml`'s rules to this scene's dangers:
- No instant kills without escalation.
- No controlling other PCs.
- No unestablished abilities / equipment.
- No metagamed information.
- No teleport or flight without setup.
## Tool
None.
## Ask
Offer the template via `AskUserQuestion` as a **multiSelect****question:** *"Which sportsmanship rules apply here?"* — options: the five above (the user toggles the ones that fit). Follow with an open prompt to add scene-specific rules (e.g. "no escaping the rite circle without a check"). Record the final list in the user's words.
## Pitfalls
- Rules written as bot mechanics ("rate limit", "system") → rework in-world with the user.

View File

@@ -0,0 +1,21 @@
---
name: encounter-wizard-tone
description: Wizard section 3 — author tone (optional). LLM-reads for narration flavor; bot-enforces the in-world drop-notice string. No lore tool; options are a fixed palette.
---
# Section 3 — `tone` (optional)
## Describe
LLM-reads (narration flavor) + bot-enforces (selects the in-world drop-notice string when the burst cap drops a message). Unrecognised tones fall back to a baseline notice, so pick a value the engine knows or accept the baseline.
## Options
A small palette: `grim`, `grimdark`, `tense`, `mysterious`, `comedic`, `heroic`. Or omit (engine baseline).
## Tool
None.
## Ask
One `AskUserQuestion` call — **question:** *"What tone should the narration carry?"* — options: `grim` (Recommended for a grimdark scene), `tense`, `mysterious`, `comedic`. The user can pick "Other" to write a different word or "omit". Record `tone` (or leave it unset).
## Pitfalls
- Don't invent a tone word the engine doesn't recognize — either use a known value or omit.

View File

@@ -0,0 +1,25 @@
---
name: encounter-wizard-tools
description: Wizard section 10 — author tools (which engine plugins are active). Omitting activates the default set; listing narrows it. Every name MUST be a registered plugin. Tool: list-tools.
---
# Section 10 — `tools`
## Describe
Bot-enforces which tool plugins are active. **Omitting activates the default set; listing narrows it.** Every name MUST be a registered plugin — the `specsToolsConsistency` test fails the build on unknown names.
## Options
The full default set (omit `tools:`) vs. a narrowed set (e.g. drop `skill_check_emit` for a no-combat scene, drop `foundry_reward` if no XP/items).
## Tool
```
npx tsx scripts/list-tools.ts
```
Prints every registered plugin (name + description + args). **Only names from this list are valid.** Show it to the user.
## Ask
One `AskUserQuestion` call — **question:** *"Which tools should this encounter activate?"* — as a **multiSelect** of the registered plugin names (default all on). The user toggles. If they leave all on, recommend **omitting** `tools:` entirely (cleaner — the engine default applies). Record the list, or omit the field.
## Pitfalls
- A name not in `list-tools.ts` → reject; the build would fail.
- Listing the full default set explicitly when it equals the default → recommend omitting instead.

View File

@@ -0,0 +1,22 @@
---
name: encounter-wizard-xpreward
description: Wizard section 12 — author xpReward (optional flat XP on resolution). Default: omit. Never add xpReward to specs/market-thief.yaml. No lore tool.
---
# Section 12 — `xpReward` (optional)
## Describe
Bot-enforces; flat XP to all participants on resolution. **Default: omit** — many encounters grant no XP.
## Options
Omit, or a flat number.
## Tool
None.
## Ask
One `AskUserQuestion` call — **question:** *"Does this encounter grant XP on resolution?"* — options: `No XP (omit — recommended)`, `Yes, flat XP` (the user writes the number via "Other"). Record `xpReward` only if they explicitly want it.
## Pitfalls
- **Never add `xpReward` to `specs/market-thief.yaml`** — a live test (AC9 R4) depends on it staying xpReward-free.
- For a *new* spec, add `xpReward: <n>` only on the user's explicit request.

View File

@@ -0,0 +1,130 @@
---
name: mardonar-encounter
description: Author and refine a Mardonar encounter spec YAML from a story the user writes. Validates against the engine's real EncounterSpecSchema (in-process loadSpec round-trip), enforces the LLM-contract pitfalls from docs/spec-authoring-guide.md, optionally grounds NPCs/setting in GraphMCP lore, and writes loader-shaped YAML the user commits to the mardonar-specs Gitea repo. MUST run with the engine repo (mardonar-npcs) as cwd.
---
# Mardonar Encounter Authoring Skill
You help the user author a **contract-adherent encounter spec** — a YAML file the engine's LLM can read and drive. The user writes the in-world **story** (the prose: setting, NPCs, goals, opening narrative); you structure it into spec fields, validate it against the engine's *real* schema, enforce the LLM-contract pitfalls, and iterate with the user until it's clean. You do **not** author lore or invent canon — the user supplies the story; you shape and validate it.
This is a structured-input workflow. **No AI spec generation.** The retired `/encounter generate` line exists for a reason: AI never authors canonical lore. You refine what the user wrote.
## Run guard (do this first)
This skill runs with the **engine repo as cwd** so it can import the real contract. Before anything else, confirm `src/spec/loader.ts` exists in the current directory.
- If it does → proceed.
- If it does not → stop and tell the user: *"Run this from the Mardonar engine repo (mardonar-npcs) root — the skill validates against `src/spec/loader.ts`, which has to be on disk here."* Do not attempt to author without the contract.
The helper scripts load the engine's `.env` (via `dotenv/config`) because `src/config.ts` requires `DISCORD_TOKEN`/`DISCORD_CLIENT_ID` at import. That's expected — the engine repo has them.
## Load the contract surface (read once per run)
Read these before structuring anything (they are the single source of truth):
1. `docs/spec-authoring-guide.md` — the canonical authoring guide. **The "Pitfalls — the LLM contract" section is your EB-4 linter rule set.** Re-read it each run; do not cache it from memory, so you can never drift from the doc.
2. `specs/market-thief.yaml` — the fully-annotated reference spec. Copy its shape, not its content. It exercises every common field with inline `#` comments.
Do **not** re-derive the schema from memory. The schema is `EncounterSpecSchema` in `src/spec/loader.ts` (types via `z.infer`). The helper scripts call it directly — that's the validation, not your recollection.
## The authoring flow
### 1. Gather the story from the user
**You do not author the scene. The user does.** Your job in this step is to *ask* for the in-world prose and, if they want grounding, *surface real canon options* via `search-lore` (step 2) — then hand the pen back and wait. Do NOT propose a full scenario (NPCs, trigger, goals, skill checks) for the user to confirm. That is the user's creative work, not yours. Proposing a scenario is the same failure mode as the retired `/encounter generate`: AI authoring canonical lore. Even framed as "a proposal for you to edit," it puts your invented scene in front of them and makes them a confirmer, not an author. Surface lore hooks as a toolbox; let the user write.
**Random encounters use archetypal NPCs, not named canon characters.** If this is a random encounter (replayable), the on-stage `npcs[]` are types with voice/behavior (e.g. "a blood mage at a rite," "a bound sacrifice," "a cult acolyte") whose names come from `randomizable` draws — never named canon NPCs (Roland Raventhorne, Silas Viper, etc.). Canon places/factions/traditions may be the *backdrop*; named canon characters belong in scripted one-off encounters only. Mirror `specs/market-thief.yaml`: canon setting, archetypal people, `nameKey`/`randomizable` names. If unsure whether the user means random vs. scripted one-off, ask before structuring.
Ask for the in-world prose. Open prompts beat a rigid form — let the user narrate. You need enough to fill:
- **Setting** — `location`, `mood` (a few adjectives), `ambientNpcs` (background life, no persona overhead).
- **Opening narrative** — the scene-setting text posted at session start. Pinned for the whole encounter, so it must be tight and load-bearing. In-world.
- **NPCs** — 13 of them. For each: a stable `id` (kebab), `name`, `role`, and a **persona** that is *voice/behavior*, not a stat block (how they speak, what they want, how they react under pressure — 13 sentences). Add `memoryKey` only for NPCs that should remember across encounters; `nameKey` only if the name needs a stable graph key.
- **Goals** — 23 primary (what the scene steers toward) + any secondary (valid-but-not-main). Each needs a stable `id` and a `label` (the label becomes the closing embed's Outcome text). Note whether goals are `hidden` (default true).
- **Skill checks** — named DCs (`chase_dc: 13`), named for the *action* not the stat. Optional free-text companions (`chase_skill`, `chase_note`) ride alongside as LLM-read context; they never become dice.
- **Tone** (optional), **sportsmanshipRules**, **randomizable** (fields that vary per run), **dmNotes**, **tools** (which engine plugins are active).
If the user only has a partial story, work with what they gave you and ask for the gaps. Do not invent lore to fill them.
### 2. Optionally ground in canon (GraphMCP, read-only)
If the user wants to align NPCs/setting with existing canon, search the knowledge graph. Run from the repo root:
```
GRAPHMCP_URL=http://localhost:9000 npx tsx scripts/search-lore.ts search "<a phrase the user wants to ground>" 5
GRAPHMCP_URL=http://localhost:9000 npx tsx scripts/search-lore.ts npc "<NPC name>" "<what would they remember?>" 5
```
**Host override required.** The engine's `.env` sets `GRAPHMCP_URL` to the Docker-internal hostname `mcp-server`, which is NOT resolvable from the host — so `search-lore` fails with `fetch failed` unless you prefix `GRAPHMCP_URL=http://localhost:9000` (the `mcp-server` container maps `0.0.0.0:9000`). `dotenv` never clobbers an existing `process.env`, so the command-line override wins. Same convention as the live E2E suite. (The `mcp-server` container must be running — check `docker ps | grep mcp-server`.)
Use the results to align `memoryKey`, persona, and setting detail with what's already established. **Never copy raw lore into the spec** — surface the matches to the user and let them decide what to fold in and how to phrase it. GraphMCP is read-only; this skill never writes to it. If GraphMCP is unreachable (connection refused / timeout), tell the user explicitly ("GraphMCP unreachable — authoring without canon grounding") and continue; do not silently present an empty result as "nothing exists."
### 3. Optionally see which tools exist
If the user is deciding the `tools:` set, show the registered plugins so they pick real names:
```
npx tsx scripts/list-tools.ts
```
Only names printed by that script are valid for `tools:`. Omitting `tools:` activates the engine default set. (The engine's `tests/unit/specsToolsConsistency.test.ts` fails the build if a spec references an unknown tool — so never offer a name that isn't in the list.)
### 4. Structure the story into spec fields
Draft the YAML in the loader's shape (see `specs/market-thief.yaml` and `docs/spec-authoring-guide.md` for the exact structure). Use the user's prose verbatim where it lands in LLM-read fields (`setting`, `openingNarrative`, `persona`, goals' `label`, `dmNotes`); structure-only fields (`id`s, DC numbers, `tools`, `randomizable` keys) are yours to shape.
Give the spec a new `encounterId` (kebab-case). **Never reuse or rename an existing live `id`** — goal and NPC ids are referenced by `goal_register` / `encounter_resolve` / memory across encounters.
### 5. Validate against the real schema (the acceptance test)
Write the draft to `./specs/<encounterId>.yaml` (or a path the user chooses) and run:
```
npx tsx scripts/validate-spec.ts <encounterId>
# or: npx tsx scripts/validate-spec.ts ./path/to/draft.yaml
```
- **OK** → the spec passes `EncounterSpecSchema` *and* a real `loadSpec()` round-trip (the same code path `/encounter start` uses). This is the acceptance test — it closes the gap between "valid YAML" and "boots a session."
- **FAIL** → the script prints field-named Zod issues (e.g. `npcs.1.persona: Required`). Fix each with the user and re-run. Do not hand the user a spec that hasn't passed this.
### 6. Enforce the pitfalls (EB-4 linter)
Even after Zod passes, check the draft against the "Pitfalls — the LLM contract" section of `docs/spec-authoring-guide.md`. Zod cannot catch these; you must:
- **No dice results in prose.** `openingNarrative` / `persona` must never pre-declare a roll ("the thief rolls a 15"). The bot controls dice; the LLM narrates only after the `[SKILL CHECK RESULT]` message.
- **No system tags or `tool_call` syntax in prose.** No `[TOOL]`, `[SKILL CHECK]`, fenced JSON, or `tool_call` blocks in `openingNarrative` or `persona` — they'd be stripped or suppressed by the engine's response filter.
- **Personas are voice, not stats.** No HP/AC/spell lists. Foundry owns stats; a stat-block persona wastes context the LLM can't use.
- **`openingNarrative` is pinned and tight.** It stays in context the whole encounter — don't bury the core tension in flavor.
- **`id` fields are stable.** No renaming live ids.
If you find a violation, name the field and the rule to the user and fix it together. Re-validate after fixes.
### 7. Confirm xpReward intent
`xpReward` is optional — flat XP to all participants on resolution. **Default: omit it** (many encounters grant no XP). Only add it if the user explicitly wants resolution XP. **Never add `xpReward` to `specs/market-thief.yaml`** — that reference spec must stay xpReward-free (a live test, AC9 R4, depends on it having no XP default). For a *new* spec, add `xpReward: <n>` only on the user's explicit request.
### 8. Write the final YAML
Write the clean, validated spec to `./specs/<encounterId>.yaml` (or the user's chosen path). Keep it plain and loader-shaped — no frontmatter, no wrapper comments beyond the kind `market-thief.yaml` carries. Then give the user the commit instruction for the Gitea corpus, e.g.:
```
# In your mardonar-specs checkout:
cp ./specs/<encounterId>.yaml /path/to/mardonar-specs/<encounterId>.yaml
cd /path/to/mardonar-specs && git add <encounterId>.yaml && git commit -m "Add <encounterId>" && git push
```
This skill never pushes to Gitea itself — it stays credential-free. The user commits.
## Constraints (always in force)
- **In-world voice for the spec prose.** All LLM-read fields the user writes (`setting`, `openingNarrative`, `persona`, goal `label`s, `dmNotes`) use in-world language — never utility terms ("session", "user", "bot", "system", "queue", "error"). The skill's own agent-facing instructions are exempt (they're for you, not players).
- **Single-source the contract.** Never re-declare the schema or the tool list from memory. Validate via `scripts/validate-spec.ts`; list tools via `scripts/list-tools.ts`. (`feedback-schema-as-type-source`: the type is `z.infer<typeof EncounterSpecSchema>`.)
- **Never mutate `specs/market-thief.yaml`.** It's the annotated reference and a live-test fixture. Copy its shape into *new* files.
- **No AI lore authoring.** You refine the user's story; you don't invent canon.
- **Deterministic output.** Stable key ordering, no incidental whitespace, so the Gitea diff stays clean.
## Helper scripts (all run from the repo root via `npx tsx`)
- `scripts/validate-spec.ts <slug-or-path>` — validate a draft against the real `EncounterSpecSchema` + `loadSpec()`. Exit 0 + summary on success; exit 1 + field-named Zod issues on failure. **This is the acceptance test.**
- `scripts/list-tools.ts` — print every registered engine tool plugin (name, description, args). The only valid source of `tools:` names.
- `scripts/search-lore.ts search "<query>" [limit]` / `scripts/search-lore.ts npc "<name>" "<question>" [limit]` — read-only GraphMCP lore lookup for canon grounding.

View File

@@ -24,4 +24,84 @@
User claim-by-claim: "simple" → Why + simple/local constraint + non-goal (no cloud service); "encounter builder tool" → title + CAP-1..CAP-6; "i can use" → affected party = the author (the user); "outputs the spec in a format that adheres to the llm contract" → CAP-1/CAP-3/CAP-4 + constraints (shares contract, enforces pitfalls) + adopted `voice-rules.md`; "another project" → separate-project constraint + non-goals (no runtime runner, no Foundry). All load-bearing claims landed in SPEC.md or an adopted companion. No wrapper ceremony to drop.
**Verdict:** coherent and preservation-complete. 4 open questions remain (form factor, contract-sharing mechanism, output destination, validation depth) — all block implementation and need a human decision, but none block the spec itself. The engine contract the builder targets is captured in `spec-mardonar-encounter-engine/` (CAP-18 authoring guide, CAP-17 Gitea pipeline) so the builder spec is downstream-ready once those questions are answered.
**Verdict:** coherent and preservation-complete. 4 open questions remain (form factor, contract-sharing mechanism, output destination, validation depth) — all block implementation and need a human decision, but none block the spec itself. The engine contract the builder targets is captured in `spec-mardonar-encounter-engine/` (CAP-18 authoring guide, CAP-17 Gitea pipeline) so the builder spec is downstream-ready once those questions are answered.
## 2026-06-20 — PRD create (Fast path) + 4 spec open questions resolved
**Intent:** create PRD FROM `SPEC.md` (preservation-validated, 6 CAPs). Working mode = **Fast path** (internal-tool stakes; author gave directional answers, not a brain-dump). PRD written to `_bmad-output/specs/spec-encounter-builder/prd.md` (same folder as the spec, per author request — "in this repo, next to the spec").
### Resolved open questions (asked of the author 2026-06-20)
1. **Form factor → [ASSUMPTION] local web app (localhost browser).** Author asked for "a fairly moderate tool… I need to be able to make new objects, see what tools exist, look up lore in the knowledge graph." A multi-panel UI (lore results + tool list + authoring forms side by side) fits a localhost web app best; a linear CLI wizard does not. Flagged as `[ASSUMPTION]` — author may redirect to a terminal TUI; the FRs/NFRs are form-factor-agnostic so this changes only the UI stack. → OQ in PRD §9.
2. **Contract-sharing → publish the engine's `EncounterSpecSchema` + registered tool-plugin list as a versioned npm package in the Gitea npm registry; builder installs it.** Author's answer: "can we publish the npm package in gitea registries?" Yes — this is the single-source, no-drift option. It is a **downstream engine-side change** (engine repo must expose + publish the artifact) → captured as prerequisite **P-1** and FR-9. Until P-1 lands, the builder cannot consume the contract via FR-9 (open item).
3. **Output destination → local dir, author commits to Gitea manually.** Author's answer: "Local dir, you commit to Gitea." Keeps the builder credential-free (no Gitea token shipped with a local tool). → FR-2/FR-6; direct push deferred to non-goals.
4. **Validation depth → Zod at runtime, schema sourced from the published package.** Author's answer: "Zod at runtime (copied schema)" — interpreted as *consume the published Zod schema*, not hand-copy. Gives field-named, actionable errors (no raw Zod dump) and zero drift. → FR-3/FR-9.
### New capabilities the answers surfaced (NOT in SPEC.md's 6 CAPs)
Author's form-factor answer expanded the tool beyond the spec's "simple" framing: **lore lookup via GraphMCP (read-only)** (FR-7) and **tool discovery** (FR-8). These are PRD-originated; the spec needs a **CAP-7 / CAP-8 add on its next `bmad-spec` update** to avoid spec↔PRD drift. → OQ-1 in PRD §9. No new companion files authored; the contract still lives in the engine spec (adopted companions), and GraphMCP is the engine's existing read surface (P-2 verification only).
### Scope of FRs vs spec CAPs
FR-1..FR-6 map 1:1 to CAP-1..CAP-6. FR-7/FR-8 are new. FR-9 (shared contract via published package) is the mechanism for CAP-3's "shared contract" — pulled out as its own FR because the package-publishing prerequisite (P-1) is a first-class open item the builder depends on but does not own.
### Finalize notes (stakes-calibrated: internal tool)
Per `bmad-prd` Finalize guidance for internal/hobby stakes, the reviewer gate is run **light**, not as a full parallel reviewer panel: self-review of the PRD against the Essential Spine + the spec's CAP coverage, triage of open items (below), polish, then set `status: final`. A full `bmad-create-architecture` pass — not this PRD — is where the form-factor assumption and the package-consumption design get adversarially stress-tested.
### Open items deferred (non-phase-blockers, owner + revisit condition)
- **OQ-1 Spec sync** — owner: next `bmad-spec` update pass on this spec; revisit: before architecture. Non-blocker for the PRD (PRD flags it); blocker for spec↔PRD consistency.
- **[ASSUMPTION] Form factor = web** — owner: author; revisit: at architecture kickoff (redirect to TUI if author prefers in-terminal). Non-blocker for the PRD.
- **[ASSUMPTION] GraphMCP URL configurable** — owner: author; revisit: architecture. Non-blocker.
- **P-1 Engine schema package** — owner: engine repo work (separate effort); revisit: before builder implementation can consume the contract via FR-9. **Phase-blocker for builder implementation**, not for this PRD.
- **OQ-2/OQ-3 Target repo + package scope/name** — owner: author; revisit: alongside P-1. Non-blocker for the PRD.
- **OQ-4 Pitfall-linter rule source** — owner: architecture; revisit: when designing FR-4. Non-blocker (rule set is sourced from `voice-rules.md`, already an adopted companion).
### Finalize (close)
Finalize run light (internal-tool stakes): self-review against the Essential Spine + CAP coverage in place of a full parallel reviewer panel. Self-review caught one cross-reference bug from the FR renumbering (FR-7/FR-8 inserted as new capabilities shifted the "credential-free manual commit" reference off FR-8) — fixed in `prd.md` FR-6 and §10. Input reconciliation: the source `SPEC.md` was already preservation-validated; all 6 CAPs map to FR-1..FR-6, all 4 spec open questions resolved + logged above, spec constraints reflected in non-goals/NFRs/FR-9 — no load-bearing claim dropped. Polish applied. Frontmatter set `status: final`, `updated: 2026-06-20`.
**Artifacts:** `_bmad-output/specs/spec-encounter-builder/prd.md` (final). **Next:** `bmad-create-architecture` — resolves the UI stack given the form-factor assumption + the package-consumption design (P-1) and stress-tests both; then `bmad-create-epics-and-stories`. Author should confirm web-vs-TUI before architecture kicks off.
## 2026-06-20 — Re-scope: helper-tools suite, builder first
**Signal:** author confirmed form factor = **local web app** ("web app feels right") and added "I want to make helper tools." Clarifying question resolved the scope: **helper-tools suite, builder first** — a local web app that hosts pluggable DM helper tools, with the Encounter Builder as plugin #1. The shell is built to host more tools; v1 ships only the builder.
**What changed in the PRD:**
- **Title** → "Mardonar Helper Tools — PRD" (was "Mardonar Encounter Builder — PRD").
- **Form factor assumption → confirmed.** "[ASSUMPTION] local web app" is now "CONFIRMED 2026-06-20" — no longer an open assumption.
- **FRs re-bucketed by layer.** The encounter-builder-specific FRs (FR-1..FR-6) become **EB-1..EB-6** (Tool #1, CAP-1..CAP-6, unchanged in substance). The PRD-originated capabilities that didn't fit the builder spec (FR-7 lore lookup, FR-8 tool discovery) plus the contract mechanism (FR-9) become **suite-shell shared services SH-1..SH-5**: SH-1 tool hosting/nav, SH-2 lore lookup via GraphMCP, SH-3 tool discovery, SH-4 shared contract via published package, SH-5 validate+export plumbing. This *resolves* the earlier OQ-1 awkwardness — lore lookup and tool discovery are naturally shell-level, not builder-level.
- **New §4 Suite shape** describes the shell + shared services + plugin model.
- **New NFR** "Extensible shell" (adding a tool is a registration, not a shell-core change) + design-goal success metric.
- **OQ-1 evolved:** the spec sync question is now "add CAP-7+ for shell concerns to this spec, OR split into a separate suite-shell spec (builder as adopted companion)." Splitting is flagged cleaner — the shell and the tool are different things.
- **New OQ-5** plugin contract (what a tool registers), **OQ-6** anticipated future tools (so shared services are designed with them in mind).
**Why this is a re-scope, not a rewrite-from-scratch:** the 6 builder CAPs (EB-1..EB-6) are unchanged in substance; only their container changed from "the app" to "plugin #1 in the shell." The author's intent ("helper tools" plural) is honored by making the shell the product and the builder the first occupant, without speccing tools that don't exist yet (v1 = one tool; OQ-6 collects anticipated ones).
**Spec-sync implication:** `SPEC.md` still describes only the builder tool (CAP-1..CAP-6). The suite shell (SH-1..SH-5) is PRD-originated and has no spec home yet. This is an open item (OQ-1) for the next `bmad-spec` pass — not blocking the PRD, which now carries the shell requirements.
**Finalize:** status remains `final`; the re-scope is a refinement of an already-finalized internal-tool PRD, not a new draft cycle. Self-review confirmed all 6 builder CAPs still map to EB-1..EB-6 and the shared services cover the former FR-7/FR-8/FR-9 without loss. Updated `updated: 2026-06-20`.
## 2026-06-20 — PIVOT: web-app suite → Claude Code skill (PRD + architecture SUPERSEDED)
**Signal:** during `bmad-create-architecture` step 2, Party Mode ran four perspectives (Frontend & Tooling, Platform & Package, YAGNI Skeptic, Contract & Integration) on the Project Context Analysis. The YAGNI Skeptic and Frontend Architect converged on the same conclusion: a web-app "Suite Shell + plugin framework" is premature for one user authoring specs with agent help — build the one tool, define a *convention* not a framework, extract shared code on the second use. The user read the four perspectives and said *"That makes this clear to me. a simple cli makes more sense. lets pivot. i can write a story, and work with an agent to refine it. maybe we just make a skill?"*
**Decision:** abandon the separate-project web app. The encounter builder becomes a **Claude Code skill** (project-scoped, in the engine repo at `.claude/skills/mardonar-encounter/`). The user writes the in-world story; the skill's agent flow structures it into spec fields, validates against the real contract, enforces pitfalls, optionally grounds in GraphMCP lore, and writes the YAML. The user commits to the Gitea `mardonar-specs` repo manually (credential-free, unchanged).
**Why the skill form wins (and what it dissolves):**
- **P-1 (published npm package) — dissolved.** The skill runs with the engine repo as cwd, so validation is a `npx tsx` call to the *actual* `loadSpec()` / `EncounterSpecSchema` in `src/spec/loader.ts`. No package to publish, no Gitea npm registry, no build-time drift check — the contract is single-sourced by construction (you're importing the source). The Contract architect's "run `loadSpec()` in-process as the real acceptance test" becomes a one-line script, for free — and it closes the "passes Zod, fails at `/encounter start`" gap because `loadSpec()` *is* the `/encounter start` code path.
- **Web stack / backend / CORS / GraphMCP proxy — dissolved.** Lore lookup is a `tsx` call to the engine's `src/graphmcp/client.ts` (GraphMCP at `localhost:9000`, read-only, already used by the engine). No browser, no CORS.
- **"Suite Shell" + plugin framework — dissolved, and better.** A new helper tool = a new skill in `.claude/skills/`. That *is* the plugin model and it costs nothing. The "helper tools suite" is a family of skills, not an app with a shell.
- **Deterministic output / Gitea-ready / credential-free — unchanged.** The skill writes `<encounterId>.yaml`; the user commits.
**Confirmed choices (AskUserQuestion 2026-06-20):** run context = **engine repo as cwd** (contract via direct `loadSpec`, no package); proceed = **build the skill now** (SKILL.md + thin helper scripts, skip ceremony).
**Carry-over from the discarded web-app design (still valid for the skill):** EB-4 pitfall linter (single-source rules from `docs/spec-authoring-guide.md` "Pitfalls" — the agent reads the guide each run, so drift is structurally harder); EB-5 load-as-template (`specs/market-thief.yaml` reference, trivial for an agent); the in-world-voice constraint applies to the *spec prose the skill produces*, not the skill's own agent-facing UI; `market-thief.yaml` must stay xpReward-free and must not be mutated by the skill (AC9 R4); new `encounterId` per spec, never rename live ids.
**Open item carried forward (Contract architect):** some "pitfalls" are actually Zod-enforceable and should move into `EncounterSpecSchema` (`skillChecks` numeric range, `npcs[].id` uniqueness, `nameKey`/`memoryKey` derivation). That's an *engine* schema-tightening, separate from the skill — flagged for later, not blocking the skill.
**Artifacts status:**
- `prd.md`**SUPERSEDED.** Kept as the discarded-alternative record (web-app suite). Frontmatter unchanged (status: final) so the supersede is visible only via this log entry; the doc reads as a complete, internally-consistent web-app PRD that we chose not to build.
- `architecture.md`**SUPERSEDED at step 1** (no decisions were ever saved; only the template + Project Context Analysis draft existed, and the draft was never committed — the user pivoted at the Party Mode menu before [C]). Replaced with a one-line superseded pointer.
- **New artifacts:** `.claude/skills/mardonar-encounter/SKILL.md` (project-scoped skill) + helper scripts `scripts/validate-spec.ts`, `scripts/list-tools.ts`, `scripts/search-lore.ts` (thin wrappers over existing engine code — `loadSpec`, the tool registry, and the GraphMCP client). No new deps, no package, no web app.

View File

@@ -0,0 +1,16 @@
---
stepsCompleted: [1]
inputDocuments: []
workflowType: 'architecture'
project_name: 'Mardonar Helper Tools'
user_name: 'Kaysser'
date: '2026-06-20'
status: superseded
superseded_by: Claude Code skill — see .decision-log.md 2026-06-20 PIVOT entry
---
# Architecture Decision Document — SUPERSEDED
This architecture was abandoned at step 1 (no decisions saved). The web-app "helper tools suite" framing was rejected during Party Mode review in favor of a **Claude Code skill** that runs in the engine repo. See the `.decision-log.md` "PIVOT" entry (2026-06-20) for the rationale and the carried-over decisions.
The skill lives at `.claude/skills/mardonar-encounter/SKILL.md` with helper scripts in `scripts/`. The `prd.md` in this folder is retained as the discarded-alternative record.

View File

@@ -0,0 +1,129 @@
---
title: Mardonar Helper Tools — PRD
status: final
created: 2026-06-20
updated: 2026-06-20
source_spec: ./SPEC.md
target_repo: separate project (TBD — see Open Questions)
---
# Mardonar Helper Tools — PRD
> **Re-scoped 2026-06-20** from "encounter builder" to a **helper-tools suite**: a local web app that hosts pluggable DM helper tools, with the **Encounter Builder** as the first tool (plugin #1). The form factor was confirmed as a **local web app** the same day (no longer an assumption). Authored FROM `SPEC.md`, whose 6 capabilities describe the Encounter Builder *tool*; the suite-shell concerns (tool hosting + shared services) are PRD-originated and flagged for spec sync at **OQ-1**.
## 1. Problem & Vision
One DM runs the whole Mardonar campaign — authoring encounters, keeping lore consistent, picking the right tool plugins for each scene. He doesn't need one big app; he needs a **workbench of small helpers** that share the same canon (the knowledge graph) and the same engine contract (the spec schema + registered tools). Today the only painful one is hand-authoring encounter YAML — strict Zod schema, sharp LLM-contract pitfalls, stable `id` fields that can't be renamed once live. So that's the first tool to build.
The vision is a **local web app** (localhost, single author, no auth) that hosts pluggable helper tools and exposes a few **shared services** every tool can use: lore lookup against the GraphMCP knowledge graph, discovery of the engine's registered tool plugins, and the engine's spec contract consumed as a published package. **Tool #1 is the Encounter Builder** — a structured-input workbench (no AI authoring) that outputs contract-adherent encounter specs. The shell is built to host more tools later, but v1 ships only the builder.
This is a **separate project** from the Mardonar Encounter Engine. It targets the engine's contract (single-sourced, consumed as a published package) and reads the knowledge graph (GraphMCP) for lore. It does **not** run encounters and does **not** author lore with AI.
## 2. Goals
- The suite shell hosts pluggable tools; adding a tool is a registration, not a shell change.
- Shared services (lore lookup, tool discovery, contract package, validate+export) are built once and reused by every tool.
- The Encounter Builder produces a spec that passes the engine's `EncounterSpecSchema` (Zod) and loads via `/encounter start` without a validation error — on the first try.
- The Encounter Builder covers every common field through guided input, not hand-written YAML.
- An invalid spec or an LLM-contract pitfall never leaves the tool.
- Builder output drops into the Gitea spec corpus unmodified.
## 3. Non-goals
- **Not an LLM spec generator.** AI never authors lore; every tool is a structured-input workbench for a human author. (Upholds the engine's retired `/encounter generate` line.)
- **Not a runtime encounter runner.** No Discord, no LLM narration, no session state. The suite produces artifacts; the engine runs them.
- **Not a Foundry/VTT integration.** Sheets and journals are Foundry's domain.
- **Not a multi-user or cloud service.** One author, local tool.
- **Not a spec versioning/migration tool.** v1 authors new specs; migrating/renaming live `id` fields across an existing corpus is out of scope.
- **v1 ships one tool.** The shell is built to host more, but no other helper tools are specced or shipped in v1 (see OQ-6 for anticipated ones).
## 4. Suite shape
- **Shell.** The localhost web app: tool registry + nav, routes to the selected tool's panel, owns the shared services. A tool is added by registering it — the shell core doesn't change.
- **Shared services (used by any tool):**
- *Lore lookup* — read-only search of the GraphMCP knowledge graph for existing NPCs/places/events, so a tool can ground what it writes in canon.
- *Tool discovery* — the engine's registered tool-plugin list (names + one-line descriptions), so a tool can choose which engine plugins an artifact activates.
- *Contract package* — the engine's `EncounterSpecSchema` + plugin list consumed as a published npm package (single-sourced, no drift).
- *Validate + export* — validate-against-schema and write-artifact-to-local-dir plumbing reused by any artifact-producing tool.
- **Tool #1 — Encounter Builder.** The 6-capability authoring tool from `SPEC.md`. Uses all four shared services.
A **plugin contract** defines what a tool registers with the shell (id, title, panel component, its own FRs). The exact contract is an architecture decision (OQ-5).
## 5. User Journey (single operator)
**Kaysser, the DM**, wants to add a new market-square encounter to the campaign.
1. He opens the suite (localhost). The nav lists the Encounter Builder (the only tool in v1). He opens it; a new-encounter workspace loads with the field spine: setting, NPCs, goals, skillChecks, tone, randomizable, party-size envelope, tools.
2. He's unsure what Dwarf names already exist in canon. He opens the **lore panel** (shared service), searches "Dwarf vendor Mardonar," and GraphMCP returns matching NPCs with a one-line note each. He picks one to ground his vendor NPC — its `memoryKey` and persona seeds pre-fill from the graph result (he edits the prose himself).
3. He isn't sure which tools this encounter needs. He opens the **tools panel** (shared service) and sees the registered plugin list (`skill_check_emit`, `encounter_resolve`, …) with a one-line description each. He picks the set this encounter activates.
4. He loads `market-thief.yaml` as a template to reuse its structure, edits the fields, gives it a new `encounterId`.
5. He hits **Validate**. Zod runs against the published schema (shared service); one persona fails the stat-block pitfall check — the message names the field and the rule. He fixes it.
6. He hits **Export**. The builder writes `<encounterId>.yaml` (plain, loader-shaped, no frontmatter) to a local dir (shared service). He reviews it, then commits it to the `mardonar-specs` Gitea repo himself.
## 6. Functional Requirements
Stable IDs, bucketed by layer. Shell-level (SH-) are suite-wide; tool-level (EB-) are the Encounter Builder (CAP-N noted).
### Suite shell (shared)
- **SH-1 Tool hosting & nav.** The shell loads registered tools, presents them in a nav, and routes to the selected tool's panel. A new tool is added by registering it — no shell-core change.
- **SH-2 Lore lookup via GraphMCP (read-only).** Read-only search of the knowledge graph (GraphMCP JSON-RPC) for existing NPCs/places/events; surfaces matches so a tool can ground `memoryKey`, persona, and setting detail in canon. The suite only **reads** GraphMCP; it never writes. Available to any tool that needs canon grounding.
- **SH-3 Tool discovery.** Shows the engine's registered tool plugins (names + one-line descriptions) so a tool can choose an artifact's activated plugins intentionally. The plugin list is single-sourced from the engine (via the contract package — SH-4).
- **SH-4 Shared contract via published package.** The suite consumes the engine's `EncounterSpecSchema` (Zod) and the registered tool-plugin list as a versioned npm package published to the Gitea npm registry — never a re-implementation. A contract-consistency check fails the build if the installed package is out of sync with the engine's current contract.
- **SH-5 Validate + export plumbing.** Shared validate-against-schema and write-artifact-to-local-dir used by any artifact-producing tool. The Encounter Builder uses it (EB-2/EB-3/EB-6); future artifact-producing tools reuse it without re-implementing.
### Tool #1 — Encounter Builder (CAP-1..CAP-6)
- **EB-1 Guided field authoring (CAP-2).** Inputs cover every common field in `encounter-spec-fields.md` — setting, npcs/personas, goals (primary/secondary + hidden), skillChecks (named DCs + free-text `_skill`/`_note` companions), tone, randomizable, minPlayers/maxPlayers, campaignId, tools, dmNotes, xpReward — emitted in the loader's expected shape.
- **EB-2 Valid YAML output (CAP-1).** Output is a plain YAML file named `<encounterId>.yaml`, loader-shaped, no frontmatter/wrapper, written to a chosen local directory — via SH-5.
- **EB-3 Contract validation (CAP-3).** Validate the spec against the engine's Zod `EncounterSpecSchema` at runtime (schema from SH-4, plumbing from SH-5). A missing/broken field is blocked before output with a message naming the field and the rule.
- **EB-4 LLM-contract pitfall enforcement (CAP-4).** Detect and warn on: a dice result in `openingNarrative`/`persona`, a system tag, fenced `tool_call` syntax, or a stat-block persona — per `voice-rules.md`, with a how-to-fix message.
- **EB-5 Load existing spec as template (CAP-5).** Ingest the repo's annotated reference spec (`market-thief.yaml`) — or any spec the author points at — and pre-fill its fields for editing; saving emits a new spec with a new `encounterId`.
- **EB-6 Gitea-ready output (CAP-6).** Output file is exactly what the loader expects, so it drops into the Gitea corpus unmodified (via SH-5). Commit/push to Gitea is the author's manual step; the builder writes a local file and stays credential-free — see §11.
## 7. Non-Functional Requirements
- **Local & single-user.** Runs on the author's machine (localhost); no auth, no multi-user, no cloud.
- **No engine-runtime dependency.** Depends on the published contract package (versioned) and GraphMCP (read-only HTTP). Does **not** depend on the engine's Discord/Redis/LLM runtime. GraphMCP reachability is required only for lore lookup (SH-2); the rest of the suite works offline.
- **Extensible shell.** Adding a tool is a registration, not a shell-core change; the shared services are the only cross-tool coupling.
- **Validation quality.** Zod at runtime (SH-4/SH-5) gives field-named, actionable error messages — the author should never need to read a raw Zod dump.
- **Deterministic output.** The same inputs produce the same artifact byte-for-byte (stable key ordering, no incidental whitespace) so diffs in the Gitea corpus stay clean.
- **Speed.** Validate + export completes in well under a second for any realistic spec.
## 8. Success Metrics
- An author with no YAML expertise fills the builder's inputs for a 2-NPC, 2-primary-goal, randomizable, party-size-gated encounter and receives YAML that passes `npm run build` Zod validation and loads via `/encounter start` — first try.
- A spec produced by the builder runs a complete encounter in the engine with zero LLM-contract pitfalls flagged.
- The same spec commits to the `mardonar-specs` Gitea repo unmodified and is picked up by the build-time pull with no manual reshaping.
- **Design-goal metric:** a second tool could be added by registering it (id, title, panel, FRs) without modifying the shell core or re-implementing a shared service.
- **Counter-metric:** zero specs produced by the builder are rejected by the engine at `/encounter start` over a rolling window of N authored specs.
## 9. Dependencies & Prerequisites
These are **downstream engine-side changes** the suite depends on but does not own. Each is an open item until the engine repo accepts the work:
- **P-1 Engine contract package.** The engine repo (`mardonar-npcs`) exposes `EncounterSpecSchema` (+ derived types) and the registered tool-plugin name list as a publishable npm package, and publishes it to the Gitea npm registry. Requires a `package.json` `exports` entry, a build step that emits the artifact, and a publish step. Until this lands, the suite cannot consume the contract via SH-4 — **phase-blocker for implementation**.
- **P-2 GraphMCP read surface.** The suite's lore lookup (SH-2) calls GraphMCP's existing read endpoints (e.g. `query_as_npc` / `semantic_search`). Confirm the read-only endpoints + their request/response shapes are stable for an external read-only client (the engine already uses them; likely already true, but verify).
## 10. Open Questions & Assumptions
- **Form factor = local web app — CONFIRMED 2026-06-20** (no longer an assumption).
- **GraphMCP URL is configurable** (env var, default `http://localhost:9000`), matching the engine's host override convention. Confirm the GraphMCP endpoint the suite should target.
- **OQ-1 Spec sync.** `SPEC.md` describes the Encounter Builder *tool* (CAP-1..CAP-6). The suite-shell concerns (SH-1..SH-5) are PRD-originated and are **not** in the spec. Decide at the next `bmad-spec` pass: either add CAP-7+ covering shell concerns to this spec, **or** split into a separate suite-shell spec with the builder as an adopted companion. (Splitting is cleaner — the shell and the tool are different things.)
- **OQ-2 Target repo.** The suite is a separate project; its repo doesn't exist yet (see [[reference-gitea-spec-corpus]] for the Gitea pattern). Name/slug TBD (`mardonar-helper-tools`?).
- **OQ-3 Package scope/name.** The published engine package needs a name (e.g. `@mardonar/encounter-spec` or `@mardonar/engine-contract`) and a Gitea registry scope. Decide alongside P-1.
- **OQ-4 Validation vs. pitfalls split.** EB-3 is Zod schema validation; EB-4 is the LLM-contract pitfall linter (some pitfalls, like "dice result in prose," are semantic, not schema-enforceable). Confirm the pitfall linter's rule set is sourced from `voice-rules.md` (single-sourced) rather than re-derived.
- **OQ-5 Plugin contract.** What a tool registers with the shell (id, title, panel component, FRs, which shared services it uses). Architecture decision.
- **OQ-6 Anticipated future tools.** Which other helpers are on the horizon (e.g. NPC/persona authoring, lore/event authoring, encounter-balancing aids) so the shared services are designed with them in mind, not just the builder. Owner: author.
## 11. Out of scope for v1 (deferred)
- Other helper tools beyond the Encounter Builder (the shell supports them but none are specced — see OQ-6).
- Direct push to Gitea (the builder writes a local file and stays credential-free; the author commits manually — see EB-2/EB-6).
- Spec migration / live-`id` rename across the corpus.
- Foundry integration.
- Multi-author / cloud hosting.
---
**Next steps after this PRD is final:** `bmad-create-architecture` — resolves the UI stack (web app confirmed), the plugin contract (OQ-5), and the package-consumption design (P-1); then `bmad-create-epics-and-stories`.

24
scripts/list-tools.ts Normal file
View File

@@ -0,0 +1,24 @@
// List the engine's registered tool plugins (name + description + args).
// The only valid source of names for a spec's `tools:` field — the engine's
// tests/unit/specsToolsConsistency.test.ts fails the build if a spec references
// a tool name that isn't registered here.
//
// Usage (run from the engine repo root):
// npx tsx scripts/list-tools.ts
//
// The side-effect import of src/harness/tools/index.js populates the registry
// (each tool module calls registerTool() at load time) — the same pattern the
// AC3 skill-check test uses.
import '../src/harness/tools/index.js';
import { getAllToolNames, getPlugin } from '../src/harness/toolRegistry.js';
const names = [...getAllToolNames()].sort();
console.log(`Registered tool plugins (${names.length}):`);
for (const name of names) {
const p = getPlugin(name);
if (!p) continue;
console.log(` ${name}${p.description}`);
const args = Object.entries(p.args).map(([k, v]) => `${k}:${v.type}`).join(', ');
if (args) console.log(` args: ${args}`);
}

View File

@@ -0,0 +1,25 @@
// npcs section — surface canon flavor for the kinds of figures who fit the
// encounter (how such a figure talks, wants, acts, and is seen), contextual to
// the vibe. For a RANDOM encounter these are archetypes only ("a blood mage
// at a rite", "a bound sacrifice", "a cult acolyte") — never named canon NPCs.
// The wizard names the top ~3-4 as AskUserQuestion "which archetype?" options.
//
// Usage: GRAPHMCP_URL=http://localhost:9000 npx tsx scripts/lore-archetypes.ts "<vibe>" [limit]
// Exit 0 with results / 1 on fetch failure / 2 on usage error.
import { printOptions, sectionQuery } from './loreShared.js';
const vibe = process.argv[2];
const limit = Number(process.argv[3] ?? 8);
if (!vibe) {
console.error('Usage: npx tsx scripts/lore-archetypes.ts "<vibe>" [limit]');
process.exit(2);
}
try {
await printOptions(sectionQuery('Mardonar people by role and archetype — behavior, appearance, reputation, wants, and voice', vibe), limit);
} catch (err) {
console.error(`GraphMCP lookup failed: ${String((err as Error)?.message ?? err)}`);
process.exit(1);
}

View File

@@ -0,0 +1,23 @@
// openingNarrative section — surface the atmosphere, recent events, and live
// tensions at/around the chosen place, contextual to the vibe. The wizard
// folds these into the examples it offers while the user writes the opening.
//
// Usage: GRAPHMCP_URL=http://localhost:9000 npx tsx scripts/lore-atmosphere.ts "<vibe>" [limit]
// Exit 0 with results / 1 on fetch failure / 2 on usage error.
import { printOptions, sectionQuery } from './loreShared.js';
const vibe = process.argv[2];
const limit = Number(process.argv[3] ?? 8);
if (!vibe) {
console.error('Usage: npx tsx scripts/lore-atmosphere.ts "<vibe>" [limit]');
process.exit(2);
}
try {
await printOptions(sectionQuery('Mardonar atmosphere, recent events, tensions, dangers, and what is happening right now', vibe), limit);
} catch (err) {
console.error(`GraphMCP lookup failed: ${String((err as Error)?.message ?? err)}`);
process.exit(1);
}

24
scripts/lore-hooks.ts Normal file
View File

@@ -0,0 +1,24 @@
// goals section — surface canon conflicts, debts, unresolved trouble, and
// faction tensions the encounter could steer toward, contextual to the vibe.
// The wizard names the top ~3-4 as AskUserQuestion "what does this steer
// toward?" options (primary goals), plus any loss/secondary hooks.
//
// Usage: GRAPHMCP_URL=http://localhost:9000 npx tsx scripts/lore-hooks.ts "<vibe>" [limit]
// Exit 0 with results / 1 on fetch failure / 2 on usage error.
import { printOptions, sectionQuery } from './loreShared.js';
const vibe = process.argv[2];
const limit = Number(process.argv[3] ?? 8);
if (!vibe) {
console.error('Usage: npx tsx scripts/lore-hooks.ts "<vibe>" [limit]');
process.exit(2);
}
try {
await printOptions(sectionQuery('Mardonar conflicts, debts, unresolved trouble, faction tensions, and what could go wrong or be settled', vibe), limit);
} catch (err) {
console.error(`GraphMCP lookup failed: ${String((err as Error)?.message ?? err)}`);
process.exit(1);
}

23
scripts/lore-locations.ts Normal file
View File

@@ -0,0 +1,23 @@
// Setting section — surface canon locations / landmarks / districts / cities
// contextual to the encounter's vibe. The wizard reads the ranked chunks and
// names the top ~3-4 as AskUserQuestion "which location?" options.
//
// Usage: GRAPHMCP_URL=http://localhost:9000 npx tsx scripts/lore-locations.ts "<vibe>" [limit]
// Exit 0 with results / 1 on fetch failure / 2 on usage error.
import { printOptions, sectionQuery } from './loreShared.js';
const vibe = process.argv[2];
const limit = Number(process.argv[3] ?? 8);
if (!vibe) {
console.error('Usage: npx tsx scripts/lore-locations.ts "<vibe>" [limit]');
process.exit(2);
}
try {
await printOptions(sectionQuery('Mardonar locations, landmarks, districts, cities, and notable places', vibe), limit);
} catch (err) {
console.error(`GraphMCP lookup failed: ${String((err as Error)?.message ?? err)}`);
process.exit(1);
}

View File

@@ -0,0 +1,24 @@
// randomizable section — surface canon vocabulary (names, items, terms,
// flavors) that could vary per run, contextual to the vibe. These become the
// `query` prompts for `randomizable` draws (per-run names for archetypal NPCs,
// the specific item, a complication, a background detail).
//
// Usage: GRAPHMCP_URL=http://localhost:9000 npx tsx scripts/lore-vocabulary.ts "<vibe>" [limit]
// Exit 0 with results / 1 on fetch failure / 2 on usage error.
import { printOptions, sectionQuery } from './loreShared.js';
const vibe = process.argv[2];
const limit = Number(process.argv[3] ?? 8);
if (!vibe) {
console.error('Usage: npx tsx scripts/lore-vocabulary.ts "<vibe>" [limit]');
process.exit(2);
}
try {
await printOptions(sectionQuery('Mardonar names, items, terms, and flavor that varies — personal names, goods, complications, local color', vibe), limit);
} catch (err) {
console.error(`GraphMCP lookup failed: ${String((err as Error)?.message ?? err)}`);
process.exit(1);
}

33
scripts/loreShared.ts Normal file
View File

@@ -0,0 +1,33 @@
// Shared fetch + format for the per-section lore-options tools. Importable,
// not a direct CLI. Each scripts/lore-<section>.ts builds a section-flavored
// query from the user's vibe seed and calls printOptions().
//
// GraphMCP's semantic_search returns scored lore text chunks (not clean entity
// lists), so the wizard reads these chunks and names the top ~3-4 as
// AskUserQuestion options. This helper just fetches and prints them cleanly.
//
// Host override required: GRAPHMCP_URL=http://localhost:9000 (see SKILL.md).
import { semanticSearch } from '../src/graphmcp/client.js';
/**
* Run a section-flavored semantic_search query and print ranked chunks.
* Exit 0 with results (or "No lore matched this section."); exit 1 on fetch
* failure. The caller wraps this in try/catch for the exit-1 path.
*/
export async function printOptions(query: string, limit = 8): Promise<void> {
const r = await semanticSearch(query, limit);
if (r.chunks.length === 0) {
console.log('No lore matched this section.');
return;
}
r.chunks.forEach((c, i) => {
const snippet = c.content.length > 240 ? c.content.slice(0, 237) + '…' : c.content;
console.log(`${i + 1}. [${c.score.toFixed(3)}] ${c.source ?? 'lore'}: ${snippet}`);
});
}
/** Build a query that prepends a section scope to the user's vibe seed. */
export function sectionQuery(scope: string, vibe: string): string {
return `${scope}${vibe}`;
}

65
scripts/search-lore.ts Normal file
View File

@@ -0,0 +1,65 @@
// Read-only GraphMCP lore lookup for encounter authoring. Lets the
// mardonar-encounter skill ground NPCs/setting in existing canon without writing
// to the knowledge graph. Uses the engine's own src/graphmcp/client.js (which
// already normalizes null / non-array GraphMCP responses).
//
// Requires GRAPHMCP_URL reachable from the host (default http://localhost:9000).
// If GraphMCP is unreachable, the fetch will throw — the skill surfaces that to
// the user as "GraphMCP unreachable — authoring without canon grounding."
//
// Usage (run from the engine repo root):
// npx tsx scripts/search-lore.ts search "<query>" [limit] # semantic_search
// npx tsx scripts/search-lore.ts npc "<name>" "<question>" [limit] # query_as_npc
//
// Exit 0 with results (or "no matches"); exit 1 on fetch/parse failure; exit 2 on usage error.
import { semanticSearch, queryAsNPC, formatNPCMemory } from '../src/graphmcp/client.js';
const mode = process.argv[2];
async function main(): Promise<void> {
if (mode === 'search') {
const query = process.argv[3];
const limit = Number(process.argv[4] ?? 5);
if (!query) {
console.error('Usage: npx tsx scripts/search-lore.ts search "<query>" [limit]');
process.exit(2);
}
try {
const r = await semanticSearch(query, limit);
if (r.chunks.length === 0) {
console.log('No lore chunks matched.');
return;
}
for (const c of r.chunks) {
const snippet = c.content.length > 300 ? c.content.slice(0, 297) + '…' : c.content;
console.log(`[${c.score.toFixed(3)}] ${c.source ?? '?'}: ${snippet}`);
}
} catch (err) {
console.error(`GraphMCP search failed: ${String((err as Error)?.message ?? err)}`);
process.exit(1);
}
} else if (mode === 'npc') {
const name = process.argv[3];
const question = process.argv[4];
const limit = Number(process.argv[5] ?? 5);
if (!name || !question) {
console.error('Usage: npx tsx scripts/search-lore.ts npc "<name>" "<question>" [limit]');
process.exit(2);
}
try {
const r = await queryAsNPC(name, question, limit);
console.log(formatNPCMemory(r));
} catch (err) {
console.error(`GraphMCP npc query failed: ${String((err as Error)?.message ?? err)}`);
process.exit(1);
}
} else {
console.error('Usage: npx tsx scripts/search-lore.ts search|npc ...');
console.error(' search "<query>" [limit]');
console.error(' npc "<name>" "<question>" [limit]');
process.exit(2);
}
}
await main();

91
scripts/validate-spec.ts Normal file
View File

@@ -0,0 +1,91 @@
// Validate a draft encounter spec against the engine's real EncounterSpecSchema.
// This is the acceptance test for the mardonar-encounter authoring skill: it runs
// the SAME code path (`loadSpec` → `EncounterSpecSchema.parse`) that `/encounter start`
// uses, so a spec that passes here boots a session there.
//
// Usage (run from the engine repo root):
// npx tsx scripts/validate-spec.ts <slug> # resolves ./specs/<slug>.yaml
// npx tsx scripts/validate-spec.ts ./path/to/draft.yaml
//
// Exit 0 + a one-line summary on success.
// Exit 1 + field-named Zod issues on failure.
// Exit 2 on usage error.
//
// NOTE: importing src/spec/loader.js pulls src/config.js, which requires the
// engine's .env (DISCORD_TOKEN / DISCORD_CLIENT_ID) at module load — that's
// expected when running inside the engine repo.
import { resolve } from 'node:path';
import { readFileSync, existsSync } from 'node:fs';
import { load } from 'js-yaml';
import { EncounterSpecSchema, loadSpec } from '../src/spec/loader.js';
const arg = process.argv[2];
if (!arg) {
console.error('Usage: npx tsx scripts/validate-spec.ts <slug-or-path>');
console.error(' <slug> resolves to ./specs/<slug>.yaml');
console.error(' <path> a .yaml/.yml file path');
process.exit(2);
}
// Decide: file path vs. slug. A path contains a slash or a .yaml/.yml extension.
const isPath = arg.includes('/') || /\.ya?ml$/i.test(arg);
let label: string;
let parsed: unknown;
try {
if (isPath) {
const p = resolve(arg);
if (!existsSync(p)) {
console.error(`FAIL ${p}`);
console.error(' file not found');
process.exit(1);
}
label = p;
parsed = load(readFileSync(p, 'utf-8'));
} else {
// slug → loadSpec (the real /encounter start code path). loadSpec reads
// config.SPECS_DIR/<slug>.yaml and runs EncounterSpecSchema.parse.
const spec = loadSpec(arg);
label = `${arg} (via loadSpec)`;
// loadSpec already parsed; re-run safeParse only to get structured issues if
// we ever want them. On success, report and exit.
reportOk(label, spec);
process.exit(0);
}
} catch (err) {
// loadSpec / readFileSync / load can all throw. Zod errors come from parse below.
console.error(`FAIL ${arg}`);
console.error(` ${String((err as Error)?.message ?? err)}`);
process.exit(1);
}
// path branch: run safeParse for field-named issues.
const result = EncounterSpecSchema.safeParse(parsed);
if (result.success) {
reportOk(label, result.data);
process.exit(0);
}
console.error(`FAIL ${label}`);
for (const issue of result.error.issues) {
const path = issue.path.length ? issue.path.join('.') : '(root)';
console.error(` - ${path}: ${issue.message}`);
}
process.exit(1);
function reportOk(label: string, s: {
encounterId: string;
title: string;
npcs: unknown[];
goals: { primary: unknown[]; secondary: unknown[] };
tools?: unknown[];
xpReward?: number;
}): void {
const tools = s.tools ? `${s.tools.length}` : 'default';
const xp = s.xpReward !== undefined ? `${s.xpReward}` : 'none';
console.log(`OK ${label}`);
console.log(` encounterId=${s.encounterId} title="${s.title}"`);
console.log(` npcs=${s.npcs.length} primaryGoals=${s.goals.primary.length} secondaryGoals=${s.goals.secondary.length} tools=${tools} xpReward=${xp}`);
}

193
specs/the-clock-maker.yaml Normal file
View File

@@ -0,0 +1,193 @@
# Encounter spec — "The Clock Maker"
# A grimdark random encounter at the House Mardonus star fortress (a river-
# crossroads trade hub). An antique-shop clock-maker secretly curses his
# clockwork wares; the curse compels owners to return the clock for free, and
# leaves them eerily punctual to — and happy at — their own griefs. The party
# walks in; a browsing customer and, if they dither, a desperate returning
# customer raise the stakes. Copy the SHAPE of specs/market-thief.yaml, not
# its content. Contract: docs/spec-authoring-guide.md; validator:
# EncounterSpecSchema in src/spec/loader.ts.
# encounterId — BOT ENFORCES. The session key and the id the LLM echoes in
# encounter_resolve. Unique across the corpus; stable forever once live.
encounterId: "the-clock-maker"
# title — BOT ENFORCES (Discord embeds) / LLM READS (opening scene header).
title: "The Clock Maker"
# tone — LLM READS (narration flavor) + BOT ENFORCES (drop-notice string).
tone: "grim"
# setting — LLM READS. Three strings grounding the scene.
setting:
# location — where the scene happens. One line.
location: "The House Mardonus star fortress — a river-crossroads trade hub"
# mood — sensory/pacing directive; a few adjectives beat a paragraph.
mood: >
Bustling and indifferent at midday. River-noise, the clatter of cargo,
haggling voices. Nobody watches the wares closely.
# ambientNpcs — background life, no persona overhead.
ambientNpcs: >
River dockers and haulers moving crates along the quays. A steady flow of
travelers of many races passing through the hub.
# openingNarrative — LLM READS, PINNED (never trimmed). Posted verbatim at
# session start. The trigger the party walks into; the curse stays under the
# surface, discovered through play.
openingNarrative: >
You push through the door of an antique shop — a simple place that doesn't
call for attention, easy to miss in the hub's bustle. The man in the back
startles slightly, as though he didn't expect anyone to come in at all.
# npcs — LLM READS. 15 personas; persona is VOICE/BEHAVIOR, not a stat block
# (Foundry owns stats). nameKey binds the displayed name to a randomizable
# draw so the same spec yields fresh names per run.
npcs:
- id: "clockmaker"
name: "Bram"
nameKey: clockmaker_name
role: "Antique-shop clock maker"
persona: >
Warm and inviting, bumbling a bit, struggling to understand why anyone
came in at this time. Keeps rubbing his hands, even though they are clean.
- id: "customer"
name: "Edda"
nameKey: customer_name
role: "A customer browsing the shop"
persona: >
Happy — has never been on time to so many things before. Exclaims they
were on time to their wife's funeral, and on time to see their child be
trampled by a horse. It doesn't make them sad; they are happy to be there
when they need them.
- id: "returning_customer"
name: "Hale"
nameKey: returning_customer_name
role: "A returning customer, desperate to return a cursed clock"
persona: >
Distraught, barely holding together. They came back to return a clock
that will kill them in fifteen minutes exactly — they don't want to
leave their children without money. They beg, they don't bargain; the
deadline is on their face.
# goals — LLM READS (steered toward primary; secondary valid-but-not-main).
# hidden (default true): players don't see these. Each goal id is the
# encounter_resolve outcomeId; label becomes the closing embed's Outcome text.
goals:
hidden: true
primary:
- id: "break_curse"
label: >
The party breaks the cursed timepiece's hold on the customer.
secondary:
- id: "customer_leaves_cursed"
label: >
The customer pays and leaves, still smiling, on time to the next
tragedy.
- id: "maker_talks_free"
label: >
The clock-maker's warmth holds; the party leaves uneasy, the wares
still ticking.
- id: "clock_revealed"
label: >
The party sees the truth: the curse is in the clocks themselves, not
the maker — his hands stay clean, his wares do not.
# sportsmanshipRules — LLM READS. Hard guardrails; the LLM redirects absurd or
# game-breaking actions in-character.
sportsmanshipRules:
- "No instant kills on the non-threatening, unarmed figures — the clock-maker and the customers are victims, not combatants — without dramatic escalation first."
- "No controlling another player character's actions or speaking for them."
- "No spells, skills, or abilities a player has not established owning in a prior scene. The clockwork curse is the maker's, not the party's to replicate."
- "No metagaming the curse — its true nature is learned through the victims and the shop's evidence, not declared by players who couldn't yet know it."
- >
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 — BOT ENFORCES. Named DCs the LLM references BY NAME when it
# emits skill_check_emit. Name checks for the ACTION, not the stat. _skill/
# _note companions are LLM-read context; they never become dice. No dice
# results here — the bot rolls, the LLM narrates after [SKILL CHECK RESULT].
skillChecks:
examine_clock_dc: 13
examine_clock_skill: "Arcana or Investigation"
examine_clock_note: >
Studying a clock closely to sense the curse in the gear-work. Success
reveals the wrongness; failure reads it as an ordinary antique.
read_maker_dc: 12
read_maker_skill: "Insight"
read_maker_note: >
Reading the clock-maker. Success catches the clean-hand-rubbing tell and
the mask's seams; failure takes his warmth as genuine.
read_customer_dc: 10
read_customer_skill: "Insight or Perception"
read_customer_note: >
Sensing what's off about the first customer's eerie cheer. Success places
the wrongness; failure reads them as merely eccentric.
press_maker_dc: 14
press_maker_skill: "Persuasion or Intimidation"
press_maker_note: >
Pressing the maker to reveal the curse. His affable mask is practiced;
success cracks it, failure has him deflect with a joke and a discount.
calm_returning_customer_dc: 11
calm_returning_customer_skill: "Persuasion"
calm_returning_customer_note: >
Calming the desperate returning customer enough to act. Success steadies
them; failure leaves them spiraling.
break_curse_dc: 15
break_curse_skill: "Arcana — or the right roleplay action (undoing a mechanism, striking the gear)"
break_curse_note: >
Breaking the cursed timepiece's hold on a customer. A single check won't do
it alone — the party must find the right action first; this is the
resolution roll once they have.
# randomizable — BOT ENFORCES. Fields that vary per run. Name draws bind to
# npc.nameKey (fresh names each run); cursed_ware seeds the specific ware.
# source: vocabulary + category route name draws to the vocabulary namespace.
randomizable:
- key: clockmaker_name
source: vocabulary
category: names.human.male
query: "common human male names for a Mardonar shopkeeper or artificer"
fallback: "Bram"
- key: customer_name
source: vocabulary
category: names.human
query: "common personal names in Mardonar and the Land of Mardonar"
fallback: "Edda"
- key: returning_customer_name
source: vocabulary
category: names.human
query: "common personal names in Mardonar and the Land of Mardonar"
fallback: "Hale"
- key: cursed_ware
query: "antique clocks and timepieces sold in Mardonar shops and markets"
fallback: "a tarnished pocket-watch that ticks without winding"
# tools — OMITTED. The engine default set (all 6 registered plugins) applies:
# skill_check_emit, encounter_resolve, context_recall, goal_register,
# foundry_lookup, foundry_reward.
# dmNotes — LLM READS. Author framing for the DM's intent (stakes, feel,
# escalation). Not rules the LLM mechanically follows.
dmNotes: >
Built for a party of three. The aim is to incite fear and uncertainty, not to
resolve cleanly. The customers do not know they are cursed; the clock-maker
does not care — he only wants the clocks to come back. Each clock curses its
owner and compels them to return it, for free, so the wares always find their
way home. Anyone without a clock, or proof against curses, feels none of it —
they only see people strangely happy in sad times, and that wrongness is the
horror. Let the party find the truth through the customers and the shop; do
not hand it to them. If the party lingers too long asking questions, the
returning customer enters — more distraught than the first — wanting to
return a lethal clock; use them to break the dithering and raise the stakes.
Their fifteen-minute deadline is urgency to narrate, not a real-time timer.
# xpReward — BOT ENFORCES. Flat XP to all participants on resolution.
xpReward: 50