Skip to content

The lever opens the portcullis: triggers and the interpreter - #59

Merged
mmacy merged 9 commits into
mainfrom
phase-14-impl
Aug 7, 2026
Merged

The lever opens the portcullis: triggers and the interpreter#59
mmacy merged 9 commits into
mainfrom
phase-14-impl

Conversation

@mmacy

@mmacy mmacy commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Implements the phase 14 plan: the trigger authoring vocabulary, authored triggers on the adventure document, and the library-shipped interpreter listener. The milestone runs in the phase golden: the lever-opens-portcullis scenario authored as data replays identically with no listeners — command and event logs byte-equal, every state block equal except the interpreter's provably empty listener_state entry — and a trigger spawn colliding with an open encounter drops and records its note.

What landed

  • crawl/triggers.py — seven frozen event patterns (area_entered, level_entered, dungeon_entered, town_entered, item_acquired, monster_defeated, flag_set), TriggerSpec (conditions AND at match time, once-only unless repeatable, consequences in authored order, narrative block), and the @party/@first selector constants.
  • ConsequenceCommand in crawl/commands.py — the typed sub-union of the nine referee commands a document may author, making parse-time enforcement free. Excluded with rationale: the lifecycle family (the interpreter's own vocabulary), IdentifyItem (session-scoped instance ids), RollDice (a draw nothing authored can read).
  • Adventure.triggers plus the validate_adventure trigger walk: unique ids, every pattern/condition/consequence reference resolved, and the selector rule — a literal character_id in a document is an error because session-scoped ids cannot exist when the document is read.
  • crawl/interpreter.py — matching in batch order then document order, mark-before-consequences, selector expansion at issue time (the log stays concrete and replays exactly), the depth-4 cascade bound with evaluate-then-note truncation at depth 5 (no mark, so a suppressed once-only trigger stays fireable), drop-and-note for rejected consequences, and source="trigger:{id}" on every issued command. It emits nothing and holds nothing — both pinned by the golden.
  • Session seam work — a command's CommandResult now carries the events of commands its listeners executed (the splice), and _persist_sight runs before listener dispatch so a listener-relocated party's seen map replays exactly.
  • Additive fieldsLocationEnteredEvent.dungeon_id (area entries carry the whole triple), MarkTriggerFired.narrative/TriggerFiredEvent.narrative (the fired beat, referee visibility). Five stored goldens regenerated, 30 inserted lines, zero deletions, zero changed values.
  • Docs — trigger authoring in building-an-adventure.md, the interpreter in listeners-and-flags.md, the widened envelope in sessions-commands-events.md, and the spec's consequence-surface amendment (selectors and exclusions).

Notable decisions

  • The fired beat rides referee visibility. Two spec paragraphs collide (display beats vs trigger-fired visibility); the visibility ruling is the more specific and wins. A trigger's player-facing voice is its journal form.
  • Selectors, not ids. Authored grants address @party (living members, marching order) or @first (the treasure-recipient precedent), expanded at issue time. @first with nobody living drops with a note; @party expands to nothing.
  • Depth is a save/restore counter, no re-entrancy latch — re-entrant self-invocation through the nested execute's listener loop is the cascade mechanism, and the depth bound is its guard.
  • Two plan amendments settled in implementation (both committed on the branch): the empty-trigger-id check moved to parse level (min_length=1 makes a validation check unreachable), and the consequence union spells out its nine members (pyright cannot read a variable in a type expression) with a census test pinning the two spellings equal.

Review provenance

Implemented and rubber-ducked by independent reviewers per the phase loop. The duck's first pass returned NEEDS REVISION with one blocking finding: the splice extended the listener dispatch input, not just the result envelope, so a listener registered after a command-issuing one received nested events twice — a repeatable trigger behind a relay listener fired twice on one flag write. The fix splits the two roles (accumulated for dispatch, envelope for the result); the listener contract — every listener sees every event exactly once — is now stated in the protocol docstring and pinned by two regression tests that were verified to fail against the old shape. Five non-blocking cleanups landed alongside: an unreachable rejection-code fallback removed, a census tripwire for the character-addressing consequence trio (phase 15's reward commands must keep it honest), fixture __all__ completeness, an over-clever model_construct replaced with honest construction, and a stale plan cross-reference swept. Re-review verdict: SOLID.

Gate

uv sync && uv run ruff format --check && uv run ruff check && uv run pyright && uv run pytest && uv run mkdocs build --strict — all green, 2087 passed / 65 skipped.

https://claude.ai/code/session_01Y5s9VU1WpLpmB5kfBA13Yc

mmacy added 9 commits August 6, 2026 19:59
… surface

A trigger is an authored binding from an observable event pattern to referee-command
consequences — edge-triggered where a gate is level-triggered — and this is the
vocabulary it is written in, parseable and pure, with nothing yet reading it.

