diff --git a/docs/phase-15-plan.md b/docs/phase-15-plan.md new file mode 100644 index 0000000..172e263 --- /dev/null +++ b/docs/phase-15-plan.md @@ -0,0 +1,139 @@ +# Phase 15 plan — quests, victory, and the authored example + +Implementation plan for phase 15 of [the osrlib spec](spec.md): the quest spec, the quest lifecycle commands with their state and player-visible events, adventure completion into victory, active quests in the player view, the per-level ambient guidance slot, and quest/objective reference validation; the example TUI crawler registers the interpreter, authors its fetch quest as adventure data, and deletes its hand-rolled listener. This is the last link of the dependency chain 10 → 11 → 14 → 15, and it consumes every prior link on purpose: phase 10's bundled items give the idol a catalog id, phase 11's conditions and narrative blocks carry the wiring and the words, phase 12's `VICTORY` mode is the terminal state this phase finally enters, phase 13's journal and `source` stamp are the record, and phase 14's patterns, consequence surface, selectors, and interpreter are the machinery quests compose — reused unchanged, exactly as the phase 14 plan promised. The milestone: **the crawler's fetch quest — activation, objective completion matched on the idol's catalog id, reward, journal beats, victory — runs end to end as authored data and replays clean.** + +Five facts shape the design: + +- **A quest is trigger machinery composed, and the interpreter stays the only actor.** The quest spec introduces no new matching vocabulary: an activation, an objective's completion, and a hidden objective's reveal are each a `TriggerClause` — a phase 14 `TriggerPattern` plus phase 11 `ConditionSpec`s, evaluated with the same edge-triggered pattern matching and live condition evaluation the interpreter already performs — and rewards are the existing `ConsequenceCommand` union under the existing selector convention (`@party`, `@first`), expanded at issue time so the log stays concrete. Quest state (per-quest status, per-objective revealed/complete) is engine session state beside `fired_triggers` and the journal, mutated only by the four lifecycle commands the interpreter issues, so a replay — no listeners registered — rebuilds it by re-executing the log, and the interpreter keeps its emits-nothing, holds-nothing posture (`handle` returns `([], {})`, the listener slot stays `{}` forever). The one wrinkle the spec forces is the quest with no activation clause, which "is active from session start": there is no command channel at session start, so `GameSession.__init__` seeds the quest-state block from `adventure.quests` — every quest present, activation-less quests `active`, the rest `inactive`, objectives revealed unless authored hidden. Seeding at construction is deterministic and shared by every path that builds a session (`new`, `load_game`, replay), so live, loaded, and replayed sessions agree by construction; a from-start quest emits no activation event and journals no offer beat — its offer stands in the player view from round 0 — which is a spec-altitude consequence recorded as an amendment (work item 11). `TriggerSpec` itself is not restructured around `TriggerClause`: its flat `when`/`conditions` shape is serialized inside every saved adventure, and rehoming those fields inside a clause object would be a wire rename — a `schema_version` bump — bought for nothing but symmetry. The clause model is quests-only, and its no-consumes validator repeats `TriggerSpec`'s two-line check as the cost of wire stability: a quest observes, it does not take. +- **Quest and objective ids are a closed domain — the deliberate opposite of the `MarkTriggerFired` pin — because the view must render what the log only references.** Phase 14 left `MarkTriggerFired.trigger_id` open on the record: a mark is pure bookkeeping, games drive it with ids from their own systems, and the interpreter stamps the narrative onto the command because no spec need exist. The quest commands invert every one of those premises: `ActivateQuest`, `RevealObjective`, `CompleteObjective`, and `CompleteQuest` advance an engine-owned state machine that the player view projects with names, offer text, and objective lists — an id with no spec behind it has no name to show and no narrative to carry — and a game running its own quest system needs none of them, because listener state plus flags already carry it (the phase 5 proof, which the listeners guide keeps teaching). So the handlers resolve everything from the spec: they reject an id `Adventure.quests` doesn't hold (`session.command.unknown_quest`, `session.command.unknown_objective`), reject a command that contradicts the current state (`session.command.quest_state`, params naming the quest and the state that refused it: activating a non-inactive quest, revealing a visible or completed objective, completing a completed objective, completing a non-active quest), and stamp the events' narrative from the spec's blocks rather than from command fields — no `narrative` field on any of the four, which is also what the roadmap's "quest and objective ids in lifecycle references" validation is: it lives at the executed seam, because no document ever authors a lifecycle command for `validate_adventure` to check. Rejections are excluded from the command log, so a replay only ever sees the accepted, state-consistent sequence and the guards can never fire under it. One deliberate asymmetry within the guards: `CompleteQuest` requires the quest active but does *not* require its completion rule satisfied — referee fiat is the command surface's standing posture (an LLM referee may rule the quest done), and the interpreter is simply a disciplined issuer that checks the rule before issuing. +- **The journal records each quest beat's display text, and the carrier mapping is a pinned interpretation.** Two collisions resolve together here. First, the spec's own sentence assigns the journal appends to the lifecycle commands ("`ActivateQuest`, … advance quest state, emit the player-visible events carrying the authored narrative, and append journal entries; `AddJournalEntry` gives one-off triggers a journal voice") — so the handlers append, one command one whole beat, and the interpreter never issues `AddJournalEntry` for a quest; the phase 14 trigger order (mark, consequences, journal last) is undisturbed because it belongs to triggers. The append emits no `JournalEntryAddedEvent` — the quest lifecycle event *is* the beat's event, and emitting both would report one beat twice in the same player-visible stream (the TUI's delta loop would print the same line back to back); the journal-growth event surface is therefore the union of `JournalEntryAddedEvent` and the quest lifecycle events, stated in that event's docstring and the views guide, and a mid-session client still recovers the whole journal from the view. Second, `NarrativeBlock` has one `journal` field while the spec names four quest journal moments (activation, reveal, objective completion, quest completion), so no per-beat journal-form mapping is total. The resolution: a quest beat's journal entry *is* its display text, appended verbatim when non-empty — the journal is the transcript of what the table was shown, which is exactly what "keeps beats whose source state has since changed" wants — and the separately authored `journal` form is the voice of carriers whose display beat the player never sees (a trigger's `fired`, referee-visibility by phase 14's pin). Quest-layer blocks leave `journal` unread the same way gates leave `fired` unread — the shared block's standing convention. The beat-to-field mapping deliberately *reassigns* one carrier from the shipped `narrative.py` docstring, which reads "`offer`, `progress`, `completion` — a quest, at activation, at an objective's completion, and at the quest's own" — putting `progress` on the quest's block. Per-objective beats need a per-objective carrier (that is why the spec gives objectives narrative blocks at all), so the pinned mapping is: quest activation displays and journals the quest block's `offer`; quest completion, its `completion`; an objective's reveal, the objective block's `offer` (the objective presenting itself); an objective's completion, the objective block's `progress` (the story advancing). The quest block's `progress` and an objective block's `completion` are unread in this mapping, stated in the docstring rather than rejected at parse — silent-unread is how every carrier already treats the fields it doesn't speak. The `narrative.py` carrier table is rewritten to say all of this (work item 11), and the spec's journal paragraph gains the display-text sentence. +- **Victory is entered in exactly one place, terminal modes stay sticky, and rewards land after the transition because they are separate logged commands.** `CompleteQuest` on a quest whose spec carries the concluding marker, executed in a non-terminal mode, clears any open `encounter` and `battle` (the same "a concluded session holds no live play state" rule `_end_on_party_wipe` applies), sets `SessionMode.VICTORY`, and emits `QuestCompletedEvent` then `AdventureCompletedEvent`, both player-visible, both carrying the quest's completion beat. In a terminal mode the same command still advances quest state — the record shows the fallen party finished the job — but transitions nothing and emits no adventure-completed event: phase 12's rule that terminal modes never transition again, now pinned from the victory side's entrance. Rewards are not the handler's business (a handler that executed further commands would double-log — the `award_adventure_xp` precedent): the interpreter issues them *after* `CompleteQuest` returns, in authored order, each stamped `source="quest:{id}"`, selectors expanded to living members, before any subsequent player command because the whole batch runs inside the listener loop. That ordering is what the spec's "which is also what lets rewards land after the transition" describes, and it has a sharp edge worth naming as author guidance rather than validation: a reward that would resume play — `SpawnMonsters`, `SpawnNpcParty`, `PlaceParty` — is illegal in `victory` by phase 12's carve-outs, so on a concluding quest it drops with the interpreter's standard rejection note. The phase golden makes that drop one of its beats. A referee-driven `CompleteQuest` with no interpreter registered grants nothing, documented plainly: rewards are the interpreter reading the spec, and a replay re-executes the logged reward commands themselves. +- **The example's idol moves into the catalog because the milestone matches a catalog id — and the economy retunes around pay-on-delivery, which restructures the milestone script.** The Jade Idol is today a `ValuableSpec` in the shrine cache, invisible to `ItemAcquiredPattern` (valuables are deliberately id-less; the spec: "a MacGuffin is an authored item in a cache, not a valuable"). It becomes a phase 10 bundled `GearTemplate` (`id="jade-idol"`) carried by `Adventure.items` and placed in the cache by `item_ids`, so taking it emits the catalog id the recovery objective matches, and the return objective composes `TownEnteredPattern` with a `has_item("jade-idol")` condition — the walked-in-carrying-it test, straight from the shipped vocabulary. The authored quest activates on `DungeonEnteredPattern("barrow")` (exercising the full activation surface in the milestone run; the temple's charge journals at the threshold), completes on the all rule, concludes the adventure into victory, and pays on delivery: `GrantCoins` to `@first`, `AwardXP` to `@party`, and `SetFlag("quest.idol", "recovered")` — the flag earning its place on its own merits: it demonstrates a reward writing the flag store that downstream gates and triggers read (the cross-system wiring the reward surface exists for), and the closing print is its live consumer. The victory transition forces the script's shape: the concluding completion fires inside the return's listener loop, so the session is in `victory` the moment `town` lands and the town-only play commands (`sell`, `heal`) are `wrong_mode` after it. The milestone script therefore makes two town trips — delve the lair hoard and the rivals' loot, return *without* the idol (the return objective's `has_item` fails, no completion; sell, heal, and the first adventure award land here), re-enter, take the idol, and return to the completion, the rewards, and victory closing the transcript. The economics shift and the plan owns it: the idol's 2,200 gp no longer rides the valuation delta (mundane gear is worth zero XP by RAW) and the 200 gp reward now lands in town where it earns no treasure XP — the old listener granted it mid-dungeon precisely to catch the delta — so the authored `AwardXP` amount is retuned at implementation until the milestone's level-2 beat holds in the restructured deterministic run; the mechanism is pinned here, the constant is the run's to settle. Deleting `quest.py` ripples exactly four places beyond the TUI: `generate_phase5_goldens.py` imports the example's content and listener, so `phase5_milestone.json` regenerates — the one existing golden this phase touches, its diff the example's redesign, explained in the commit; `examples/fastapi_crawler/content.py` registers the listener on both its create and restore paths and switches to `Interpreter`; `docs/guides/listeners-and-flags.md` includes `quest.py` by snippet marker, so the guide rewrites its quest-tracker section around a self-contained inline listener (the extension-surface proof stays taught) with the interpreter presented as the shipped instance of the pattern; and `docs/front-ends/tui-crawler.md` includes `quest.py`, the registration marker, and the shrine snippet, so its fetch-quest section rewrites to the authored-data story (work item 11). + +## Scope + +In scope: + +- `TriggerClause`, `ObjectiveSpec`, and `QuestSpec` in the new `crawl/quests.py` +- `ActivateQuest`, `RevealObjective`, `CompleteObjective`, `CompleteQuest` in `crawl/commands.py`, with handlers, state models, and construction seeding in `crawl/session.py` +- `QuestActivatedEvent`, `ObjectiveRevealedEvent`, `ObjectiveCompletedEvent`, `QuestCompletedEvent`, `AdventureCompletedEvent` in `crawl/events.py`, registered and templated +- The victory transition (and its terminal-stickiness rule) inside the `CompleteQuest` handler +- `Adventure.quests` with a `quest()` accessor, and the `validate_adventure` quest walk over shared clause/consequence helpers +- The interpreter's quest processing: activation, reveals, completions, the completion-rule check, reward issuance with selector expansion, `source="quest:{id}"` stamps, drop notes, and the depth bound +- The `quests` session-state block in `persistence.py`; `PlayerView.quests` with `QuestView`/`ObjectiveView` +- `LevelSpec.guidance` — the per-level ambient guidance slot +- The example rework: the bundled idol, the authored quest, `quest.py` deleted, the TUI and FastAPI examples registering `Interpreter`, the milestone script and its assertions updated +- The phase golden (`tests/goldens/phase15_quest.json`), `tests/test_quests.py`, the `test_interpreter.py` extensions, the planned `phase5_milestone.json` regeneration, docs, the spec amendments, and the changelog +- The closing deliverable: the osr-forge issue (`mmacy/osr-forge`) recording that its overrides schema has no quest surface, filed when the implementation PR lands + +Out of scope (deferred to the phase or track that picks each up, or excluded by spec decision): + +- **Post-victory play, quest accept/decline, NPC entities and dialogue, branching narrative, procedural quests, campaign continuity** — out of scope by spec decision; the resume-play carve-outs phase 12 shipped are what keep the victory door shut so a post-victory mode can arrive additively later. +- **Quest content in content packs** — spec decision; packs carry no quests and `validate_content_pack` is correct unchanged. +- **Repeatable quests or objectives** — the state machine is monotonic (inactive → active → completed; hidden → revealed; incomplete → complete) because the spec authors no repeat vocabulary for quests; `repeatable` stays a trigger concept. +- **Per-objective rewards** — rewards are quest-level by spec ("issued immediately on completion" of the quest). An author who wants a grant at an objective's moment writes an ordinary trigger beside the quest watching the same pattern — the composition surface exists precisely so the quest model doesn't grow a second reward site. +- **Quest events as trigger observables** — no `TriggerPattern` variant matches the new quest events; one arrives additively when a consumer demonstrates the need, the condition-union precedent. Cross-quest wiring (one quest's completion activating another) composes today through a flag reward and a flag-set activation clause. +- **Consuming conditions in quest clauses** — rejected at parse, the `TriggerSpec` rule verbatim: a quest observes an event that already happened and has no success seam to charge a toll against. The idol stays in the pack after the temple pays; prose, not mechanics, hands it over. +- **An objective narrative field for the player view** — the view ships visible objectives as ids and states, the spec's exact enumeration; a mid-session client recovers objective prose from the journal, where the reveal and completion beats were written. A view field would be additive later if a front end demonstrates the need. +- **A `Ruleset` flag and an adaptations entry** — quests, victory, and journals have no SRD basis; every pin here is a spec-design decision and the register's silence is the phase 11–14 precedent. +- **An osr-forge quest surface** — the closing deliverable is the issue recording its absence; quest authoring stays native-project-only until osr-forge grows one in its own repo against this settled schema. + +## Work items + +### 1. The quest spec — `crawl/quests.py` (new) + +- `TriggerClause`, frozen: `pattern: TriggerPattern`, `conditions: tuple[ConditionSpec, ...] = ()` — all must hold, evaluated live at match time, the `TriggerSpec` AND. A model validator rejects `consumes=True` with the trigger rationale. The field is `pattern`, not `when`, so an objective's clause reads `objective.when.pattern` rather than `when.when`. +- `ObjectiveSpec`, frozen: `id: str` (min length 1), `when: TriggerClause` (the completion clause), `hidden: bool = False`, `reveal_when: TriggerClause | None = None`, `narrative: NarrativeBlock | None = None`. A validator rejects `reveal_when` on a non-hidden objective — a reveal clause for an objective that starts visible is authored dead weight. A hidden objective with no reveal clause is legal: it surfaces when it completes, the spec's own "or the objective completes". +- `QuestSpec`, frozen: `id: str` (min length 1), `name: str` (min length 1), `activation: TriggerClause | None = None` (absent means active from session start), `objectives: tuple[ObjectiveSpec, ...]` (min length 1 — an objective-less quest under the all rule would be born complete, a parse-time absurdity rather than a runtime surprise), `rewards: tuple[ConsequenceCommand, ...] = ()`, `completion: Literal["all", "any"] = "all"`, `concludes_adventure: bool = False`, `narrative: NarrativeBlock | None = None`. Validators: objective ids unique within the quest (objective ids are quest-scoped; two quests may both name an objective `"return"`), and rewards carry no authored `source` — the `TriggerSpec` rule, same rationale: the interpreter stamps. +- The module docstring orients: a quest composes the trigger vocabulary — activation, objectives, reveals are clauses over the same patterns and conditions triggers use; rewards are the same consequence surface under the same selectors; document order is the `Adventure.quests` tuple order. Imports: pydantic, `crawl.commands`, `crawl.gates`, `crawl.narrative`, `crawl.triggers` — a sibling of `triggers.py`, no session, no cycle (`adventure.py` imports this module). + +### 2. The four lifecycle commands — `crawl/commands.py` + +- `ActivateQuest` (`activate_quest`; `quest_id`), `RevealObjective` (`reveal_objective`; `quest_id`, `objective_id`), `CompleteObjective` (`complete_objective`; `quest_id`, `objective_id`), `CompleteQuest` (`complete_quest`; `quest_id`) — all string fields min length 1, all referee commands on the inherited `_ALL_MODES` legality (quest bookkeeping resumes no play; `CompleteQuest`'s transition *ends* play, which terminal stickiness keeps one-way). Appended at the tail of `ALL_COMMAND_CLASSES` with `__all__` entries. Three-section docstrings state the closed domain (ids resolve against the adventure's quest specs; contrast `MarkTriggerFired`'s open domain, cross-referenced both ways), the state guards, the journal appends, referee fiat on `CompleteQuest`, and the victory transition with its stickiness rule. +- Rejection codes, registered in `tools/docs/rejection_codes.json`: `session.command.unknown_quest`, `session.command.unknown_objective`, `session.command.quest_state`. +- `CONSEQUENCE_COMMAND_CLASSES` is untouched; its docstring's lifecycle-family sentence widens from three commands to seven — the quest four are the interpreter's own vocabulary exactly as the trigger three are, and typing `QuestSpec.rewards` with `ConsequenceCommand` makes the exclusion parse-time-free, the phase 14 mechanism. The census test's exclusion list updates to match. + +### 3. The five events — `crawl/events.py`, `messages.py` + +- All player-visible, all in the `session.` namespace, appended at the `CRAWL_EVENT_CLASSES` tail: `QuestActivatedEvent` (`session.quest.activated`; `quest_id`, `name`, `narrative: str | None`), `ObjectiveRevealedEvent` (`session.quest.objective_revealed`; `quest_id`, `objective_id`, `narrative`), `ObjectiveCompletedEvent` (`session.quest.objective_completed`; `quest_id`, `objective_id`, `narrative`), `QuestCompletedEvent` (`session.quest.completed`; `quest_id`, `name`, `narrative`), `AdventureCompletedEvent` (`session.adventure.completed`; `quest_id`, `narrative`). +- `narrative` on each is the beat from the mapping in the facts (offer, objective offer, objective progress, completion, completion), `None` when unauthored — content data in a structured field, the standing carve-out, appended verbatim by the existing formatter hook. Player visibility is the spec's ruling: quest lifecycle and adventure-completed events are the table's news; the wiring that fired them stays in the referee-visibility trigger and flag events. +- `messages.py` gains five templates (exact strings settled in implementation; the narrative hook does the authored talking), and the message-codes reference regenerates from the registry. + +### 4. Session state, seeding, and handlers — `crawl/session.py` + +- `ObjectiveState` (mutable, `validate_assignment`): `revealed: bool`, `complete: bool`. `QuestState` (same): `status: Literal["inactive", "active", "completed"]`, `objectives: dict[str, ObjectiveState]` — keyed by objective id, insertion order the spec's authored order, so every iteration anywhere downstream is deterministic. Both join `__all__` beside `JournalEntry` with docstrings stating the monotonic machine. +- `GameSession.__init__` seeds `self.quests: dict[str, QuestState]` from `adventure.quests` in document order — the from-start rule from the facts — so `new`, `load_game`, and replay all begin from the same block, and an adventure that authors nothing seeds `{}` at zero cost. +- Handlers at the module bottom, registered in `_REFEREE_HANDLERS`, each pure bookkeeping (no draws, no clock, no interaction with the wipe check): `_handle_activate_quest` (guards, then status `active`, journal the offer beat when non-empty, emit), `_handle_reveal_objective` (guards — quest active, objective hidden and unrevealed and incomplete — then revealed, journal the objective offer, emit), `_handle_complete_objective` (guards — quest active, objective incomplete — then complete *and* revealed (completing surfaces a hidden objective, no separate reveal), journal the objective progress beat, emit), `_handle_complete_quest` (guards — quest active — then status `completed`, journal the completion beat, emit `QuestCompletedEvent`; when the spec concludes the adventure and the mode is not terminal: clear `encounter` and `battle`, set `SessionMode.VICTORY`, append `AdventureCompletedEvent`). Journal appends go through the same `JournalEntry` construction `_handle_add_journal_entry` uses, stamped with the live clock. +- `Adventure` gains the `quest(quest_id)` accessor beside `dungeon()`; the handlers resolve specs through it. + +### 5. Persistence — `persistence.py` + +- `session_state` gains `"quests": {id: state.model_dump(mode="json")}`; `load_game` restores it when the payload carries the key (`QuestState.model_validate` per entry, the `deprivation` pattern) and otherwise keeps the constructor's seed — which is exactly right for every pre-phase save, whose adventure authors no quests and whose seed is `{}`. No `SCHEMA_VERSION` bump, no migration: a new payload key with a derivable default is the additive case, and the referee view carries the block for free. The module docstring's save-contents enumeration gains the block. + +### 6. The interpreter's quest processing — `crawl/interpreter.py` + +- The constructor caches `session.adventure.quests` beside the triggers. Per event, after that event's triggers (pinned order: triggers in document order, then quests in document order — one rule, stated in the docstring), the interpreter walks each quest: an inactive quest whose activation clause matches fires `ActivateQuest`; an active quest's objectives walk in authored order — a hidden, unrevealed, incomplete objective whose `reveal_when` matches fires `RevealObjective`; an incomplete objective whose `when` matches fires `CompleteObjective` — and immediately after a completion lands, the completion rule is checked against live state (all or any) and, when satisfied, `CompleteQuest` fires followed by the rewards in authored order. Evaluate-as-you-go throughout, the phase 14 pin extended: an activation earlier in the walk lets the same event complete an objective of the quest it just activated, and an earlier firing's `SetFlag` satisfies a later clause's condition in the same batch. Clause matching reuses `_matches` and `condition_holds` verbatim — one matching semantics for triggers and quests, by construction. +- Every issued command — lifecycle, rewards, notes — is stamped `source="quest:{quest_id}"` (the phase 13 golden's quest-shaped stamp made real). Rewards expand selectors through the same `_expand` path trigger consequences use; a rejected reward drops alone and records a `RecordNote` naming the quest, the reward's position and type, and the rejection code — the trigger note's shape with `quest` in place of `trigger`. +- Fiat cuts both ways, documented in the interpreter and command docstrings: the interpreter checks the completion rule only after a completion *it issued*, and no pattern matches the quest events, so a referee who completes the final objective by hand also completes the quest by hand — the same boundary as the no-interpreter `CompleteQuest` granting no rewards. +- Depth: quest issuance runs under the same save/restore counter, one deeper than the event that fired it; past the bound the interpreter evaluates in full and records a truncation note per suppressed advancement instead of issuing — no state moves, and because clauses are edge-triggered the suppressed edge is gone (a suppressed activation waits for its pattern to match again), stated plainly in the docstring as the same semantics triggers have. +- An event never matches events of its own kind into a loop by construction — no pattern matches the quest events — but reward consequences cascade into triggers exactly as trigger consequences do, through the nested execute's listener loop, bounded as before. + +### 7. Adventure and validation — `crawl/adventure.py` + +- `Adventure.quests: tuple[QuestSpec, ...] = ()` — document order is tuple order. Pre-phase documents parse unchanged. +- `validate_adventure` grows the quest walk, error lines in the house shape (`"quest {id}: ..."`, `"quest {id} objective {oid}: ..."`): quest ids unique across the adventure (trigger and quest ids are separate namespaces — separate stores, distinct `source` prefixes — so no cross-check); per clause (activation, each objective's `when` and `reveal_when`), the pattern's area/level/dungeon/item/monster references and the conditions' item ids resolve; per reward, the same reference and selector checks consequences get. The mechanics land by refactoring `_validate_trigger`'s body into two shared helpers — `_validate_clause(pattern, conditions, owner, ...)` and `_validate_consequence(consequence, owner, ...)` — that triggers and quests both call, so the two surfaces can never drift; the trigger walk's behavior and messages are unchanged. + +### 8. The player view — `crawl/views.py` + +- `ObjectiveView`, frozen: `id: str`, `state: str` (`"incomplete"` or `"complete"`). `QuestView`, frozen: `id`, `name`, `narrative: str` (the offer beat, empty when unauthored), `speaker: str` (the block's attribution — a wire client has no adventure document to resolve it from, and the block's own contract says a renderer may prefix it; the spec's view enumeration gains it by amendment, work item 11), `objectives: tuple[ObjectiveView, ...]` — revealed objectives only, authored order. `PlayerView.quests: tuple[QuestView, ...]` — quests with status `active` only, document order (completed quests leave the list; their record is the journal). +- The leak pins, extended in the whitelist test: the serialized player view contains no clause or pattern keys, no reward contents, no `guidance`, no hidden objective's id, and no quest whose status is `inactive` — activation wiring is the game's secret exactly as trigger wiring is. + +### 9. The ambient guidance slot — `crawl/dungeon.py` + +- `LevelSpec.guidance: str = ""` — the spec's one per-level slot: LLM steering that attaches to no mechanical object, applying while the party occupies the level, trusted as content like description prose. It is inert authored data: no engine read, no event, reachable by a narrating front end through the adventure document (referee-side by construction — the player view ships no level internals). The docstring says exactly that, and the authoring guide picks it up. + +### 10. The example rework — `examples/` + +- `content.py`: the idol becomes `GearTemplate(id="jade-idol", name=IDOL_NAME, ...)` bundled through `Adventure.items`, and the shrine cache carries it by `item_ids` beside its 50 gp (the `ValuableSpec` and `IDOL_VALUE_GP` go); the authored quest lands as `Adventure.quests` — activation on `DungeonEnteredPattern("barrow")`, objectives `recover-idol` (`ItemAcquiredPattern("jade-idol")`) and `return-home` (`TownEnteredPattern` + `has_item("jade-idol")`), completion `all`, `concludes_adventure=True`, rewards `GrantCoins(@first)`, `AwardXP(@party)`, `SetFlag("quest.idol", "recovered")`, narrative blocks carrying the offer, per-objective beats, and the completion line. The reward constants retune per the facts: the XP amount is settled against the deterministic milestone run so the level-2 beat holds. +- `quest.py` is deleted — no shim, no re-export; `__main__.py` registers `Interpreter(session)` (snippet marker renamed accordingly) and needs no render changes: the event-log delta loop already prints the new player-visible quest events through `format_message`, and the closing `_status` reports `victory`. `examples/fastapi_crawler/content.py` registers `Interpreter` on both its create and restore paths. +- `scripts/milestone.txt` restructures to the two-trip shape from the facts: loot, first return (sell, heal, the first adventure award — the town-only commands live here, before any completion), re-entry, the idol, the concluding return. The adventure-completed line and the `victory` mode close the transcript, the reward's "200 gp in coin" lands on the final return, and the standing assertions — the goblins, the hoard, the rivals, level 2, `quest.idol = 'recovered'`, the temple purchase, determinism — hold against the new script in `tests/test_example_crawler.py`. +- `generate_phase5_goldens.py` follows the script: two `session.xp.adventure_award` events instead of one, extra travel turns on the clock, and the `post_award` checkpoint pinned to the *final* return (the generator's last-`town`-wins capture is made explicit rather than incidental); its milestone assertions move from listener state and the granted-in-dungeon reward to quest state, the interpreter's empty slot, and the pay-on-delivery beats. + +### 11. Docs and spec impacts — applied with the implementation PR + +- **`docs/spec.md` gains four sentence-level amendments**, each passing the phase 12 altitude test (an author or front end would mispredict from the current text): the journal paragraph gains the display-text rule — quest beats journal the display text they showed, and the separately authored journal form is the voice of carriers whose display beat the player never sees; the quests paragraph's activation sentence gains the from-start consequence — an activation-less quest stands active in the first view, with no activation event and no offer journal beat, because there is no command channel at session start; the validation paragraph's "quest and objective ids in lifecycle references" clause rewrites to the executed seam — no document authors a lifecycle command (the rewards type forecloses it at parse), so the lifecycle commands resolve their ids against the adventure's quest specs at execution; and the player-view enumeration's active-quests entry gains the speaker attribution. Everything else this phase implements is stated by the spec in the present tense. +- **`docs/adaptations.md` gains no entries** — no SRD text is touched or reinterpreted; the register's silence is the standing precedent. +- **Docstrings**: `narrative.py`'s carrier mapping is rewritten per fact 3 (the objective column, the `progress` reassignment, the unread-fields note); `JournalEntryAddedEvent` documents that quest beats append without it, so the journal-growth event surface is that event plus the quest lifecycle events; `MarkTriggerFired` and the quest commands cross-reference their opposite id-domain pins; the `Interpreter` docstring gains the quest walk, the reward issuance, the pinned triggers-then-quests order, and the fiat boundary from work item 6. +- **Guides**: `docs/getting-started/building-an-adventure.md` gains the quest authoring section (clauses, hidden objectives and reveals, rewards and selectors, the completion rule, the concluding marker, the beat mapping, `guidance`); `docs/guides/listeners-and-flags.md` rewrites its quest-tracker section around a self-contained inline listener example and presents the interpreter as the shipped instance of the pattern; `docs/front-ends/tui-crawler.md` rewrites its fetch-quest section — the `quest.py` and registration-marker snippet includes, the shrine snippet's prose, and the transcript walkthrough all move to the authored-data story and the two-trip script; `docs/guides/sessions-commands-events.md` gains the four commands, the closed id domain, and `source="quest:{id}"`; `docs/guides/views-and-visibility.md` gains `PlayerView.quests`, the hidden-objective leak rule, and the journal-growth event note. The API and schema references pick up the new module, commands, and events automatically. +- **`CHANGELOG.md`** `[Unreleased]`: Added — the quest spec, the lifecycle commands and events, quest state and its persistence, victory-on-completion, `PlayerView.quests`, `LevelSpec.guidance`, the validation walk. Changed — the example TUI and FastAPI crawlers author the fetch quest as adventure data and register the library interpreter; the hand-rolled listener is deleted. + +### 12. Tests — `tests/test_quests.py` (new), `test_interpreter.py` extensions, and the goldens + +- **Models and validation** (`test_quests.py`): spec round-trips; parse rejections — consuming clause conditions, `reveal_when` on a visible objective, empty objectives, duplicate objective ids, a lifecycle command or authored `source` in `rewards`; `validate_adventure` catching each dangling clause and reward reference class plus duplicate quest ids, and accepting a clean document; pre-phase documents and saves loading unchanged. +- **Lifecycle semantics**: seeding (from-start active, triggered inactive, hidden unrevealed); every guard rejection with its code and params; completion implying reveal; journal appends carrying the mapped beats with correct `rounds` stamps, empty beats appending nothing, and no `JournalEntryAddedEvent` emitted for a quest beat (the fact 3 pin, asserted at the executed seam); referee fiat (`CompleteQuest` under an unsatisfied all rule accepted); the victory transition — mode, cleared play state, event order — and its stickiness from both terminal modes (state advances, no transition, no `AdventureCompletedEvent`); event visibility at the executed seam; the fuzzer strategies and `sample_command` gaining the four shapes (guards total, never raising). +- **Interpreter integration** (`test_interpreter.py`): activation, reveal, and completion firing in pinned order; the same event activating and completing; the any rule completing early with the remaining objective left incomplete; rewards issued after `CompleteQuest` with selector expansion, `source` stamps on every issued command, and a resume-play reward dropping in victory with its note; a hidden objective completing directly with no reveal issued; the depth truncation note for a suppressed quest advancement; a game-driven `CompleteQuest` with no interpreter granting no rewards. +- **Views and persistence**: the whitelist gains `quests`; the leak pins from work item 8; round-trip with a populated block; a pre-phase save loading to the seeded block; `load(save)` equals `replay(seed, commands)` with quest state populated — the standing equivalence covering the new block. +- **The phase golden** — `tests/generate_phase15_goldens.py`, `tests/goldens/phase15_quest.json`, `tests/test_phase15_goldens.py`, on the phase 14 pattern with a `crawl_fixtures` quest adventure: an activation trigger, a visible objective completed on a bundled item's acquisition, a hidden objective revealed by an area entry and completed by a game-issued `SetFlag`, a concluding completion into victory, rewards landing post-transition with one authored spawn reward dropping and noting, a play command refused `wrong_mode` in victory (recorded like the phase 14 refusals) and a referee grant accepted after the end. Asserts: final `quests`, `journal`, and mode exact; every interpreter-issued command stamped `source="quest:..."`; replay of the accepted log with no listeners reaching identical state on every block except `listener_state` (the interpreter's entry exactly `{}`), command and event logs byte-equal; `load(save)` equal to `replay(seed, commands)` — the milestone's replays-clean beat in the golden, with the example run as its second, end-to-end proof. +- **The planned golden change**: `phase5_milestone.json` regenerates because the example it scripts is redesigned — the only existing golden this phase touches (the stored snapshots elsewhere hold no adventure dumps and no fields this phase adds), its generator and assertions in `test_phase5_goldens.py` updated per work item 10 (two-trip script, two awards, the `post_award` checkpoint pinned to the final return, quest state and the interpreter's empty slot in place of listener state); the diff is explained in its commit message per the standing golden rule. +- 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 item 1 (the quest spec) with its model tests — the authoring vocabulary lands first, parseable and pure. +2. Work items 2–5 (commands, events, session state and handlers, persistence) with the lifecycle, visibility, stickiness, and persistence tests — the engine substrate is complete and referee-drivable before the interpreter reads it. +3. Work items 7 and 8 (`Adventure.quests`, validation, the player view) with the validation and leak tests. +4. Work item 6 (the interpreter's quest processing) with the integration tests, then the phase golden. +5. Work items 9 and 10 (the guidance slot; the example rework) with the example test updates and the `phase5_milestone` regeneration in its own explained commit. +6. Work item 11 remainder (docs sweep, the spec amendments, changelog; the full gate on both OSes), and the osr-forge issue filed as the PR lands. + +## 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 twice over: the phase golden's authored quest — activation, reveal, completion, rewards, victory — replays identically with no listeners and restores equal to its replay; and the example's fetch quest runs end to end as authored data through the real terminal loop, idol matched by catalog id, reward paid, journal written, victory reached, deterministically. +- The four lifecycle commands clear every census and docs gate; their guards reject with the three new codes and never raise under the fuzzer; quest ids are closed-domain by test, and the `MarkTriggerFired` open-domain pin is untouched. +- Victory is entered in exactly one place, sticky from both terminal modes, with rewards landing after the transition and resume-play rewards dropping with notes — all pinned by test and golden. +- The interpreter emits nothing and holds nothing (the golden's byte-equal logs and exactly-empty slot), and every command it issues for a quest carries its `source="quest:{id}"` stamp. +- The player view ships active quests and visible objectives only, leaking no clauses, rewards, hidden objectives, or guidance, pinned by the extended leak test. +- No `SCHEMA_VERSION` bump and no migration; pre-phase documents and saves load unchanged; the only existing golden that changes is `phase5_milestone.json`, regenerated for the example's redesign and explained in its commit. +- `FetchQuestListener` is gone without a shim; both example front ends register the interpreter; the listeners guide still teaches the game-owned pattern with a self-contained example. +- The spec gains exactly the four amendments argued here; `docs/adaptations.md` gains nothing; the osr-forge issue is filed and linked from the implementation PR.