diff --git a/docs/phase-14-plan.md b/docs/phase-14-plan.md new file mode 100644 index 0000000..bead22f --- /dev/null +++ b/docs/phase-14-plan.md @@ -0,0 +1,109 @@ +# Phase 14 plan — triggers and the interpreter + +Implementation plan for phase 14 of [the osrlib spec](spec.md): the trigger spec, authored triggers on the adventure document, and the library-shipped interpreter listener — document-order matching, once-only fired-state, the depth-4 cascade bound with truncation notes, and dropped-consequence recording; `validate_adventure` grows trigger reference checks. This is the third link of the dependency chain 10 → 11 → 14 → 15: phase 10 built the item-identity surface patterns and grants speak, phase 11 built the condition union and narrative block triggers reuse, phase 13 built the lifecycle commands and session blocks the interpreter drives, and phase 15 composes quests from this phase's trigger machinery. The milestone: **the lever-opens-portcullis scenario authored as data replays identically with no listeners, and a trigger spawn colliding with an open encounter drops and records its note.** + +Five facts shape the design: + +- **The issuance channel already exists, and two session seams must harden before it carries authored content.** The extension contract's proven pattern for a command-issuing listener is the phase 5 example's `FetchQuestListener`: re-entrant `session.execute()` inside `handle()`, returning no events because the nested executes already logged theirs. The interpreter is that pattern promoted to library code — but two defects in the seam become load-bearing. First, nested-command events never reach the outer `CommandResult`: a front end executing `MoveParty` would render nothing of the portcullis opening. The fix is in `execute()`'s listener loop — record the event-log length before each `listener.handle()` call and splice everything logged during it (the re-entrant commands' events, single copies, log order) into the result's accumulated events. Second, `_persist_sight` runs *after* the listener loop, so a consequence that relocates the party (`PlaceParty`, the authored teleport) would fold the *destination's* reveal over the move's own — while a replay, executing the same commands sequentially with no listeners, folds both. Moving `_persist_sight` before listener dispatch makes live and replay fold identically; today no listener relocates, so the move changes no existing behavior and no stored golden (goldens snapshot state and logs, never result envelopes, and the event log was already interleaved correctly). +- **Replay identity forces the interpreter to emit nothing and hold nothing.** The standing equivalence — `load(save)` equals `replay(seed, commands)`, event log included — survives a registered interpreter only if every effect it has is a logged command: a listener-channel event or a private memory would exist live and vanish under replay. So the interpreter returns `([], {})` from every `handle()` call, keeps its `listener_state` entry empty forever, and reads all durable state from the session — fired-marks from `fired_triggers`, world state through the commands it issues. Its only instance state is a transient cascade-depth counter that exists mid-command, where no save can happen. This is the spec's "the interpreter holds no state the engine cannot reconstruct" made mechanical. One block is exempt from the equality by construction, and the exemption is pinned rather than papered over: `register_listener` itself creates the listener-state entry (`listener_state.setdefault(listener.key, {})`), so a live session inescapably carries `{"osrlib.interpreter": {}}` where a listener-free replay carries `{}` — the phase 5 milestone golden already compares modulo `listener_state` for exactly this reason. The phase golden takes that precedent and strengthens it: state equality on every block *except* `listener_state`, plus the assertion that the interpreter's entry is exactly the empty dict — holds-nothing pinned directly, not merely carved out — and command and event logs byte-equal. +- **Mark-before-consequences is the cascade guard, and the depth counter replaces the example's re-entrancy latch.** Matching walks the batch's events in log order and, per event, the adventure's triggers in document order — the spec's "triggers matching one event fire in document order", with batch order pinned here. A firing issues `MarkTriggerFired` first (phase 13's contract: the mark precedes the consequences), which is what makes once-only cascade-safe: the trigger's own consequence events re-enter the interpreter through the nested execute's listener loop, and the mark is already in `fired_triggers` when they do. Repeatable triggers mark before every firing (the idempotent re-mark phase 13 pinned). Depth is a save/restore counter around a firing's issuance: events of the player's command are depth 0, a firing whose matched event sat at depth *d* issues under depth *d*+1, and matching runs at depths 0–4. At depth 5 the interpreter still evaluates would-be firings in full — pattern, fired-state, conditions — and for each genuine suppression issues `RecordNote` instead of firing: no mark, so a truncated once-only trigger remains fireable later. Depth 6 is unreachable (nothing fires at 5), and the example's `_reacting` latch has no analogue here — re-entrant self-invocation *is* the cascade mechanism, and the depth bound is its guard. Rejected consequences are dropped individually — the batch continues — each recorded by a `RecordNote` naming the trigger, the consequence's position and type, and the rejection code; the note text is composed from those stable parts only, exact strings settled in implementation. +- **The `fired` beat's carrier is a pinned interpretation, because two spec paragraphs collide.** The narrative-blocks section lists "fired text for triggers" among display beats a deterministic renderer shows verbatim; the visibility section rules trigger-fired events referee-visibility. Both cannot hold for a player-facing renderer. Pinned resolution: the visibility ruling is the more specific and wins — `MarkTriggerFired` gains `narrative: str | None` (the interpreter stamps the fired beat into it) and `TriggerFiredEvent` carries it out at referee visibility, where the generic formatter hook already appends any event's non-empty `narrative` verbatim, exactly the phase 11 gate-beat mechanism. A trigger's *player-facing* voice is its `journal` form — `AddJournalEntry` rides the player-visible `JournalEntryAddedEvent`, the spec's own "gives quest-less triggers a journal voice" — plus whatever its consequences' own player-visible events show. The authoring guide states the rule plainly: for the player to read text when a trigger fires, author the journal form. Firing order is mark, then consequences in authored order, then the journal entry — the phase 13 golden's order, so the journal records the moment the firing's effects have landed and its `rounds` stamp sits after any consequence-advanced time. +- **Authored consequences cannot name session-scoped ids, so character targets are selectors and the consequence surface is a typed sub-union.** `GrantItem`, `GrantCoins`, and `AwardXP` address a `character_id` the engine allocates per session — no adventure document can know one, yet the spec requires authored grants and XP ("Authored XP belongs in `AwardXP`"). Pinned: in an authored consequence, `character_id` must be a party selector — `@party` (the interpreter expands to one command per living member, marching order) or `@first` (the first living member, the treasure-recipient precedent) — expanded at issue time, so the command log stays fully concrete and replays exactly. `@first` with nobody living drops with a note; `@party` with nobody living expands to nothing. The selector namespace cannot collide with engine ids (`character-NNNN`), and `validate_adventure` rejects literal ids outright. The surface itself is `ConsequenceCommand`, a discriminated sub-union over a new `CONSEQUENCE_COMMAND_CLASSES` census in `commands.py`: `GrantItem`, `GrantCoins`, `AwardXP`, `SetFlag`, `SpawnMonsters`, `SpawnNpcParty`, `SetDoorState`, `PlaceParty`, `AdvanceTime`. Typing `TriggerSpec.consequences` with that union makes parse-time enforcement free — a lifecycle command (`MarkTriggerFired`, `AddJournalEntry`, `RecordNote` are the interpreter's own vocabulary, never authored), a player command, or an unknown type fails at parse with no validator code. Excluded with rationale: `IdentifyItem` (its instance ids are session-scoped, the same unknowability as character ids, with no selector story worth building unconsumed) and `RollDice` (a draw whose result no authored construct can read — an authored no-op that would perturb the adjudication stream). + +## Scope + +In scope: + +- The pattern union, `TriggerSpec`, and the selector constants in the new `crawl/triggers.py` +- `ConsequenceCommand` and `CONSEQUENCE_COMMAND_CLASSES` in `crawl/commands.py` +- `Adventure.triggers` and the `validate_adventure` trigger checks (ids, patterns, conditions, consequence references, selectors) +- The interpreter listener in the new `crawl/interpreter.py`: matching, firing, cascade depth, truncation and dropped-consequence notes, selector expansion, `source` stamping +- The session seam work: the listener-loop splice and the `_persist_sight` reorder +- `LocationEnteredEvent.dungeon_id` (area entries become self-describing), `MarkTriggerFired.narrative`, `TriggerFiredEvent.narrative`, with their planned golden regenerations +- `flag_values_equal` extracted as a public helper in `crawl/gates.py` +- The phase golden (`tests/goldens/phase14_triggers.json`), `tests/test_triggers.py` and `tests/test_interpreter.py`, docs, the spec's consequence-surface amendment (selectors and exclusions), and the changelog + +Out of scope (deferred to the phase that picks each up): + +- **Quest specs, quest lifecycle commands and state, rewards, victory-on-completion, active quests in the player view, the per-level ambient guidance slot, and the example crawler's swap to the interpreter** — phase 15, which reuses `TriggerSpec`, `ConsequenceCommand`, and the selector convention unchanged. The example's hand-rolled `FetchQuestListener` stays exactly as it is this phase. +- **Trigger content in content packs** — out of scope by spec decision ("quest content in content packs"); packs carry no triggers, and `validate_content_pack` is correct unchanged. +- **An outcome filter on the monster-defeated pattern, coin/valuable acquisition patterns, and further selectors (`@carrier`, `@leader`)** — each arrives additively when a consumer demonstrates the need; the unions grow the way the condition union does. +- **Advisory analysis** — trigger cycles, flags nothing writes, unreachable triggers, a trigger with neither consequences nor narrative: authoring-tool territory by spec, not engine validation. The depth bound is the runtime guarantee that makes cycles safe to leave to lints. +- **Matching magic-item acquisitions by instance masking beyond the acquiring character** — the pattern resolves instance ids against the acquiring character's inventory at match time (the batch that carried the event); anything subtler waits for a consumer. +- **Tightening `MarkTriggerFired.trigger_id` against the trigger specs** — phase 13 pinned this open and handed the decision here; the answer is that it stays open, on the record. The command remains the documented referee surface a game's own listener or an LLM referee drives with ids of their own systems (`SetFlag`'s open key domain is the precedent, and the phase 13 golden marks `lever-east` with no spec in existence); tightening would couple a total `_ALL_MODES` handler to adventure content and make such logs illegal to replay. The interpreter only ever marks ids from the adventure's own trigger specs, so authored content gains nothing from the check. +- **A `Ruleset` flag and an adaptations entry** — triggers have no SRD basis; every pin here is a spec-design decision, so the register's silence is the phase 11/12/13 precedent. + +## Work items + +### 1. The pattern union and the trigger spec — `crawl/triggers.py` (new) + +- Seven frozen pattern models discriminated on `pattern_type`, mirroring the spec's four observables against the events that exist: `AreaEnteredPattern` (`"area_entered"`; `dungeon_id`, `level_number`, `area_id` — all three because area ids are level-scoped), `LevelEnteredPattern` (`"level_entered"`; `dungeon_id`, `level_number`), `DungeonEnteredPattern` (`"dungeon_entered"`; `dungeon_id`), `TownEnteredPattern` (`"town_entered"`; no fields), `ItemAcquiredPattern` (`"item_acquired"`; `item_id` — the equipment ∪ magic domain `has_item` established), `MonsterDefeatedPattern` (`"monster_defeated"`; `template_id`, any outcome — slain, routed, and surrendered all defeat), `FlagSetPattern` (`"flag_set"`; `key`, `value: str | int | bool | None = None` where `None` matches any written value, unambiguous because flag values cannot be `None`). `TriggerPattern` is the annotated union. String fields carry `min_length=1`. +- One matching pin with rationale: `LevelEnteredPattern` matches both `level`-kind and `dungeon`-kind location events with the right dungeon and level, because the event emission is an `elif` ladder — a dungeon crossing subsumes the level crossing it lands on, and arriving on a level is arriving on a level however the party got there. `AreaEnteredPattern` matches `area`-kind events by the full triple; `TownEnteredPattern` matches `town`-kind events wherever they emit (`TravelToTown`, `PlaceParty`). +- `TriggerSpec`, frozen: `id: str` (min length 1), `when: TriggerPattern`, `conditions: tuple[ConditionSpec, ...] = ()` (all must hold, evaluated live at match time — the AND a tuple gives without combinators), `repeatable: bool = False`, `consequences: tuple[ConsequenceCommand, ...] = ()` (empty is legal: a trigger whose whole job is its journal beat), `narrative: NarrativeBlock | None = None`. Two model validators: a condition with `consumes=True` is rejected at parse — a trigger fires, it does not take; there is no success seam to consume at (the `TrapEffect` gated-transition precedent) — and a consequence carrying `source` is rejected at parse, because the stamp is the interpreter's and an authored value would lie in the log. +- `PARTY_SELECTOR = "@party"` and `FIRST_LIVING_SELECTOR = "@first"` live here with the authoring contract in their docstrings. Module docstring orients: a trigger is an authored binding from an observable event pattern, optionally gated by conditions, to referee-command consequences — edge-triggered where gates are level-triggered; document order is the `Adventure.triggers` tuple order. Imports: pydantic, `crawl.gates`, `crawl.narrative`, `crawl.commands` — no session, no cycle (`adventure.py` imports this module). + +### 2. The consequence surface — `crawl/commands.py` + +- `CONSEQUENCE_COMMAND_CLASSES: tuple[type[Command], ...]` — the nine classes from the facts, wire order — and `ConsequenceCommand`, the annotated discriminated union over them, both in `__all__`. The docstring states the contract in the present tense: the referee commands an adventure document may carry as authored consequences, the lifecycle family excluded because those are the interpreter's own vocabulary, `IdentifyItem` excluded because instance ids are session-scoped, `RollDice` excluded because no authored construct reads a roll; and the selector rule for `character_id` fields. +- `test_commands.py`'s census cross-checks: every consequence class is a referee command, and the exclusions are exactly the two named plus the lifecycle family. + +### 3. Authored triggers on the adventure — `crawl/adventure.py` + +- `Adventure.triggers: tuple[TriggerSpec, ...] = ()` — document order is tuple order, the ordering contract matching relies on. Pre-phase documents and saves parse unchanged (the additive default); no `SCHEMA_VERSION` bump anywhere in the phase. +- `validate_adventure` grows the trigger walk, error lines in the house shape (`"trigger {id}: ..."`): ids non-empty and unique across the adventure; pattern references resolve (`AreaEnteredPattern`'s dungeon → level → area id, `LevelEnteredPattern`'s dungeon → level, `DungeonEnteredPattern`'s dungeon, `ItemAcquiredPattern`'s item id against effective equipment ∪ magic — the `_validate_gate` domain — and `MonsterDefeatedPattern`'s template id against the effective monster catalog; flag keys are the open domain, unchecked); condition `has_item` ids through the same helper the gate check uses, refactored so gate and bare-condition sites share it; consequence references per command — `GrantItem.item_id` against effective equipment, `SpawnMonsters.template_id` against effective monsters, `SetDoorState`'s dungeon/level resolving and a door edge existing at its cell and direction, `PlaceParty.location` resolving with the position in bounds (town needs no check); and the selector rule — a `character_id` in an authored consequence must be `@party` or `@first`, a literal id is an error because session-scoped ids have no meaning in a document. + +### 4. Session seam work — `crawl/session.py` + +- The splice: in `execute()`'s listener loop, record `len(self.event_log)` before each `listener.handle()` call; after it returns, extend `accumulated` with the log slice from that mark (the events re-entrant commands appended, already logged once, in log order), then with the listener's own emitted events as today. The `CommandResult` for a player command now carries the full cascade — the portcullis `DoorEvent`, the journal beat — in exactly event-log order, for any command-issuing listener, the example's included. The `Listener` protocol docstring and the extension-contract prose document the widened guarantee: everything a command causes, listener reactions included, rides its result envelope. +- `_persist_sight` moves above the listener loop, with the replay-equality argument from the facts in its docstring. Both changes are behavior-visible to games (a richer envelope, an identical-under-replay seen map) and land as one commit with changelog bullets; no stored golden changes, because goldens snapshot state and logs, never result envelopes. + +### 5. The additive event and command fields — `crawl/events.py`, `crawl/commands.py` + +- `LocationEnteredEvent.dungeon_id: str | None = None`, populated on `area`-kind entries only (`_boundary_events`), where it is the missing fact — `location_id` is the area id and area ids are level-scoped; `level`/`dungeon` entries already carry the dungeon id in `location_id`, and town has neither. The docstring updates accordingly. Matching never reads the party's current position: a consequence can relocate the party mid-batch, and events carry their own facts. +- `MarkTriggerFired.narrative: str | None = None` and `TriggerFiredEvent.narrative: str | None = None` — the fired display beat, stamped by the interpreter from the trigger's narrative block, carried at referee visibility per the pinned interpretation; the handler copies it from command to event, and the existing generic formatter hook appends it. Visibility is unchanged, so no view or leak-test surface moves. +- The golden consequence, planned: regenerate every stored golden whose event log carries a `LocationEnteredEvent` (the generators enumerate them by running; expected: the phase 4, 5-milestone, 11, 12, and 13 goldens) plus `phase13_journal.json` for the mark/fired `narrative` keys — pure-additive null-key diffs, one commit, explained per the standing golden rule. + +### 6. The interpreter — `crawl/interpreter.py` (new) + +- `Interpreter`, listener key `"osrlib.interpreter"`, constructed with the session (`Interpreter(session)`, the example's shape) and registered by the game via `register_listener`; it caches `session.adventure.triggers` at construction (frozen content). `handle()` returns `([], {})` always — the emits-nothing, holds-nothing pin from the facts. +- Matching: for each event in batch order, for each trigger in document order — pattern match (private per-pattern functions; `FlagSetPattern` compares the *event's* written value through `flag_values_equal`, edge semantics, not current state; `ItemAcquiredPattern` matches mundane catalog ids in `item_ids` directly and resolves magic instance ids through the acquiring character's inventory), then fired-state (`trigger.id in session.fired_triggers` blocks unless `repeatable`), then conditions via `condition_holds` against live session state. A match fires immediately — evaluate-as-you-go, so a later trigger's conditions see an earlier firing's effects; deterministic either way, and pinned this way. +- Firing, under the depth save/restore: issue `MarkTriggerFired(trigger_id, narrative=)`; expand and issue each consequence in authored order (`model_copy` for the selector substitution and the `source` stamp — frozen models copy, never mutate); if the trigger's narrative carries a journal form, issue `AddJournalEntry(text=)`. Every issued command — mark, consequences, notes, journal — is stamped `source="trigger:{id}"`, the phase 13 golden's format, so the log alone answers why. A rejected consequence is dropped and recorded with a `RecordNote`; a non-selector `character_id` that somehow reaches issuance (hand-built content bypassing validation) flows through and lands as an ordinary rejection-drop-note — the machinery already degrades gracefully. +- Depth: `_MAX_MATCH_DEPTH = 4`; at depth 5, the truncation scan from the facts. The docstring documents the whole lifecycle: re-registration after load (listeners are code, games re-register them, and the empty state entry means nothing migrates), register once (a double registration double-fires — the same contract as any listener), and the collision semantics (a spawn consequence meeting an open encounter rejects `encounter_in_progress` and drops with its note — the milestone's second beat; a mid-cascade wipe flips the session to `game_over` and the remaining consequences land or drop under terminal-mode legality, the phase 12/13 rules). + +### 7. The shared flag comparison — `crawl/gates.py` + +- The strict comparison (equality plus matching boolness) extracts from `condition_holds` into the public `flag_values_equal(stored, expected) -> bool`; `condition_holds` and the interpreter's `FlagSetPattern` matching both call it, so the condition and the pattern can never disagree about what "equals" means. Joins `__all__` with a docstring carrying the `True == 1` rationale. + +### 8. Docs and spec impacts — applied with the implementation PR + +- **`docs/spec.md` gains one addition, in the triggers section**: the consequence-surface amendment. Two sentences: the selector convention — consequence commands address characters through party selectors (`@party`, `@first`) expanded to living members at issue time, because a document can never name a session-scoped id — and the surface's exclusions — the lifecycle family (the interpreter's own vocabulary), `IdentifyItem` (session-scoped instance ids), and `RollDice` (a draw no authored construct reads). Both are spec-altitude by the phase 12 test: they define what a valid adventure document is, an author would mispredict them from "the existing referee command vocabulary" alone, and phase 15's rewards inherit them. Everything else in the phase implements existing spec text. The fired-beat visibility resolution is recorded in this plan and the relevant docstrings, below spec altitude — the spec's visibility paragraph already states the ruling that wins. +- **`docs/adaptations.md` gains no entries** — no SRD text is touched or reinterpreted; the silence is the standing precedent. +- **`docs/getting-started/building-an-adventure.md`** gains the trigger authoring section: the pattern vocabulary, conditions, consequences and selectors, `repeatable`, the narrative beats — including the plain rule that the journal form is the player's voice and `fired` is the referee's — the cascade bound, and the drop-and-note semantics. **`docs/guides/listeners-and-flags.md`** gains the interpreter: registration, the re-entrant issuance pattern now first-class (the splice), and the emits-nothing posture as the model for command-issuing listeners. **`docs/guides/sessions-commands-events.md`**: the result envelope now carries listener-issued command events; `source` stamping gets its worked example. The API reference picks up both new modules automatically. +- **`CHANGELOG.md`** `[Unreleased]`: Added — the trigger spec and pattern vocabulary, `Adventure.triggers`, the interpreter, `ConsequenceCommand`, the selectors, the validation checks, the three additive fields, `flag_values_equal`. Changed — command results now include events of commands issued by listeners. Fixed — sight persistence runs before listener dispatch, so a listener-relocated party's seen map replays exactly. + +### 9. Tests — `tests/test_triggers.py`, `tests/test_interpreter.py` (new), and the phase golden + +- **Models and validation** (`test_triggers.py`): pattern and spec round-trips on the discriminators; parse rejections — a lifecycle command, a player command, and an unknown type in `consequences`; `consumes=True` in `conditions`; an authored `source`; `validate_adventure` catching each dangling reference class (pattern area/level/dungeon/item/monster, condition item, consequence item/monster/door/location, literal `character_id`, duplicate and empty trigger ids) and accepting a clean document with every pattern kind; pre-phase documents and saves loading unchanged. +- **Matching units** (`test_interpreter.py`): each pattern kind against matching and non-matching events, the level-subsumes-dungeon pin, `FlagSetPattern`'s any-value and strict-value modes (`1` vs `True` both directions through `flag_values_equal`), magic-instance resolution on item acquisition, condition gating (a failing condition blocks a firing and leaves no mark), once-only vs `repeatable`, document-order and batch-order firing, evaluate-as-you-go (an earlier firing's `SetFlag` satisfying a later trigger's condition in the same batch). +- **Interpreter integration**: the lever-portcullis wiring end to end; selector expansion (`@party` to living members in marching order, `@first`, `@first` with nobody living dropping with its note, a dead member excluded); `source` stamps on every issued command; the drop-and-note on a colliding spawn; the truncation ladder — a flag-chain of repeatable triggers cascading to depth 5, the note recorded, the suppressed trigger unfired and provably fireable afterward; mark-before-consequences pinned by a once-only trigger whose consequence event would re-match it; the splice — a player command's result carrying the full cascade in event-log order; the reorder — a `PlaceParty` consequence teleporting the party, live seen-map equal to replay's. +- **Existing-surface guards**: the listener-contract tests in `test_session.py` extended for the splice (an emit-only listener unchanged; a command-issuing listener's events riding the result); the leak test extended — the serialized player view contains no `pattern_type`, `consequences`, or trigger-wiring keys after a triggered session; `test_public_surface.py` covering both new modules' `__all__` and the two new `commands.py` exports. +- **The golden** — `tests/generate_phase14_goldens.py`, `tests/goldens/phase14_triggers.json`, `tests/test_phase14_goldens.py`, on the phase 13 pattern. A gated portcullis (phase 11's `requires` on the door, refusal text authored), a lever flag set by a game-issued `SetFlag`, and the authored trigger — `FlagSetPattern` on the lever key, consequences `SetDoorState(open=True)`, a journal form, a fired beat: the scripted run probes the gate refusal, pulls the lever, watches the trigger mark-open-journal through the spliced result, walks through the open portcullis, and separately enters a keyed-encounter area whose area-entered trigger tries `SpawnMonsters` and drops with its note. Asserts: final `fired_triggers` and `journal` exact; every interpreter-issued command in the log stamped `source="trigger:..."`; replay of the accepted log with **no listeners** reaching identical final state on every block except `listener_state` — where the live side is asserted to be exactly `{"osrlib.interpreter": {}}`, the phase 5 modulo-`listener_state` precedent strengthened to pin holds-nothing — with command and event logs byte-equal; `load(save)` equal to `replay(seed, commands)` under the same comparison — the milestone, verbatim, both beats. +- The full gate green: `uv sync && uv run ruff format --check && uv run ruff check && uv run pyright && uv run pytest && uv run mkdocs build --strict`. + +## Sequencing + +1. Work items 2 and 1 (the consequence census and union; the pattern union and `TriggerSpec`) with the model tests, plus work item 7's extraction — the authoring vocabulary lands first, parseable and pure. +2. Work item 3 (`Adventure.triggers`, validation) with its tests — the document shape exists before anything reads it. +3. Work items 4 and 5 (the session seams; the additive fields) with their tests and the planned golden regenerations, one commit for the regens with the diff explained. +4. Work item 6 (the interpreter) with the matching units and integration tests. +5. Work items 8 and 9 remainder (docs sweep, the spec amendment, changelog, the phase golden; the full gate on both OSes). + +## Definition of done + +- `uv sync && uv run ruff format --check && uv run ruff check && uv run pyright && uv run pytest && uv run mkdocs build --strict` green on both OSes. +- The milestone runs in the phase golden: the lever-opens-portcullis scenario authored as data, live with the interpreter registered, replays identically with no listeners — command and event logs byte-equal, every state block equal except the interpreter's provably empty `listener_state` entry; the colliding trigger spawn drops and its note is in the log. +- No new commands, events, rejection codes, or message templates; no `SCHEMA_VERSION` bump and no migration; pre-phase documents and saves load unchanged; the only changes to existing goldens are the planned additive-key regenerations, explained in their commit message. +- The interpreter emits no listener-channel events, pinned by the golden's event-log byte-equality, and stores no listener state, pinned by the exact-empty-entry assertion; every command it issues carries its `source` stamp. +- `validate_adventure` catches every dangling trigger reference and the selector rule through the standard `ContentValidationError` gate with its exact existing signature; `validate_content_pack` is correct unchanged. +- The player view leaks no trigger wiring, pinned by the extended leak test; the fired beat rides referee-visibility events only, and the journal form is the player-facing voice, both pinned by test. +- The spec gains exactly the consequence-surface amendment; `docs/adaptations.md` gains nothing — both argued in this plan.