`crawl/triggers.py` carries the seven patterns (`area_entered`, `level_entered`,
`dungeon_entered`, `town_entered`, `item_acquired`, `monster_defeated`, `flag_set`),
their discriminated union, and `TriggerSpec` — once-only by default, conditions ANDed
in a tuple, consequences in authored order, an optional narrative block. Two model
validators keep the document honest: a condition may not consume (a trigger reacts to
what has already happened and has no attempt of its own to charge against), and a
consequence may not carry `source` (the stamp belongs to whoever issues the command).
The party selectors `@party` and `@first` live here with their authoring contract.

`ConsequenceCommand` in `commands.py` is the sub-union that makes both enforcement and
documentation free: typing the field with it rejects a lifecycle command, a player
command, or an unknown type at parse, with no validator behind it.
`CONSEQUENCE_COMMAND_CLASSES` is the census beside it, and the exclusions are argued in
its docstring — the lifecycle family is the interpreter's own vocabulary, `IdentifyItem`
speaks session-scoped instance ids, and `RollDice` is a draw no authored construct reads.

`flag_values_equal` extracts out of `condition_holds` into the public gate surface, so
the flag condition and the flag pattern can never disagree about what equality means.

Claude-Session: https://claude.ai/code/session_01Y5s9VU1WpLpmB5kfBA13Yc
`Adventure.triggers` is the authored tuple, and its order is document order — the
ordering matching relies on. The default is empty, so every pre-phase document and
save parses unchanged and no `schema_version` moves.

`validate_adventure` grows the walk that makes a trigger's promises checkable before
play: ids unique across the adventure; the pattern's area, level, dungeon, item, and
monster references resolving against the same effective catalogs everything else
resolves against; `has_item` conditions through the item domain the gate check already
used, now extracted so the gate site and the bare-condition site can never disagree;
and per consequence, granted item ids, spawned template ids, a door edge actually at
the cell a door write names, a placement landing on the grid, and the selector rule —
a session allocates character ids, so a document naming one names something that
cannot exist when it is read.

Flag keys stay unchecked everywhere: the namespace is open by design, and a key
nobody writes is an authoring lint rather than a broken document.

Claude-Session: https://claude.ai/code/session_01Y5s9VU1WpLpmB5kfBA13Yc
…heir dungeon

Two seams harden before authored content can travel through them, and three fields
land that the matching needs.

The listener loop splices: `execute` records the event log's length before each
`handle` call and folds everything logged during it into the result. A listener that
reacts by executing commands has always logged its cascade correctly and reported
none of it back to the caller — a front end executing `MoveParty` would render
nothing of the portcullis opening. Now the envelope carries the whole chain in
event-log order, each event once, for any command-issuing listener.

`_persist_sight` moves above the listener loop, so the map a live session remembers
is the map a replay rebuilds. A listener that relocates the party executes a command
that folds its own destination in turn; folding afterwards instead would fold the
destination's view over the move the party actually made, where a replay — same
commands, no listeners — folds both in order.

`LocationEnteredEvent.dungeon_id` rides area entries, where it is the missing fact:
area ids are level-scoped, and matching must read an event's own facts rather than
the party's current position, which a consequence can move mid-batch.
`MarkTriggerFired.narrative` and `TriggerFiredEvent.narrative` carry the firing's
authored beat at referee visibility, where the existing formatter hook appends it —
trigger wiring is the game's secret, and the players' voice is a journal entry.

The stored goldens whose event logs carry either shape now differ by the added null
keys; they regenerate in the commit that follows.

Claude-Session: https://claude.ai/code/session_01Y5s9VU1WpLpmB5kfBA13Yc
Mechanical, and additive only: 30 inserted lines across five files, no deletions and
no changed values.

`phase4_delve`, `phase5_milestone`, `phase11_gates`, `phase12_wipe`, and
`phase13_journal` all carry location-entered events, which now serialize a
`dungeon_id` — the dungeon id itself on the area entries that were missing it, and
null on the level, dungeon, and town entries that name themselves. `phase13_journal`
additionally gains the `narrative` key on its two marks and the two events they
emitted, both null: the golden's lever writes no beat.

Every draw, every value, and every ordering is unchanged, which is the point: these
are new keys on existing shapes, not new behavior.

Claude-Session: https://claude.ai/code/session_01Y5s9VU1WpLpmB5kfBA13Yc
`Interpreter` is an ordinary listener a game registers on its session. It walks each
command's events in order and, per event, the adventure's triggers in document order;
a match fires immediately, so a later trigger's conditions see what an earlier firing
already changed. A firing marks first — which is what makes once-only safe against a
consequence that would re-match its own trigger — then runs the consequences in
authored order, then journals if the trigger's narrative carries a journal form.
Everything it issues is stamped `source="trigger:{id}"` through `model_copy`; the
authored consequence in the document is never touched.

Selectors resolve at issue time, so the log stays concrete: `@party` becomes one
command per living member in marching order, `@first` the lead survivor. `@party` with
nobody standing expands to nothing — a reward for the dead is nothing, not a problem —
while `@first` drops with a note, because there is no recipient to guess at.

