The lever opens the portcullis: triggers and the interpreter - #59
Merged
Conversation
… 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_stateentry — 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 unlessrepeatable, consequences in authored order, narrative block), and the@party/@firstselector constants.ConsequenceCommandincrawl/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.triggersplus thevalidate_adventuretrigger walk: unique ids, every pattern/condition/consequence reference resolved, and the selector rule — a literalcharacter_idin 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, andsource="trigger:{id}"on every issued command. It emits nothing and holds nothing — both pinned by the golden.CommandResultnow carries the events of commands its listeners executed (the splice), and_persist_sightruns before listener dispatch so a listener-relocated party's seen map replays exactly.LocationEnteredEvent.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.building-an-adventure.md, the interpreter inlisteners-and-flags.md, the widened envelope insessions-commands-events.md, and the spec's consequence-surface amendment (selectors and exclusions).Notable decisions
@party(living members, marching order) or@first(the treasure-recipient precedent), expanded at issue time.@firstwith nobody living drops with a note;@partyexpands to nothing.min_length=1makes 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 (
accumulatedfor dispatch,envelopefor 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-clevermodel_constructreplaced 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