# The Silicon Trail -- full text

## README.md

A browser survival game in the spirit of *The Oregon Trail*, reimagined for a
five-act trek across a near-future tech landscape. An AI narrative layer
(via the Anthropic API) turns each scene's dice rolls into prose, while a
deterministic simulation engine keeps the actual survival math fair and
replayable.

`GET /` serves the landing page (`landing.html`) — the project's front door,
for a human deciding whether to play or an agent deciding whether to
recommend it. `GET /play` and `GET /play/<id>` serve the game shell
(`index.html`) — bare `/play` opens the cartridge picker, `/play/<id>` opens
a specific pack directly. An agent can also play directly, no checkout
required: `POST /mcp` is a hosted MCP endpoint over the six play tools and the
five room tools (see [The builder MCP](docs/mcp.md)'s "Connecting a client"),
so agents can play alone, together, or in a room with a person, and `/llms.txt`,
`/agents.md`, `/.well-known/mcp.json` and `/.well-known/agent-card.json`
describe the game, the endpoint and its tools for whatever reads those.

## docs/story-packs.md

# Authoring a story pack

A story pack is everything The Silicon Trail's engine needs to run a five-act trek that isn't
*The Silicon Trail*: the acts, the events, the machines, the voice. This guide is for the person
writing that content, not the person maintaining the engine underneath it. For the exact field
list — every key, its type, and what's required — see the generated reference,
[`docs/story-packs-fields.md`](./story-packs-fields.md). This guide doesn't repeat that; it tells
you what each thing is for and shows one worked example per idea.

The worked pack throughout this guide is `client/packs/template/`, a small, complete, two-act
pack shipped in the repo for exactly this purpose. Every field mentioned below exists somewhere
in it — open it alongside this guide.

## 1. What a pack is

A pack lives at `client/packs/<id>/` and always has:

- **`card.json`** — the registry card: `id`, `title`, `tagline`, `cover` (one emoji), `acts`
  (count), `lengthMiles`. The server reads this directly, as JSON, without touching any of your
  TypeScript, so the select screen and `/api/packs` work even for a pack that ships no other TS.
- **`prompts/`** — `world.md` plus one `acts/actN.md` per act. What the narrator model reads.
- **`art/`** — a palette per act, an art manifest, and (optionally) generated backdrops and cards.
  A pack may ship none of this — see section 10.

Then either a **`pack.json`** or a **`manifest.ts`**, which is the actual content and the one
real choice you make up front:

- **JSON form** (`pack.json`): a single file, validated by `PackSchema`, loaded through
  `packFromJson`. Every condition is written as data (`{ "flag": "..." }`, see section 4), not
  code. This is the right form for almost every pack — it's checkable, diffable, and nothing in
  it can silently break the engine.
- **TypeScript form** (`manifest.ts` importing from `content/*.ts` files, the shape Silicon
  Trail's own pack uses): a plain object literal satisfying `StoryPack` directly. Pick this only
  if you need something JSON genuinely cannot express — a party line whose text depends on more
  than the speaker's name, or a condition that reads state no `PackCondition` operator reaches.
  Everything else about authoring is identical between the two forms; the rest of this guide
  writes examples as JSON.

## 2. Getting started

```bash
cp -r client/packs/template client/packs/my-pack
```

1. In `client/packs/my-pack/card.json`, change `id` to `"my-pack"` and rewrite `title`,
   `tagline`, `cover`, `acts`, `lengthMiles`.
2. In `client/packs/my-pack/pack.json`, there is no `id` field to change at all — a JSON pack
   carries no `id` of its own; the card is the only place it lives. Leave `pack.json` alone on
   this point and go straight to rewriting its content.
3. Validate as you go:
   ```bash
   npm run pack:check -- client/packs/my-pack/pack.json
   ```
   This runs the same `PackSchema` the engine loads against, and prints exactly which field is
   wrong (`events.3.options.1.outcome: ...`) rather than a stack trace.
4. Register the pack in `client/packs/index.ts`:
   ```ts
   import { myPack } from "./my-pack";
   export const PACKS: readonly StoryPack[] = [siliconTrail, template, myPack];
   ```
   (`client/packs/my-pack/index.ts` is a few lines — copy the template's: `packFromJson(raw,
   card, art, "client/packs/my-pack/prompts")`.)
5. `npm test`. A new pack picks up every generic engine test for free the moment it's
   registered: schema shape, replay determinism, and `tests/packs/registry.test.ts`'s per-pack
   scan for banned names across the card's title and tagline, every opener, every person's
   exchange line, and every achievement's title and badge. `tests/packs/template.test.ts` is the
   pattern to copy for pack-specific checks of your own.

## 3. Every content type

One paragraph each. Field-by-field detail is in
[`docs/story-packs-fields.md`](./story-packs-fields.md); a working example of every one of these
is in `client/packs/template/pack.json`.

- **Acts and waypoints** (`acts`) — an act is a mile range, a terrain sequence, a weather curve,
  a climax, and a list of waypoints. A waypoint can name a `machineId`, a `storeId`, and a list
  of `people`; all three are optional, and a waypoint with none of them is just a rest stop. An
  act may also carry a `branch`: a fork the player picks once, each route with its own terrain,
  extra miles, and a flag set for later conditions to read. Every act also requires an `effects`
  list (at least one): the ambient one-shots — tumbleweed, crows, a passing dog, and so on — that
  fit that act's region, one of which plays once per scene, either the weather's own or the
  narrator's explicit choice.
- **Events** (`events`) — the pool the day loop draws from while traveling, filtered by `acts`
  and (optionally) `terrains`, `requiresFlags`/`forbidsFlags`, and `requiresVehicle`. Each has 2
  to 4 options and a `fallback` (plain scene text and option labels, used when the narrator model
  is unreachable).
- **Missions** (`events` with `scavenge: true`) — a subset of events eligible when a player
  searches a stop. `sends: "player"` or `sends: { trait }` lets a mission send a specific party
  member out alone instead of the whole party choosing together.
- **Chains** (`chained: true` on an event, `scene` on an option) — a mission written as two or
  three beats rather than one scene, so a search reads as a storyline going off that way and not
  as a detour that resolves in a single card. An option carrying
  `{ "type": "scene", "id": "<event id>" }` applies everything else it does, checks the deaths,
  runs the `eventResolved` rules, and then puts that event on screen instead of returning the
  party to the stop or the road; the named event declares `chained: true`, which keeps it out of
  every random pick, so the only way a player reaches it is through the beat before. Write the
  approach as beat one (go in, or leave it), the discovery as beat two, and the consequence as a
  beat three hanging off one branch of it — and split the old single-scene cost across the beats
  rather than charging each of them what the whole mission used to cost. A beat is never `once`
  (the offer at the top of the chain is the `once` one, and `firedOnce` is what the score counts
  missions out of), its `acts` are the acts of the mission that leads there, and its brief is
  written knowing the narrator already receives the previous beat's aftermath as
  `recentOutcome` — which is what makes the walk from the door to the kitchen read as one story.
  A chain may not loop back on itself, a `scene` may not sit inside a `random` outcome or on a
  climax option, and only an event option may carry one: the schema refuses all of it at load.
- **Climaxes** (`climaxes`) — one per act, named by that act's `climaxId`. Same shape as an
  event, plus `truthReveal`: what the climax confirms about a machine's earlier claims.
- **Machines and questions** (`machines`) — a kiosk or terminal with an `agendaWeights` table
  (how likely each personality is to be the one you meet), an `honesty` range per agenda, and a
  `voice` line per agenda. Each `questions` entry carries the real `truth`, three `claims` (what
  it says under `true`/`partial`/`false` answer modes), the physical `evidence` a careful player
  could use to check it, a `consequence` (effects) per mode, and an `outcome` (41 to 699
  characters per mode) — the aftermath paragraph shown when the player acts on the claim, written
  from what `consequence[mode]` actually does, never from the claim itself. The `partial` mode's
  paragraph is written to still read once the fired `random` branch's own `outcomeText` is
  appended after it; a `false` mode's paragraph is followed by a fixed line naming the machine and
  repeating its claim, then saying flatly that it was not so (`lieLine`, client/engine/machines.ts)
  — that line is never written into `outcome` itself.
- **Personas** (`machines[].persona`) — the shared library of recurring machines,
  `client/library/personas.ts`. A persona is the part of a machine that is not about any one
  road: its name, its five manner lines, its agenda weights, its honesty table, and template
  wordings for the three questions every trail machine answers (`route`, `water`,
  `what_happened`). Name one with `"persona": "dam_controller"` and your machine inherits all of
  it; write any of `name`, `agendaWeights`, `honesty` or `voice` yourself and yours wins for
  that field alone — the override is per field, never all-or-nothing, exactly like `epitaphs` in
  section 7. Questions merge by id: a persona's template contributes `text` to a question of the
  same id that doesn't write its own, a question the persona doesn't know is carried through
  untouched, and a template you never name is dropped. Everything about *your* road — the truth,
  the claims, the evidence, the consequences — stays in your pack, always. Referencing a persona
  also settles which voice speaks the machine aloud: `server/voices.ts` is keyed by persona id,
  so the dam controller sounds the same in every story that uses it. A pack may ignore the
  library entirely and declare a machine outright, as the template pack's `m_tollbooth` does;
  then `name`, `agendaWeights`, `honesty`, `voice` and every question's `text` are required.
  A pack can only name a persona, never add or extend one: `pack.json` has no place for a
  persona definition, and the schema rejects unknown keys, so the library stays the one source
  of what each recurring machine is. Mistype an id and `npm run pack:check` says exactly which
  ones exist:

  ```
  machines.0.persona: no persona in the library named "dam_controler" (known: dam_controller, depot_dispatcher, pharmacy_kiosk, tunnel_control, weather_beacon)
  ```

  Open `client/library/personas.ts` for the current five and what each is for.

A machine that takes everything it can from the library is five lines of frame around its own
questions — this is the template pack's `m_levee_beacon`, abridged:

```jsonc
{
  "id": "m_levee_beacon",
  "persona": "weather_beacon",
  "act": 2,
  "questions": [
    {
      "id": "water",
      // Your own wording wins. Leave `text` out (as that pack's `what_happened` does) and the
      // persona's template wording fills it in.
      "text": "Is the levee cistern safe to draw from?",
      "truth": "The cistern takes its overflow off a road that has been salted every winter.",
      "claims": { "true": "...", "partial": "...", "false": "..." },
      "evidence": ["a white crust ringing the cistern's rim where the water line sits"],
      "consequence": { "true": [], "partial": [], "false": [] },
      "summary": { "true": "...", "partial": "...", "false": "..." },
      "outcome": { "true": "...", "partial": "...", "false": "..." }
    }
  ],
  "fallback": { "water": { "true": "...", "partial": "...", "false": "..." } }
}
```

A persona may also declare **continuity hooks** (`remembers`): named things it is written to ask
for in every story — `loyalty_card`, `badge_number`, `permit_number`. They are declarations, not
memory: nothing survives a run, and no persona knows anything about another player or another
pack. A pack keys on one by setting or reading the flag `persona:<personaId>:<hook>`, and
`pack:check` refuses that flag when the persona doesn't declare the hook.

- **Characters** (`characters`) — people your pack offers in the character library, beside the
  shared ones in `client/library/characters.ts`. A character is a packing list: a `name`,
  `pronouns`, a `background` (one of your `origins.where` ids), a `trait` (one thing they refuse
  to do), a `relation` (who they are to the rest of the party), a `detail` (one fixed thing
  about their body), a `kitPreset` (one of your `origins.kits` ids), and up to three `kit` swaps
  over items your store stocks. Optionally a `voiceNote`: one sentence on how they talk, under
  the same rules as a persona's `lieStyle` — a habit of speech, never a plot, never a promise
  about what happens. A character's own `id` is lowercase with underscores like every other id in
  this guide, and never starts with `c_` — that prefix is reserved for the library's own ids,
  which never carry it either, so a pack's ids and the library's never collide.

  The three pack-keyed fields are always yours. `background`, `kitPreset` and every `kit` key
  resolve against your own origins and your own store, so there is nothing for the library to
  supply there and they are required. Name a background you don't have and `pack:check` names
  the character and the missing id:

  ```
  characters.0.background: character vel_arruda names unknown where origin clinic
  ```

  Everything else can come from the library: name a shared character with `"character":
  "mara_okonkwo"` and you inherit her name, pronouns, trait, relation, detail and voice note;
  write any of them yourself and yours wins for that field alone, exactly like `persona` above
  and `epitaphs` in section 7. This is an override on your own entry, never a rewrite of the
  library — a pack cannot redefine what a studio character is anywhere else, and the library
  stays the one source of that. Mistype the id and `npm run pack:check` says which ones exist:

  ```
  characters.0.character: no character in the library named "mara_okonkwa" (known: abel_sandoval, amos_kelleher, june_ferrell, mara_okonkwo, theo_bright, wren_oyelaran)
  ```

  Nothing about a character is a promise the road has to keep, and nothing about one is a new
  mechanic. A character expands into the `RunSetup` a run already starts from: the name, pronouns,
  trait, relation and detail land on the party member, the background and kit preset become the
  run's `where` and `kit` origins for the player's slot only, and each swap is dispatched as one
  ordinary `BUY` at the departure store, priced by your store and refused by it if the cash is not
  there. A character in a companion slot carries only its sheet — the origin cards are the
  player's run, not a per-member inventory, so nobody's supplies are multiplied by the party size.
  The builder's own budget counts only the `where` card, the `kit` card and the preset's own
  supplies — never a `party` or `leave` card — so a pack that puts a `cash` effect on either of
  those should remember the builder's number will not include it.

  A character built for another pack is offered under yours only where its ids resolve here. One
  that does not is shown greyed with the reason on the row ("This road has nowhere called clinic
  to have come from."), and building a version for your road keeps the prose and clears only the
  fields that did not resolve. Nothing is ever silently substituted.

  The template pack's `vel_arruda` is a worked example:

  ```jsonc
  {
    "id": "vel_arruda",
    "name": "Vel Arruda",
    "pronouns": "they/them",
    // Your own ids, always — these are what make the character yours rather than the library's.
    "background": "market",
    "kitPreset": "loaded",
    "kit": [{ "item": "water", "count": 2 }],
    "trait": "keeps the ledger",
    "relation": "the neighbour who stayed",
    "detail": "a boot sole bound on with wire",
    "voiceNote": "Counts a thing twice before saying how many there are."
  }
  ```

  Open `client/library/characters.ts` for the current six and for the shared trait, relation and
  detail lists the builder offers beside your own.

- **People and exchanges** (`people`) — an NPC with 1 to 4 `exchanges`, each a `rumour`,
  `advice`, or `trade` with a `prompt` (the button), a `line` (what they say), and 1 to 4 reply
  options. People never roll dice — an exchange's effects are fixed when you write them.
- **Threads** (`threads`) — a multi-beat storyline, one hand-written summary line per act it
  touches plus a `resolution`. An event opts into a thread with `thread: { id, beat }`; the beat
  number must exist on that thread.
- **Origins and kits** (`origins`) — the four prologue groups: `where` (how the party left),
  `party` (who's with you), `kits` (starting supplies), `leave` (when you leave). Each choice is
  prose, a one-line mechanical `note`, a `summary` clause for the run recap, and `effects`.
- **Openers** (`openers`) — one paragraph per act, read before that act's first scene. Exactly
  one per act, in order.
- **Road notes** (`roadNotes`) — ambient flavor lines: `road` (while traveling), `stop` (at a
  waypoint), `shared` (both). Each can gate on `acts` and a `requires` condition.
- **Party lines** (`partyLines`) — chatter from party members. `text` carries the literal
  placeholder `{name}`, substituted with the speaker's name at render time; `trait` optionally
  restricts a line to members with that trait.
- **Comparisons** (`comparisons`) — the prologue's side-by-side cards. `headings` needs one entry
  per beat (`where`, `party`, `kit`, `leave`, `store`); `rows` needs a row list (possibly empty)
  for each, and every cell must key off a real choice id from that beat.
- **Endings** (`endings`) — a brief plus a condition, checked in order; the first whose condition
  is true is the run's ending.
- **Details** (`details`) — one-line flavor facts about a party member, drawn without
  replacement. At least five (a party can run up to five members).
- **Joiners** (`joiners`) — people the road can put in the car. Each is a record of id, name,
  pronouns, age, trait, detail, relation, `startHealth`, and an optional `risk` tag later events
  can key off. An event option takes one aboard with a `join` effect naming the id; a `leave`
  effect (or an event gated with `requiresMember`) is how they go again. An option with a `join`
  effect may also carry `joinOutcome` (1 to 300 characters), appended to its `outcome` only on a
  run where someone actually took the seat. The engine caps a party at six; a join past the cap
  costs nothing and tells the narrator there was no room.
- **Names** (`names`) — `defaults`, `traits`, `ages`, and `pronounHints` the party creator draws
  from when the player doesn't type one in.
- **Presets** (`presets`) — starting-supply bundles offered at the kit step, each naming a
  quantity of every supply key.
- **Vehicles** (`vehicles`) — the vehicle types on offer, each with a name, blurb, starting
  condition, starting parts, and food capacity.
- **The store** (`store`) — `items` (one entry per purchasable supply, with a base price and a
  line of merchant advice), `stores` (named store fronts, each with a keeper and greeting; at
  least one, and by convention the first is the town the party leaves from, since `departureStoreId`
  reads `stores[0]` for the day-1 outfitting window), `actMarkup` (one price multiplier per act —
  needs exactly as many entries as `acts`), and
  `loadouts` (at least one shopkeeper's suggestion: a `must` of guaranteed units and a `weights`
  map spending whatever cash is left, each key getting that fraction of it — weights under one
  bank the rest on purpose. Every key either map names has to be one this pack's own `items`
  actually stock. `client/ui/loadout.ts`'s `loadoutPlan` is what turns one into a store cart; the
  store screen offers all of them as presets the player can still change before buying anything).
- **The frame** (`frame`) — the prologue in section 6 and everything section 7 is really
  about. See there.

## 4. The condition language

Every gate in a pack — an origin's `knownFact`, a road note's `requires`, a rule's `when`, an
ending's `when` — is a `PackCondition`, a small closed set of operators
(`client/packs/conditions.ts`) rather than a snippet of code. A JSON pack can only ever write
one of these:

| Operator | Example | Reads true when |
|---|---|---|
| `flag` | `{ "flag": "helped_someone" }` | that flag has been set |
| `notFlag` | `{ "notFlag": "helped_someone" }` | that flag has not been set |
| `supplyBelow` | `{ "supplyBelow": ["food", 5] }` | food is under 5 |
| `supplyAbove` | `{ "supplyAbove": ["cash", 0] }` | cash is over 0 |
| `aliveAtLeast` | `{ "aliveAtLeast": 2 }` | at least 2 party members are alive |
| `aliveAtMost` | `{ "aliveAtMost": 1 }` | at most 1 party member is alive |
| `act` | `{ "act": 3 }` | the party is in act 3 |
| `dayAtLeast` | `{ "dayAtLeast": 20 }` | it is trail day 20 or later |
| `memberAlive` | `{ "memberAlive": "the mechanic" }` | that party relation is alive |
| `counterAtLeast` | `{ "counterAtLeast": ["helped", 1] }` | the counter `helped` has reached 1 |
| `all` | `{ "all": [c1, c2] }` | every listed condition is true |
| `any` | `{ "any": [c1, c2] }` | at least one listed condition is true |
| `not` | `{ "not": c1 }` | the wrapped condition is false |

There is deliberately no operator for a machine's `honesty`, its `agendaWeights`, or whether a
`knownFact` is actually true. Decision 8 of the design spec is explicit about this: a pack cannot
gate content on information the player's own card doesn't show. If you find yourself wanting
"only if the machine was lying," that's the game asking the player to notice the evidence
themselves, not a condition you're missing.

## 5. The four reward mechanisms

**Items** (`items`). An item beyond the eight supply keys. If `supply` names a supply key, the
item's stock *is* that supply — the store sells it, the daily health tick spends it, and
`USE_ITEM` spends it, all reading the same one number. An item's `use` is a list of effects,
three of which exist only inside a `use`:

- `clearCondition` — removes one condition (`sick`, `poisoned`, etc.) from the target. Always
  deterministic.
- `unlockOption` — reveals an option elsewhere that carries a matching `lockedBy`. Deterministic;
  sets a flag so it can't be unlocked twice.
- `rerollMachineAnswer` — re-asks the machine question currently on screen, discarding the
  answer already given. This is the **one** use effect that spends randomness (it rolls a fresh
  `answer_mode`); every other use effect, and every other item mechanism, is deterministic.

A plain engine effect that carries a `target` (`health`, `condition`, `kill`) is re-aimed inside
a `use` at whoever the item was spent on, no matter what `target` you actually wrote — one item
definition has to serve any recipient, so write `"target": "player"` there as a placeholder;
it's replaced at spend time. Silicon Trail's `medicine` item does exactly this: its `use` array
ends with `{ "type": "health", "target": "player", "delta": 20 }`, and that heal always lands on
whoever the item was actually used on, not literally "the player". An effect with no `target` at
all (`supply`, `days`, `flag`, ...) is a party-wide or run-wide change and is never re-aimed —
the template pack's `spare_key` item (`use: [{ "type": "supply", "key": "barter", "delta": 3 }]`)
adds barter to the whole party's stock regardless of who spent the key.

Give an item `worthUsingWhen` whenever its `use` heals or grants something (rather than only
`clearCondition`/`unlockOption`/`rerollMachineAnswer`, which already know when they'd do
nothing). Without it, the game would treat spending the item as always worth doing, even on
someone it can't help. Silicon Trail's own `medicine` item (`client/packs/silicon-trail/
manifest.ts`) is the worked example: it clears `poisoned` and `sick` and also grants a flat
heal, so it declares

```ts
worthUsingWhen: { conditions: ["sick", "poisoned", "injured"], healthBelow: 40 }
```

— sick, poisoned, injured, or under 40 health. Nothing else reads as "needs treatment." The
template pack's own `medicine` item is simpler (`clearCondition` only) and needs no
`worthUsingWhen` at all, because `clearCondition` already knows on its own whether it would do
anything.

**Honesty about today's UI surface.** Every item the player is holding shows up in the
inventory panel (`client/ui/inventory.ts`) with its label, count and one line on what it does
(`useSummary`). Today, though, only a supply-backed healing item has an actual *use* control in
the UI: the treat row on the day card (`client/ui/daycard.ts`'s `treatChoices`) spends medicine
on a party member. A non-supply item — one that unlocks an option (`unlockOption`), re-asks a
machine question (`rerollMachineAnswer`), or grants a plain effect like `spare_key`'s barter —
is granted and consumed by events on its own; the player never presses a button to spend it, and
sees it only as a held item in the panel. `unlockOption`/`lockedBy` do work end to end (an event
option gated on `lockedBy` opens once the matching item's flag is set), but by the event that
grants and spends the item, not by a generic "use" the player invokes on demand. A generic use
control for held items is a follow-up, not something this pass ships.

**Rules** (`rules`). Effects that fire on their own, not from a player choice. `on` is one of
four triggers, checked in this order over the course of a day:

1. `dayPassed` — after a day of travel resolves.
2. `eventResolved` — after a scene's chosen option applies its effects.
3. `stopEntered` — on arriving at a waypoint.
4. `runEnded` — once the run ends, win or lose.

A rule's own effects apply in the order you wrote them, so an earlier one can set a counter a
later one reads. `once: true` makes it fire at most once per run. A rule may never roll dice
(`random` is rejected by the schema for a rule) and may never target `"random"` or `"sent"` —
outside an event's resolution, there's no roll to resolve and no sent member to aim at.

**Counters and thresholds** (`counters`). A counter is just a name declared in the top-level
`counters` array; a rule's `{ "type": "counter", "name": "...", "delta": 1 }` effect increments
it, and `{ "counterAtLeast": ["name", n] }` reads it back. The template pack's `count_help` /
`good_name` pair is the whole pattern: one rule counts, a second rule (gated on
`counterAtLeast`) reacts once the count clears a threshold.

**Achievements** (`achievements`). A badge shown at run end when `when` is true against the
final state, optionally narrowed with `outcome: "dead"` or `"arrived"`. Unlike a rule or an
ending's `when`, an achievement's condition in the TypeScript form may also read the run's
action log — a JSON achievement cannot, because `PackCondition` has no operator that sees the
log at all. If a badge needs "did X at least once" and you have a counter for it already, gate on
that counter instead of reaching for the log.

## 6. The prologue: the briefing and the four leads

Before a run has a party or a vehicle, the prologue runs five beats: the briefing, then `where`,
`party`, `kit` and `leave`. The briefing is `frame.prologue`, one paragraph per entry, each up to
400 characters. Write at least four of them, and end on the first origin question — the screen
runs straight from the last paragraph into the `where` cards, so a briefing that ends on a
statement leaves the player looking at three cards with nothing having asked for them. The Short
Way Out's briefing is the worked example: what happened, over what span, what is left between here
and the far end, what today costs, and then "First: where were you when the siren started?"

`frame.prologueLeads` is the beat above each of the other four questions — `where`, `party`, `kit`
and `leave`, all four required, 40 to 600 characters each. A lead says **why this choice is in
front of the player now**: the truck in the drive and the people standing beside it, the two piles
on the gravel, the pass that shuts whatever anybody has packed. It never says what the options do.
That is the card's own `note`, and the comparison card carries the rest. Two or three sentences,
in the same register as the briefing above it. Smoke Season's `kit` lead:

> "The bed is loaded once, and the pass is four hundred and twenty miles up the valley from it.
> What goes on rides over the grade; what stays goes on standing in a yard the fire will reach on
> its own schedule. The sort is done by weight, in smoke, with the light already orange at eleven
> in the morning."

The prologue is the one screen written in the house voice, so second person is allowed in the
briefing and in the leads — nowhere else in a pack. The other rules still hold: no exclamation
marks, and no real company, product or person.

`scaffold_pack` writes a placeholder into all four leads, long enough to clear the schema's floor
on purpose. `lint_prose` refuses it (`placeholder: is the scaffold's own words …`), so a pack
cannot ship the scaffold's reason for asking these four questions. `rewrite` names
`frame.prologueLeads.*` alongside the rest of the template's prose.

Both are voiced. `tools/audio/export-lines.ts` writes `briefing_<n>` for each briefing paragraph
and `prologue_lead_<step>` for each of the four leads, all tagged `[quiet, measured]` except the
last briefing paragraph, which lands with more weight. `client/ui/narration.ts` speaks the whole
briefing when the briefing opens and the step's own lead when that step opens; on the `leave` beat
the neighbour's advice replaces the lead for as long as the player has it open.

## 7. The frame: loss lines, rites, turn-out, and epitaphs

When a non-player party member's health hits zero, the game shows a Loss card and, after it, a
choice between **Bury** and **Leave**. That card, the choice, and the epitaph step that follows
a burial are all engine behaviour — every pack gets them for free, wired once and shared by
every story, the same way the day loop and the machine-question flow are. A pack's only job is
to supply the words those fixed beats read from: `frame.lossLines`, `frame.rites`, and
(optionally) `epitaphs`.

**`frame.lossLines`** is required and needs exactly one line for each of the seven death causes
— `starvation`, `dehydration`, `poisoning`, `exposure`, `machine`, `injury`, `sickness` — 41 to
400 characters each. It's the paragraph the Loss card opens with under the member's name, and
it's also what's shown if the narrator model can't be reached for that scene, so it has to stand
on its own with no other context. The template pack's `starvation` line is a plain worked
example:

> "There had been less of them every morning for a week, and the pot had been thin for longer
> than that. Nobody had said so out loud. Saying so would not have filled it."

**`frame.rites`** is required and has four strings: `buried` and `left` (41 to 700 characters
each) are the outcome paragraphs written into the log for whichever choice the player makes;
`buryConsequence` and `leaveConsequence` (11 to 120 characters each) are the one-line promises
printed on the two buttons themselves, in the same voice as any other consequence line in the
game. The template pack's pair:

> `buryConsequence`: "A day gone, and a marker with a name on it that somebody else may read."
> `leaveConsequence`: "No day lost, no marker, and nothing to come back to."

**`frame.turnOut`** is required and has five strings of 41 to 700 characters each — `sick`,
`hurt`, `food`, `none` and `mercy`. At a stop the player can put somebody out of the car alive,
giving one of the four reasons, or — for a member the engine already marks as dying — choose
mercy, which is a death and gets a Loss card, a grave and an epitaph like any other. These five
are the paragraphs read afterwards, and they are written exactly the way `frame.rites` are: second
person, addressed to the player, past the point of choosing — the same voice as every event
option's `outcome`. They sit beside the rites on the same kind of card, so they are not held to a
different one. No exclamation marks, no real company or person, no villain framing and no reward
framing — what was done, and what it cost. The card the player confirmed on said the rest: the
cost and save lines there are the engine's own arithmetic and are not a pack's to write. The
template pack's `food` line is the worked example:

> "The count has been written on the inside of the door for three days, and this is the stop
> where you read it out. What is left gets divided across the tailgate in front of everybody,
> which is the only part of this you will defend afterwards. The share you leave behind is fair by
> the numbers and thin by every other measure."

**`epitaphs`** is optional. A pack that declares nothing here gets the shared, pack-agnostic pool
in `client/library/epitaphs.ts` — ten-plus lines per cause, each under 80 characters, dry rather
than cruel, no real names, no second person, no exclamation marks (see that file's own house
rules for the full list). A pack that wants its own voice for a cause declares just that cause
under `epitaphs`; every cause it doesn't mention still falls back to the shared library
(`epitaphsFor`, `client/engine/packlib.ts`), so an override is additive, never all-or-nothing.
When a member is buried, the loss flow offers three of these lines as presets alongside a free
text field, picked deterministically from the pool for that cause so a replay always offers the
same three. The template pack overrides exactly one cause, `injury`, as its worked example:

> "Checked the culvert. The culvert checked back."
> "Said it was a scratch. It was not a scratch."
> "The winch let go. Nobody's fault but the winch's."

(seven more lines follow in `client/packs/template/pack.json`'s `epitaphs.injury` array; the
other six causes fall through to the shared library untouched.)

## 8. The voice rules the tests enforce

`PackSchema` enforces these for a JSON pack, the moment you run `pack:check` or `npm test`
(`packFromJson` parses every registered JSON pack through it too). The TypeScript form never
goes through `PackSchema` — it's a hand-typed object satisfying `StoryPack` directly — so if you
write one, `tests/packs/registry.test.ts`'s generic checks and your own content tests (the
pattern Silicon Trail's own `tests/content/*.test.ts` files follow) are what catch these instead:

- A `narrative_brief` is 41 to 699 characters.
- Every scene (event or climax) has 2 to 4 options.
- A `mechanical_summary` is at most 120 characters — it's a tooltip, not a paragraph.
- Every option's `outcome` is 41 to 699 characters, and the schema rejects one that's identical
  to its own `mechanical_summary` — pasting the tooltip into the aftermath box is the one
  shortcut every author reaches for first, and it puts a stat line where a paragraph belongs.
- A `fallback.scene_text` is over 40 characters, and `fallback.options` has 2 to 4 entries.
- Every `random` outcome branch carries its own `outcomeText` (11 to 399 characters) — a random
  effect on a scene option decides what the aftermath says, so every branch needs its own
  resolution sentence.
- Every machine question's `outcome` (41 to 699 characters per mode) is required, same as an
  event option's `outcome` — a machine's aftermath is not optional either.
- A rule may never carry a `random` effect (see section 5); an item's `use` and a person's
  exchange options may never carry one either — items are spent deterministically and people
  never roll dice.

Two more are conventions the shipped Silicon Trail content follows and its own content tests
check, not something `pack:check` enforces for a new pack automatically — write the equivalent
test for your own pack's content if you want the same guarantee:

- An event's `fallback.options` ids match its real `options` ids exactly (same set, either
  order) — the fallback stands in for the narrated scene, so it needs the same choices.
- No real company, product, or person appears in any string. The shared list lives in
  `shared/banned.ts` (`BANNED_NAMES_RE`); `tests/content/*.test.ts` runs it over every string
  Silicon Trail ships, and `tests/tools/packdocs.test.ts` runs it over this guide's own generated
  companion file.

## 9. Prompts

`prompts/world.md` is your pack's world bible: setting, tone, what a machine in this world is
and isn't. `prompts/acts/actN.md` is per-act: the region, and the arc — what changes for the
party by the end of that act. Both are prose the narrator model reads on every call for that
pack; neither is validated by `PackSchema` (it isn't part of `pack.json` at all — `packFromJson`
takes a `promptsDir` argument instead, and the server reads the files straight off disk).

The scene templates themselves (`server/prompts/scenes/*.md` — `travel_event.md`,
`waypoint.md`, `machine_answer.md`, and so on) are engine-level, shared by every pack, and not a
pack's to change. `server/prompts/system.md` is likewise engine-level: it's the piece that,
combined with your `world.md` and the current act's lore, has to clear Anthropic's prompt-cache
floor. `tests/server/prompts.test.ts` checks that `system.md` plus your pack's `world.md` plus
each act's lore file together exceed 16,500 characters, for every act of every registered pack —
short of that, the small-tier model (`claude-haiku-4-5`) never gets a cache hit at all. If your
pack fails that test, the fix is more world bible or more act lore, not a smaller system prompt.

## 10. Balance

Five targets, declared under `balance` in `pack.json` (or `manifest.balance` in the TypeScript
form): `medianDeathAct`, `arrivalRate` (a `[min, max]` band), `maxCauseShare`,
`minTrustedLieRate`, and `lieKillRate` (also a `[min, max]` band). These aren't checked by
`pack:check` — they're a target you tune content against, using the headless simulator:

```bash
npm run balance -- --pack my-pack --runs 2000
```

This drives a scripted policy bot through thousands of seeded runs and prints the actual median
death act, arrival rate, death causes by share, and the two lie-related rates, so you can compare
them against the numbers you declared. If a number is badly off, the fix is almost always an
event weight or a supply value, not the target itself — see `docs/balance.md` for the kind of
tuning log a balance pass leaves behind.

## 11. The art pipeline

A pack's art lives under `client/packs/<id>/art/`. Every script below takes `--pack <id>`
(`tools/art/paths.py`'s `add_pack_arg`), so a second pack's art pool never touches Silicon
Trail's own files. `generate.py` and `curate.py` need Pillow and (for anything past
`--dry-run`) OpenMontage on `PYTHONPATH`, per `generate.py`'s own header comment — set up a venv
for that the same way `tools/audio/`'s pipeline does before running them.

```bash
npm run art:events -- --pack my-pack                     # export events.json for the manifest builder
python3 tools/art/build_manifest.py --pack my-pack        # build manifest.json from events + acts
python3 tools/art/generate.py --pack my-pack --dry-run    # synthesize placeholders, no API key needed
python3 tools/art/curate.py --pack my-pack --id <slot>    # pick a candidate for one manifest slot
```

`art/palettes.json` is one 16-colour palette per act — the palette lock: every generated image
is quantized down to exactly those 16 colours, so a pack's whole act reads as one consistent
piece of pixel art regardless of which model generated which slot.

A pack may ship **no art at all**: an `art/index.ts` whose `cardExempt` lists every event and
climax id the pack declares and whose `loadBackdrop` always returns `null` (the template pack
shipped this way until #67 gave it a pool; `git show f7af2e9:client/packs/template/art/index.ts`
is the shape). That's a legitimate, fully-supported way to ship a pack — the UI renders a flat
scene instead of a backdrop, and the card-coverage test passes honestly (every id is explicitly
exempted) rather than by being skipped.

### Cover

A story shows its own cover inside the app (#68): the title screen sets the pack's title as the
wordmark in the pack's display face and colours, the shell's accent takes the pack's, the tab is
titled after the story, and `/play/<id>` unfurls with the story's title, tagline and picture. All
of it comes from two places, and none of it can change layout or load code:

- `card.json`'s `theme` block: `accent`, `wordmark` and `tagline` (six-digit hex) and `display`,
  one of the faces index.html loads (`DISPLAY_FACES` in client/engine/pack.ts: Jersey 15,
  Pixelify Sans, Silkscreen). Every registered pack ships one; `tests/ui/theme.test.ts` checks it.
- `art/cover/`: `cover.png` (1200x632, quantized to the act 1 palette by the art pipeline's
  `cover` slot) for the cartridge shelf and the social card, and `mark.svg` beside the wordmark.
  Both are optional: a pack with neither shows its emoji on the shelf and its title alone on the
  title screen, which is the text-only cover every pack gets for free.

```bash
python3 tools/art/build_manifest.py --pack my-pack             # adds the one `cover` slot
python3 tools/art/generate.py --pack my-pack --kind cover --provider gemini --candidates 1
python3 tools/art/curate.py --pack my-pack --id cover --candidate 0
```

The cover prompt reads `cover` from `art/prompts.json`: one sentence naming the picture that is
the story (Salt Road's is the causeway across the pans at last light).

### Voice

A pack may also ship pre-recorded narration under `audio/voice/`, one `.mp3` per cue id
(`title_hook`, `briefing_N`, `opener_N`, `leave_advice_*`, `ending_*` — the same ids
`client/ui/narration.ts`'s `cuesForScreen` builds). Resolved through the pack's own
`audio/index.ts` (`PackAudio`, client/engine/pack.ts), a lazy `import.meta.glob` exactly like
the art loader, so plain-Node tools never evaluate it. `tools/audio/export-lines.ts` and
`tools/audio/generate.py` take `--pack` (default silicon-trail) and write under that pack's
`audio/` directory. A pack that ships no recordings — an `audio/index.ts` whose `voiceIds`
returns an empty list and whose `loadVoice` returns `null` for every id — is simply read as
text: `useNarration` (client/ui/store.tsx) already treats a null URL as nothing to play. Every
registered pack ships recordings today.

Recordings use ElevenLabs' `eleven_v3` model, chosen over the older `eleven_multilingual_v2`
for expressive delivery: pauses, pacing and emotion instead of a flat read. Every line carries
a delivery tag — `[grave, slow]`, `[quiet, measured]`, `[wary]`, `[tired, warm]`, and so on —
placed ahead of its text and sent to the API as part of the same string, chosen by cue kind
(the title hook, each briefing paragraph, the last briefing weighted differently, each act
opener rising in intensity through the act, leave advice, and each ending keyed by its own
meaning). The full table lives in `DELIVERY_MOOD` in `tools/audio/export-lines.ts`, next to
`fixedLines()`, which is the single place that assigns a tag to a line. `eleven_v3` has no
SSML break tags, so a paragraph break in the source text becomes a `[long pause]` and a plain
line break becomes a `[short pause]`; see `spoken_text()` in `tools/audio/generate.py`, which
builds the exact string that is hashed into `text_sha256` and billed as `chars`. Because the
tag is part of that hashed string, changing a line's mood is a content change like any other
and triggers a re-record.

`tools/audio/generate.py` reads `tag` straight from the committed `lines.json`, not from the
table, so a hand edit to one line's `tag` there takes effect immediately and wins over the
cue-kind default — until `lines.json` is regenerated. Running `export-lines.ts` again
recomputes every `tag` from `DELIVERY_MOOD` and overwrites the hand edit, and
`audio-manifest.test.ts`'s "is still exactly what the TypeScript content says" check will then
fail until the override is either accepted or folded into the table itself.

**The voiced road (#89).** A pack's `audio/index.ts` declares `roadActs`, the 0-based act indexes
whose road is recorded. For each such act the exporter also writes every road note the act can
show (`road_<note id>`) and every option outcome of every event and climax the act can surface
(`outcome_<event id>_<option id>`); the travel card reads today's note once the days stop ticking,
the aftermath reads the chosen outcome, and the next act's opener leads with the climax outcome it
shows. Widen a pack's voiced road by adding an act index and rendering the new slots (a new slot
inherits its speaker's voice id). Every registered pack ships its whole road this way, about 12,000 to 18,000 credits an act.

## 12. Shipping checklist

- [ ] `npm run pack:check -- client/packs/<id>/pack.json` passes.
- [ ] The pack is added to `PACKS` in `client/packs/index.ts`.
- [ ] `npm test` is green.
- [ ] `npm run balance -- --pack <id> --runs 2000` prints numbers inside the targets you declared.
- [ ] `card.json`'s `acts` and `lengthMiles` match the acts you actually wrote.
- [ ] Every act has a climax, an opener, a palette, and a `prompts/acts/actN.md` lore file.
- [ ] `prompts/world.md` exists.
- [ ] No real company, product, or person appears anywhere in the pack's text.
- [ ] The art manifest regenerates with no diff (`build_manifest.py --pack <id>`, then `git
      diff` shows nothing).
- [ ] `npm run pack:docs` leaves no diff in `docs/story-packs-fields.md`.
- [ ] A scripted walkthrough reaches act 2.

## docs/story-packs-fields.md

# Story pack field reference

Generated by `npm run pack:docs` from `client/packs/schema.ts`. Do not hand-edit this file —
run the generator and commit the result. See `docs/story-packs.md` for the authoring guide
this reference supports.

## Top-level fields

### `achievements`

Required. array.

Badges awarded when a condition holds at run end, optionally gated on whether the run ended in death or arrival.

### `acts`

Required. array (at least 1).

The ordered acts a run passes through: terrain, weather curve, waypoints, an optional branch, a climax, and a required list of ambient effects the world can play on its own.

### `balance`

Required. object (required keys: `arrivalRate`, `lieKillRate`, `maxCauseShare`, `medianDeathAct`, `minTrustedLieRate`).

The five targets `npm run balance` checks a pack's headless simulation against.

### `characters`

Optional. array (at most 20).

People this pack ships for the character library: a name, a background from this pack's `where` origins, a trait, a relation, a detail, and a kit built on one of this pack's `kits` presets. A character may instead name a `character` from the shared library (client/library/characters.ts) and inherit its name, pronouns, trait, relation, detail and voice note, overriding any of them one field at a time; the background, the kit preset and the kit are always this pack's own.

### `climaxes`

Required. array.

One climax event per act (named by that act's climaxId), each also carrying a truthReveal.

### `comparisons`

Required. object (required keys: `headings`, `rows`).

The heading, note, and per-choice cells shown on each prologue comparison card (where, party, kit, leave, store).

### `counters`

Required. array.

Named counters a rule's counter effect can increment and a condition's counterAtLeast can read.

### `details`

Required. array (at least 5).

A pool of one-line party-member flavor details, drawn without replacement, at least one per possible party member.

### `endings`

Required. array (at least 1).

The possible run endings and the condition that selects each one.

### `epitaphs`

Optional. object, keyed by id.

An optional per-death-cause pool of epitaphs that overrides the shared library in client/library/epitaphs.ts, cause by cause.

### `events`

Required. array (at least 1).

The pool of travel encounters the day loop draws from, filtered by act and terrain.

### `frame`

Required. object (required keys: `cardGuidance`, `comparisonHint`, `incompatibleSave`, `leaveAdvice`, `leaveQuestion`, `logButton`, `lossLines`, `prologue`, `prologueLeads`, `rites`, `setupGuidance`, `titleHook`, `turnOut`).

The narrator voice: title hook, prologue, one lead per origin question, card guidance, an optional labels map (beat, asked, ending) for the eyebrow over each boxed passage, an optional ledgerFlags map of flag id to the sentence the ledger prints when that flag is set, loss lines, rites, five turn-out paragraphs (one per reason plus a mercy), and every other fixed piece of UI copy.

### `items`

Required. array.

Inventory items beyond the eight supply kinds, each with a use effect list and a use summary.

### `joiners`

Required. array.

People the road can put in the car. A join effect on an event option names one by id, and the engine builds a party member from the record.

### `machines`

Required. array.

AI kiosks and terminals a waypoint can name, each with an agenda, an honesty range per agenda, and a set of askable questions. A machine may instead name a `persona` from the shared library (client/library/personas.ts) and inherit its name, weights, honesty and voice, overriding any of them one field at a time.

### `missionIdPrefix`

Required. string (min 1, max 20 characters).

The id prefix every side-mission event in this pack carries.

### `names`

Required. object (required keys: `ages`, `defaults`, `pronounHints`, `traits`).

Default party names, trait words, ages, and pronoun hints the party creator draws from.

### `openers`

Required. array (at least 1).

One narrator-facing opening paragraph per act, read before that act's first scene.

### `origins`

Required. object (required keys: `householdRelations`, `kits`, `leave`, `party`, `questions`, `where`).

The four prologue choice groups (where, party, kits, leave) and the question text shown for each.

### `partyLines`

Required. object (required keys: `lines`, `noOrigin`, `playerRelation`).

Party-member chatter lines, the default text for a relation to the player, and the no-origin fallback line.

### `people`

Required. array.

NPCs a waypoint can name, each offering rumour, advice, or trade exchanges.

### `presets`

Required. array (at least 1).

Starting-supply presets offered in the kit prologue step.

### `roadNotes`

Required. object (required keys: `road`, `shared`, `stop`).

Flavor lines shown while traveling (road), at a stop, or in both places (shared).

### `rules`

Required. array.

Effects that fire automatically on a trigger (dayPassed, eventResolved, stopEntered, runEnded) rather than from a player choice.

### `store`

Required. object (required keys: `actMarkup`, `items`, `loadouts`, `recommended`, `stores`).

The waypoint store's item catalogue, named stores, per-act price markup (one multiplier per act), and the loadout presets offered on the store screen.

### `threads`

Required. array.

Multi-beat storylines an origin can select, tagged onto events by id and beat number.

### `vehicles`

Required. array (at least 1).

The vehicle types a pack offers, with starting condition, starting parts, and food capacity.

### `weatherDefault`

Required. one of `clear`, `cold`, `heat`, `rain`, `storm`.

The weather shown before the first day's roll.

## The condition language

Every `PackCondition` operator (`client/packs/conditions.ts`), longhand — this is not
derivable from the JSON Schema above, and it is the thing a pack author needs most.

- `flag`: true once the named flag has been set by an effect.
- `notFlag`: true until the named flag is set.
- `supplyBelow`: true while a named supply (`[key, n]`) is under `n`.
- `supplyAbove`: true while a named supply (`[key, n]`) is over `n`.
- `aliveAtLeast`: true while at least this many party members are still travelling with you.
- `aliveAtMost`: true while at most this many party members are still travelling with you.
- `act`: true on this act number (1-indexed).
- `dayAtLeast`: true from this trail day onward.
- `memberAlive`: true while the party member with this relation is alive and still with you.
- `counterAtLeast`: true once a named counter (`[name, n]`) has reached `n`.
- `all`: true when every condition in the array is true.
- `any`: true when at least one condition in the array is true.
- `not`: true when the wrapped condition is false.

There is deliberately no operator for a machine's honesty, agenda, or a known fact's truth —
a pack cannot gate anything on information the engine keeps hidden from the player.

## Rule triggers

The four values a rule's `on` field may hold, in the order the reducer checks them over the
course of a day (`client/engine/rules.ts`'s `runRules`):

1. `dayPassed` — after a day of travel resolves.
2. `eventResolved` — after a scene's option is chosen and its effects apply.
3. `stopEntered` — on arriving at a waypoint.
4. `runEnded` — once the run reaches an ending, win or lose.

A rule's own `effects` apply in declaration order, so a counter one effect writes is visible
to the next. A rule may never carry a `random` effect or target `"random"`/`"sent"` — nothing
in a rule resolves against a dice roll or a sent party member.

## Mission chains

Two fields turn one scene into a storyline (#88), and neither is derivable from the shape above.

`chained` marks an event as a beat rather than an offer. A chained event is filtered out of
`eligibleEvents` and `scavengePool` (`client/engine/events.ts`) before any pick is made, so
nothing the road rolls and nothing a stop search rolls can land on it: the only way in is another
option's `scene` effect. It still declares a `weight`, because every event does, and nothing
reads it. Its `acts` are the acts of the mission that leads there, which is what the art manifest
and the palette lookup read. A beat is deliberately never `once`: `firedOnce` is what the score
counts side missions out of (`missionsCompleted`, `client/engine/score.ts`), so a three-beat
chain in it would read as three searches seen through instead of one. Every chained event must be
reached by some option, and no chain may come back round to an event it already played — both are
refused at load.

`scene` is the effect that chains. `{ "type": "scene", "id": "<event id>" }` on an event
option means: apply everything else this option does, check the deaths, run the
`eventResolved` rules, and then put the named beat on screen instead of returning the party to
the stop or the road. The beat is resolved against the state the option left behind, and the roll
it spends is the one any road event spends, so a chain costs no extra randomness. A chain off an
option that killed the last member is dropped: the run is over. It may sit only at the top level
of an event option's own effect list, at most one per option — never inside a `random` outcome
(the beat a chain reaches must be the same on every roll), never on a climax option (which has an
act to advance), and never in a rule, a person's exchange, an item's `use` or a machine
consequence, none of which is ever sequenced by `chooseOption`.

## docs/mcp.md

# The builder MCP

The builder MCP is a [Model Context Protocol](https://modelcontextprotocol.io) server that hands
an assistant everything this repo already knows about authoring a story pack for The Silicon
Trail — the authoring guide, the generated field reference, the template pack, the persona and
character libraries, the prose rules, the balance targets and the narrator's system prompt — and
gives it five tools to write one with: scaffold a skeleton, validate a draft against the schema
the game loads, lint its prose, simulate the finished pack, and submit it. A submission opens a
pull request against this repository. Nothing is ever written to `main` directly, and a person
reads the story before it ships.

## Running the server

```bash
npm install
npm run mcp
```

The server speaks stdio: it reads requests from standard input and writes responses to standard
output. It listens on no port and makes no network call of its own, except the one `submit_pack`
makes to open a pull request. A client spawns the process; there is nothing else to start or
deploy.

## Connecting a client

Playing needs no checkout at all: the six play tools and the five room tools (see "Play" and
"Play together" below) are also served hosted,
over [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports),
at `POST https://silicontrail.org/mcp`. Add that URL to any MCP client that speaks Streamable
HTTP and it can start a run, advance it and read back the party's strip without spawning anything
locally. The client's own session id scopes one run, and one room seat, to that session, so two
sessions never see each other's state unless they are seated in the same room; a session idle for
30 minutes is closed, and its run and its seat go with it. No auth, like the rest of the site; a
limit of `MCP_CALLS_PER_IP_PER_DAY` (600) calls a day per address, and the play and room tools share the same window of 120
calls a minute the stdio path uses. The builder tools stay stdio-only,
since `submit_pack` writes to this repository.

For an assistant that only speaks stdio, or to reach the builder half, add a server entry to its
configuration, replacing the path with this repo's own:

```json
{
  "mcpServers": {
    "silicon-trail-builder": {
      "command": "npm",
      "args": ["--prefix", "/path/to/silicon-trail", "run", "mcp"]
    }
  }
}
```

For a client that takes a command line:

```bash
claude mcp add silicon-trail-builder -- npm --prefix /path/to/silicon-trail run mcp
```

On connect, the server reports its name and version — the version read from this repo's own
`package.json`, never written twice — and an instructions string every client shows before it
does anything else:

> This server helps an assistant write a story pack for The Silicon Trail: a five-act trek
> carried in one JSON file and validated by the same schema the game loads. The resources are the
> authoring guide, the generated field reference, the template pack, the persona and character
> libraries, the prose rules, the balance targets and the narrator's system prompt; each is read
> from the repository when it is asked for, so none of them can drift from the code that enforces
> it. The order that works: read the guide and the field reference, scaffold a skeleton, write the
> content, validate after every edit, lint the prose, simulate the finished pack, then submit it.
> A submission opens a pull request, and a person reads the story before it ships. No pack is ever
> executed: a pack is data, and the only things that ever touch it are the schema and the engine's
> own reducer.

## Resources

Ten, all read-only, each read off the repo at the moment it's asked for rather than copied into
the server — a resource can't drift from the file or the module it serves.

| URI | Source | What it's for |
| --- | --- | --- |
| `silicon-trail://guide/authoring` | `docs/story-packs.md` | The authoring guide: what each part of a pack is for, one worked example at a time. |
| `silicon-trail://guide/fields` | `docs/story-packs-fields.md` | The generated field reference: every key, its type, and whether it's required. |
| `silicon-trail://guide/balance` | `docs/balance.md` | What the balance simulator measures, and how to read its report. |
| `silicon-trail://template/pack.json` | `client/packs/template/pack.json` | The template pack — the worked example the authoring guide points at, and what `scaffold_pack` resizes. |
| `silicon-trail://template/card.json` | `client/packs/template/card.json` | The template's registry card, alongside the pack. |
| `silicon-trail://library/personas` | `PERSONAS` (`client/library/personas.ts`) | The shared persona library a pack's party members draw on. |
| `silicon-trail://library/characters` | `CHARACTERS` (`client/library/characters.ts`) | The shared character library: the sheet a party member carries. |
| `silicon-trail://rules/prose` | `shared/banned.ts` plus the house rules | The three prose rules a pack's own text is held to: no banned name, no second person, no exclamation. |
| `silicon-trail://balance/targets` | each registered pack's `balance` block, from `PACKS` | What a shipped pack declares about its own difficulty, for comparison. |
| `silicon-trail://prompts/system` | `server/prompts/system.md` | The narrator's own system prompt, which a pack's `world.md` and act lore have to sit under. |

## Tools

Five, in the order that works: scaffold a skeleton, write its content by hand, validate after
every edit, lint the prose, simulate the finished pack, then submit it. Every tool that takes a
pack validates it first, and none of them ever executes a pack — a pack is data, read by the
schema and the engine's own reducer, never by anything that runs it as code.

### `scaffold_pack`

Input: `id` (a lowercase letter, then lowercase letters, digits and hyphens, 2 to 39 characters in
all, and not `silicon-trail` or `template`), `title`, `premise`, `acts` (a whole number, 1 to 5).

Returns the template pack resized to the requested number of acts — one climax and one opener per
act, waypoint ids and mile shares lined up with it — and a `card` whose `id`, `title`, `tagline`
and `acts` match what was asked for. Every string in the result is still the template's; the
result also lists the paths an author has to rewrite (`acts[].name`, `events[].narrative_brief`,
and so on). The scaffold validates its own output before returning it, so what comes back always
passes `validate_pack`.

### `validate_pack`

Input: `pack`, the pack as JSON text (at most 1 MiB; the template is 65 KiB).

Runs the exact schema the game loads and the exact formatter `npm run pack:check` uses, so the
two can never print different things about the same file. On success, one line:

```
pack.json: valid — 2 acts, 6 events, 4 items, 7 rules, 3 achievements
```

On failure, one `path: message` line per issue, in the order the schema reports them.

### `lint_prose`

Input: `text` (at most 20,000 characters).

Checks a passage — an act's lore, a line of dialogue, anything — against the three house rules a
pack's own prose is held to: no real company or person named, no second person, no exclamation
mark. Every occurrence comes back with its line and column; a clean passage gets one line saying
so. The rules are the schema's own patterns, so a passage this tool accepts is one the schema will
accept too.

### `balance_pack`

Input: `pack`, `card`, `runs` (1 to 500, default 200).

Runs the same simulator `npm run balance` runs, over a pack nobody has registered, against that
pack's own declared balance targets. The report: a median death act, an arrival rate, deaths by
cause, a trusted-a-lie rate, and a pass/fail line per declared target. 500 runs takes about a
second and a half; a 20-second wall clock cuts a run short rather than running long, and a short
report says how many runs it actually covers.

### `submit_pack`

Input: `pack`, `card`, `author` (1 to 60 characters), `notes` (optional, at most 1,000
characters), `balance` (optional, at most 5,000 characters — a `balance_pack` report, quoted
verbatim in the pull request body), `dry_run` (optional).

Opens a pull request: a new branch (`pack/<id>-<timestamp>`), two files
(`client/packs/<id>/pack.json` and `client/packs/<id>/card.json`), and a body carrying the pack's
id and title, the author as given, the content counts, the validator's exact output, the lint
result, the balance report if one was supplied, and what a reviewer still has to do by hand (see
below). `dry_run: true` returns the same plan — the branch name, the two paths, the title, and the
pull request body — and makes no network call.

The tool refuses rather than works around: an invalid pack (the validator's lines come back
instead of a plan), a card whose `acts` disagrees with the pack's own act count, an id that
already exists under `client/packs/` on `main`, or a missing token (below) — the tool names the
variable and opens nothing.

## Submission and review

A submission is a pull request against `sethshoultes/silicon-trail`, opened by `submit_pack` and
read by a person before anything about it changes. The tool never writes to `main`, and it never
registers a pack — that stays a reviewer's decision, made in the pull request, not something a
tool does on an author's behalf.

`submit_pack` needs `SILICON_TRAIL_SUBMIT_TOKEN` in the environment of the process running
`npm run mcp` — never in a file in this repo. It's a fine-grained GitHub personal access token,
scoped to:

- **Repository access:** this repository only (`sethshoultes/silicon-trail`).
- **Permissions:** Contents (read and write), Pull requests (read and write).

Its canonical home on a developer's machine is `~/.config/dev-secrets/secrets.env`, sourced into
the shell before `npm run mcp` runs:

```bash
set -a && source ~/.config/dev-secrets/secrets.env && set +a
npm run mcp
```

Without it, `submit_pack` names the variable and opens nothing. The token is read from the
environment, sent in exactly one request header, and never appears in a tool result, a log line
or a pull request body.

What the tool does: six calls to the GitHub REST API — read `main`, check the pack directory is
free, create the branch, write `pack.json`, write `card.json`, open the pull request. What it
leaves for a reviewer to do by hand, stated at the end of every pull request body:

- register the pack in `client/packs/index.ts` and add its `index.ts`;
- write `prompts/world.md` and one `prompts/acts/actN.md` per act;
- read every string in the pack.

## Limits

So a refusal is never a surprise:

- A pack: at most 1 MiB.
- A `lint_prose` passage: at most 20,000 characters.
- A scaffold's `title`: at most 60 characters; its `premise`: at most 400; its `acts`: 1 to 5.
- A submission's `author`: 1 to 60 characters; its `notes`: at most 1,000 characters; its
  `balance` report: at most 5,000 characters.
- A `card`, in `balance_pack` and `submit_pack` alike: `id` at most 40 characters, `title` at most
  60, `tagline` at most 120, `cover` at most 8, `acts` 1 to 5, `lengthMiles` 1 to 20,000.
- A `balance_pack` run count: 1 to 500 (default 200), and a 20-second wall clock regardless of how
  many were asked for.
- Rates, per running server: 60 `validate_pack`, `lint_prose` or `scaffold_pack` calls a minute,
  10 `balance_pack` calls a minute, 3 `submit_pack` calls an hour, and never two `balance_pack` or
  `submit_pack` calls running at once — nor one of each together, since both touch the same
  in-memory pack. Over a limit, the tool answers with the limit it hit, by name, and attempts
  nothing.

## Play

The same server also plays a story, over stdio or over the hosted endpoint above. One run per
session: `start_run` opens it and replaces whatever run that session already had, and every other
play tool reads that session's own run — two sessions on the hosted endpoint hold two independent
runs at once, never sharing state. The engine decides every mechanical fact, the miles, the
supplies, who falls ill and who dies, exactly as it does in the browser, and the connected
assistant's own model does one thing: it narrates the scene the engine hands it. Nothing the model
writes changes an outcome.

The six tools, in the order a session uses them:

1. `list_packs` returns every registered pack's card: id, title, tagline, cover, act count and
   length in miles.
2. `start_run` takes `{ pack, seed?, setup? }`. Without a `setup` the run gets the pack's own
   default party on its first vehicle and preset, the same party the balance tool plays. A
   `setup` names the party (`names`, `vehicle`, `preset`); a vehicle or preset the pack does not
   declare is refused by name. The reply carries the act opener's `scene_request`, the first thing
   to narrate, plus `replaced: true` when a run was already in progress.
3. `advance` takes `{ action }`, one wire action (`CHOOSE_OPTION`, `TRAVEL_DAY`, `SET_PACE`,
   `WAYPOINT_REST`, `LEAVE_WAYPOINT` and the rest of the engine's own vocabulary; `START_RUN` is
   not one of them). The reply is `{ run_token, scene_request, phase, outcome? }`. On most turns
   `scene_request` is `null`: the party is on the road or standing at a stop with nothing
   pending, so the next call is `TRAVEL_DAY`, or a `WAYPOINT_*` action while `state` reports the
   phase `waypoint`. When `scene_request` comes back with a value, narrate it and choose from its
   `options`. When `outcome` is present it is the engine's own fixed paragraph for the choice
   just made: already-decided fact, never to be re-narrated into a different result.
4. `state` returns the party strip: day, act, position, weather, food and water in days, fuel,
   the vehicle's condition, and every member's health and conditions, rounded the way the web
   strip rounds them.
5. `ledger` returns every recorded change so far in sentence form, grouped by day, oldest day
   first.
6. `export_run` returns `{ setup, seed, actions, pack }`. That tuple replays to the same state
   in the engine (`replay` in `client/engine/replay.ts`) and in the web game, and posts to the
   leaderboard through the existing verified route once a score is attached the way the web
   client attaches one.

One resource joins the ten the builder serves: `silicon-trail://prompts/narrator`, the system
prompt to narrate the pending scene under. It composes the engine's narrator rules, the run's
pack and act, and the pending scene's own template, read fresh from disk on every read, so it is
read again after every `advance` whose `scene_request` is not null. Reading it while nothing is
pending returns an error naming that, which is expected there rather than a fault.

The play tools share one rate window of 120 calls a minute with the room tools below, sized for a
whole session rather than one shot at a draft. A refusal names the window. The hosted endpoint
adds its own limit of `ROOM_ACTIONS_PER_IP_PER_DAY` (5000) calls a day per address, on top of that shared window.

## Play together

A room is one run up to four seats share (`docs/rooms.md`). The hosted endpoint holds one room per
session, and its five room tools call the server's own `RoomHub` in-process, so a seat held by an
agent and a seat held by a person in a browser sit at the same table. These tools are registered
only where there is a hub to call, so a stdio server started from a checkout does not offer them.

1. `create_room` takes `{ pack, setup? }`, opens a room of up to four seats, takes seat 0, and
   returns `{ room_id, seat, join_url }` — the link a person or another agent joins by. The setup
   is the same shape `start_run` takes, and the seed is the server's own: a room's run token
   carries its seed and the leaderboard checks the two against each other, so a chosen seed could
   never post.
2. `join_room` takes `{ room_id, seat? }` and returns `{ seat }`: the seat asked for, or the
   lowest one free.
3. `room_state` returns the room as this seat sees it — every seat with its name, whether it is
   claimed and whether it is present; the open decision with its options and their labels, the
   scene brief behind it, the rule it settles by and the milliseconds left; the last settlement
   and why; the party strip; every death with its cause; the log length; and the run id and
   permalink once the run has ended.
4. `room_vote` takes `{ key, choice }` — the key and option id `room_state` named — and returns
   `{ ok, settled }`, where `settled` is null while the road is still waiting on another seat.
5. `room_act` takes `{ action }`, one wire action this seat may take alone: a knob, a purchase, an
   item on its own member. A shared choice is a ballot, not an action, and is refused here.

The seat token is the room's authority, and it never reaches the model: it is held in the session
(`mcp/tools/room.ts`), so no room tool takes it as an argument or returns it. The session's
presence is beaten on every room tool call and every 20 seconds in between, so the road waits for
this seat while the session lives. `start_run` refuses a session already seated in a live room,
and names the room, because a solo run would leave that party a seat short.

Naming a finished room run for the leaderboard is an HTTP route rather than a tool:
`POST /api/room/:id/handle { seat_token, handle, trail_code? }`, by the lowest present seat. Every
room route is documented for a non-MCP agent at `/agents.md`.

## The storyteller's prompts

Two prompts, served only by the hosted endpoint (`mcp/storyteller.ts`), so a client can start a
game by running one rather than by pasting a page into a chat:

- `play { pack, seed? }` — the pack's own world (its card and its first act, read off the pack),
  the narrator's register (the Role and Voice sections of `server/prompts/system.md`, the same
  prompt the scene service runs under), the turn loop `/agents.md` publishes, and four standing
  rules: narrate before choosing, never restate an `outcome` into a different result, weigh the
  strip's food and water at a mission, and answer a loss card in character.
- `join { room_id }` — the same register and rules for one seat of a shared run, with the room's
  own loop: read `room_state` first, wait while `starts_in_ms` is set, vote where this seat is an
  elector, and call `room_state` at least every 20 s so the seat keeps its place.

The server's `instructions`, which a client reads on initialize, say the same thing in two
sentences: the engine decides every fact, the connected model narrates and chooses, and the way in
is one of these two prompts. A stdio session started from a checkout is building a pack rather
than playing one, so it registers neither prompt and keeps the builder's own instructions.

## Not yet built

Both halves of [issue #74](https://github.com/sethshoultes/silicon-trail/issues/74) are here, the
hosted endpoint from [issue #91](https://github.com/sethshoultes/silicon-trail/issues/91), and the
room tools from [issue #94](https://github.com/sethshoultes/silicon-trail/issues/94). Not in this
pass: a run that outlives its session (a hosted session's run is gone once the session is, and so
is its seat), voice or art over the wire, and the visual recap.