Nothing that fails stops the run. A rejected consequence is dropped alone and recorded
with the trigger, the consequence's position and type, and the rejection code; the
consequences after it still land. Cascades are bounded at depth four: at five the
interpreter still evaluates every would-be firing in full and records each genuine
suppression as a note rather than a mark, so a once-only trigger cut short there stays
fireable later. There is no re-entrancy latch, because re-entrant self-invocation is
the cascade.

And it emits nothing and holds nothing: `handle` returns `([], {})` always, all durable
state is the session's, and a replay with no listeners rebuilds the same world from the
same log.

Claude-Session: https://claude.ai/code/session_01Y5s9VU1WpLpmB5kfBA13Yc
Work item 3 asked validation to check that trigger ids are non-empty and unique.
`TriggerSpec.id` carries `min_length=1`, so an empty id is a parse rejection and never
reaches validation; a check for it in `validate_adventure` would be unreachable code.
The plan now says uniqueness alone, with the model test carrying the empty case.

Work item 2 asked for the consequence union "over" the census tuple, which reads as
`Union[*CONSEQUENCE_COMMAND_CLASSES]` — the shape `AnyCommand` uses. That shape only
works where the union is a runtime value; this one annotates `TriggerSpec.consequences`,
and a type checker cannot read a variable in a type expression. The union spells its
nine members out, and a census test keeps the two spellings identical.

Claude-Session: https://claude.ai/code/session_01Y5s9VU1WpLpmB5kfBA13Yc
The phase golden runs the scenario the phase exists for. A keep whose portcullis wants
a crank nobody has refuses the party's probe with its authored text and costs nothing;
a game-issued `SetFlag` pulls the lever, and the trigger watching that key marks
itself, opens the grille, and writes the journal beat — all inside the result of the
player's own command; the party walks through; and stepping into the guardroom opens
the room's own encounter, so the ambush trigger's spawn arrives to find one already
open and is dropped alone, with a note naming the trigger, the consequence, and the
rejection code, while its journal beat still lands.

The same log replayed with no listeners at all reaches the same world: command and
event logs byte-equal, every state block equal but the listener store, where the live
side is asserted to be exactly `{"osrlib.interpreter": {}}` — holds-nothing pinned
rather than carved out — and `load(save)` equals `replay(seed, commands)` under the
same comparison.

The fuzz gains a keep of its own: whatever a random command sequence does to a session
with the interpreter registered, nothing raises, the interpreter's slot stays empty,
and the player view shows the journal beats and none of the wiring behind them.

The spec gains the one amendment this phase's design earned: the selector convention
and the consequence surface's three exclusions, both of which define what a valid
adventure document is and neither of which an author would predict from the referee
command vocabulary alone. The authoring guide gains the trigger section — the pattern
vocabulary, conditions, selectors, the cascade bound, the drop-and-note rule, and the
plain statement that `fired` is the referee's line and `journal` is the players'. The
listener guide gains the interpreter as the worked reference for a command-issuing
listener, and the session guide gains the widened result envelope.

Claude-Session: https://claude.ai/code/session_01Y5s9VU1WpLpmB5kfBA13Yc
`_issue` returns the result of the command it executed, and `_fire` reads its
acceptance; the type says so now.

Claude-Session: https://claude.ai/code/session_01Y5s9VU1WpLpmB5kfBA13Yc
The blocking one first: the splice was delivering nested events twice. `accumulated`
was doing two jobs — the dispatch input handed to each listener, and the envelope
handed back to the caller — so folding a listener's nested-command events into it
also re-delivered them to every listener registered after that one. Those listeners
had already seen the events at the nested level, because a nested `execute` runs the
whole listener loop itself; a repeatable trigger behind a relay listener fired twice
on one flag write. The two jobs are now two lists: `accumulated` takes only the
command's own events plus what earlier listeners authored, `envelope` additionally
takes the log slice, and the result carries `envelope`. Every listener sees every
event exactly once; the caller still gets the whole chain. Two regression tests pin
it — a counting listener behind a relay, and the repeatable trigger that fired twice
— and both fail against the old shape.

Five cleanups alongside it:

- The interpreter's drop-note no longer guards `result.rejections[0]` with an
  `else "unknown"`: both rejection paths in `execute` always carry a non-empty tuple,
  so the branch was unreachable.
- A census test names the three consequence classes that address a character. The
  interpreter's selector expansion and the document validator each enumerate that
  trio by hand, and a fourth class carrying `character_id` would have slipped past
  both; the test says which two sites must grow.
- The lever keep's fixture constants join `crawl_fixtures.__all__` beside the crank
  that was already there.
- The literal-character-id test builds its trigger normally. `TriggerSpec` never
  rejected a literal id — `validate_adventure` does — so `model_construct` was
  claiming a parse-level rule that does not exist; the post-construction adventure
  swap is the real bypass and stays.
- The plan's test list still promised validation coverage of empty trigger ids, which
  the earlier amendment moved to a parse-level model test. Swept to match.

Claude-Session: https://claude.ai/code/session_01Y5s9VU1WpLpmB5kfBA13Yc
@mmacy
mmacy merged commit b5eb811 into main Aug 7, 2026
5 checks passed
@mmacy
mmacy deleted the phase-14-impl branch August 7, 2026 03:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant