Skip to content

Authored gates, triggers, and quests: decision-complete design #41

Description

@mmacy

Note

As built (2026-08-07): the osrlib slice — delivery-order item 1 — is complete: spec derivation in #49, phases 10–15 as PRs #50#61 plus a phase 16 documentation refresh (#62, #63), and the osr-forge gap recorded in mmacy/osr-forge#39. docs/spec.md is now the authority; where the implementation departs from this body, the as-built comment records each delta. Remaining from the delivery order: the osr-web and osr-editor phases.

Important

This is the authoritative, decision-complete design for authored gates, triggers, and quests
across osrlib, osr-web, and osr-editor, filed here because osrlib is the schema's authoritative
home. Every design question it raises, it settles; every load-bearing claim about current
behavior has been verified against the code of all three repos. The next step is deriving the
osrlib spec from this design. Implementation follows the delivery order below.

Problem

An osrlib adventure can be lost but not won. The engine's only terminal state is a battle TPK
(SessionMode.GAME_OVER, assigned solely at battle.py:542); there is no victory, no authored
objective, and no completion detection at any granularity. Content wiring — the lever that opens
the portcullis, the key that fits the lock, the idol the temple pays for — is possible only in
bespoke game code via osrlib's extension surface (event listeners, session flags, referee
commands). Nothing about it is authorable: the adventure document has no field for it, osr-web has
no UI for it, and osr-editor has nothing to edit.

Current state, verified across the three repos

  • osrlib's Adventure model carries exactly name, description, hooks, town, dungeons,
    monsters (crawl/adventure.py:47-67). hooks is inert prose — osr-web renders it as town
    "rumors" and nothing ever references it mechanically.
  • There is no item-identity surface for authored objects. The adventure document can define
    custom monsters (Adventure.monsters unions bundled MonsterTemplates into the base catalog
    via _effective_monsters, crawl/adventure.py:94, with collision reporting) but not custom
    items. The equipment catalog is four typed template tuples — WeaponTemplate,
    ArmourTemplate, GearTemplate, AmmunitionTemplate (core/items.py:320) — each template
    carrying an item_type discriminator and an id. Hand-placed cache contents already reference
    catalog ids (FeatureSpec.item_ids and magic_item_ids, crawl/dungeon.py:365), GrantItem
    validates against the catalog (commands.py:1459), and ItemAcquiredEvent.item_ids carries
    catalog ids — so the placement, grant, and observation machinery all exist and all speak
    catalog ids; what is missing is a way for an adventure to mint one. Named valuables
    (ValuableSpec, crawl/dungeon.py:293-308) carry kind/name/value/weight and no id, so a taken
    valuable is invisible to event matching.
  • The extension surface a quest system rides on all exists and is serialized in saves: listener
    protocol and state store, session.flags, SetFlag/AwardXP/GrantCoins/GrantItem, and
    trigger-suitable events (LocationEnteredEvent, ItemAcquiredEvent, MonsterDefeatedEvent).
    FlagSetEvent is referee-visibility by design — content wiring is the game's secret.
  • The replay contract constrains where an interpreter can live. Replays rebuild state by
    re-executing the command log with no listeners registered; listener-issued commands are already
    in the log (persistence.py:281-331; osrlib docs/spec.md: "replays reproduce engine state
    exactly with or without listeners"). An always-on interpreter inside the session would fire
    again during replay and double-issue its consequences.
  • Doors are authorable as locked (DoorSpec, dungeon.py:183-196) but the only player-facing
    unlock is the thief's PickLock (ForceDoor explicitly defers: "locked doors need PickLock,
    not muscle"). There is no key-item concept. The referee command SetDoorState
    (commands.py:1631) can rewrite any door's overlay anywhere — the lever-opens-portcullis
    primitive — but nothing authored drives it.
  • osrlib's spec (docs/spec.md:171) excludes quest systems from the library: "game-design
    systems with no SRD basis — quests, achievements, story progression, dialogue — belong to the
    game." The same spec's session-flags bullet names "a lever in room 3 opens the portcullis in
    room 12" as something dungeon content reads flags for — so the gates layer is close to
    blessed already, and the quest layer arrives as a first-party implementation of the documented
    extension surface rather than a new in-session system.
  • The one worked example in the ecosystem is examples/tui_crawler/quest.py — an 80-line fetch
    quest listener, deliberately outside the library, written as proof that the extension surface
    is sufficient for game authors. It matches its idol by display name because there is nothing
    else to match on. It is not a surface adventure authors can reach.
  • osr-web consumes only the six Adventure fields; PlayerView (crawl/views.py:134-156) is a
    closed whitelist with no field for flags, progress, or objectives — session.py:802-804
    asserts the exclusion — so quest state has no route to a client by construction, and no
    consumer can evaluate conditions itself. Its transcript log is ephemeral (wiped on every game
    entry); it has a deterministic narrator (server/narrate.py) plus an LLM overlay
    (server/narration.py) whose per-event tone hints are hardcoded. It explicitly swallows
    FlagSetEvent.
  • osr-editor has zero quest code, but its spec anticipated this feature: quests are a named
    future extension (docs/spec.md:267) with the growth path pre-written — osrlib owns the
    schema, generated types pick it up, ops grow additively, triggers place on the map the way
    transitions do, diagnostics lint dangling quest references the way they lint monster ids.

Design overview

Three layers. Mechanics are two primitives — gates and triggers — with quests composed from both.
Narrative is authored text attached to mechanical objects. Presentation is who renders that text.

The two primitives live at different depths of the engine, and that layering is itself a design
commitment:

  • Gates are in-engine command-legality checks, evaluated exactly the way locked is today.
    They mutate nothing, so replay is unaffected.
  • The trigger/quest interpreter is a library-shipped Listener that games register — the
    existing extension contract. Everything the interpreter does lands in the command log as
    ordinary commands, and all quest, trigger, and journal state is engine-owned session state
    mutated only by those commands — so a replay, which runs with no listeners, rebuilds quest
    state exactly by re-executing the log. The interpreter holds no state of its own that the
    engine cannot reconstruct.

This layering also resolves the library-posture tension: gates are dungeon furniture osrlib's
spec already gestures at, and the quest layer arrives as a first-party implementation of the
documented extension surface.

Item identity

The adventure document gains bundled item definitions, mirroring the Adventure.monsters
precedent exactly:

  • Adventure.items is a tuple of item templates — the existing WeaponTemplate | ArmourTemplate | GearTemplate | AmmunitionTemplate union, discriminated on the item_type
    field each template already carries. A quest key or idol is authored as a GearTemplate; the
    surface does not artificially exclude a named weapon or armour piece.
  • An _effective_items union (mirroring _effective_monsters) merges bundled templates into the
    base equipment catalog at session start and inside validate_adventure. Shipped ids win;
    collisions are reported exactly as monster template collisions are.
  • Everything downstream is existing machinery, unchanged: bundled items place in caches through
    FeatureSpec.item_ids, GrantItem grants them because it validates against the effective
    catalog, ItemAcquiredEvent carries their ids because it already carries catalog ids, and
    validate_adventure resolves every item reference against the effective catalog.

ValuableSpec stays id-less. Valuables remain generic, sellable treasure; a MacGuffin is an
authored item placed in a cache, not a valuable. This keeps exactly one item-identity namespace —
the catalog — and eliminates the display-name matching the worked example was forced into. If a
"this specific gem is also a quest target" need ever materializes, an optional id on
ValuableSpec can arrive additively; nothing here forecloses it.

Conditions and gates

A condition is a deterministic predicate over session state, evaluated live at the moment the
gated thing is attempted (level-triggered, not edge-triggered). Live evaluation is the point: a
flag set at item pickup drifts from the truth when the party drops or sells the item; a predicate
checked at use time cannot. The vocabulary:

  • has_item(item_id, consumes=False) — some party member's carried inventory contains an item
    whose catalog id (equipment or magic-item) matches. Any member's carried inventory is the whole
    test: items live in member inventories (GrantItem takes a character_id), there is no shared
    pile, and equipped-only would break the toll-coin case. With consumes=True, one instance is
    removed when the gated command succeeds — consumption is an effect of the successful command,
    reported through its events, never a side effect of condition evaluation, which stays pure
    (this is what keeps the gates-mutate-nothing replay argument true). The instance is taken from
    the first holder in party order.
  • flag_equals(key, value) — a session flag holds a value.
  • effect_active(kind) — an active effect whose EffectDefinition.kind matches is attached to
    a party member (the light precedent: authors choose between "merely carrying the talisman
    suffices" and "you must invoke it").

Conditions form a discriminated union that grows additively — this union is the primary seam for
future condition kinds (NPC-related conditions among them).

A gate is an optional requires condition on an interaction spec. Version one scopes gates to
DoorSpec.requires and TransitionSpec.requires — specs that content packs do not carry (see
the packs seam). Two further gate sites are part of the design but trail the first slice, riding
the pack-interaction decision when it comes: TrapSpec.bypass (the trap does not spring for a
party meeting the condition) and condition-keyed prose variants on AreaSpec/FeatureSpec
descriptions (the feather-holder notices the draft, the scroll-bearer reads the inscription).
When prose variants land they are engine-resolved — see the visibility section.

Gates and the lock machinery

requires and locked are orthogonal layers, not one mechanism. Unifying them — re-expressing
locked as a built-in gate — was considered and rejected: locked is SRD-grounded, stateful,
edge-triggered mechanics (thief skill rolls, per-character lockouts, overlay mutation on
success), while a gate is a stateless authored predicate. Merging them would force dice,
character state, and lockout tracking into the condition union — a merge of unlikes that reads
as simplification and is not.

The composition rules:

  • When a door carries both, the lock and the condition must each be satisfied to operate it.
  • PickLock addresses only the lock, never the gate. A thief is not a universal quest bypass;
    an author who wants a pickable obstacle authors locked without a gate.
  • SetDoorState rewrites the state overlay only and never touches requires — specs are
    immutable ("play mutates the overlay, never this spec").
  • A door standing open admits passage without a gate check. Triggers open gated doors by setting
    them open, not by clearing the gate — the portcullis raised by the lever is open regardless of
    what raising it by hand would require. If the door closes again, the gate re-applies; live
    evaluation stays meaningful.
  • A gate refusal is a command rejection with its own codes — exploration.door.gate_refused and
    exploration.transition.gate_refused — distinct from exploration.door.locked, with the
    gate's authored refusal text carried alongside the code so the renderer can show it. Success
    text rides the normal door and transition events as an optional authored beat.

Triggers

A trigger is an authored binding: when an observable event matches a pattern (optionally further
gated by conditions), issue referee commands. Edge-triggered. The observables are the existing
events (LocationEnteredEvent, ItemAcquiredEvent, MonsterDefeatedEvent, FlagSetEvent);
the consequence surface authors reach is the existing referee command vocabulary (AwardXP,
GrantCoins, GrantItem, SetFlag, SetDoorState, SpawnMonsters, SpawnNpcParty). The
semantics:

  • Once-only by default, with repeatable: true as the authored opt-in. Once is the common
    authored case (the ambush, the reveal, the reward) and caps cascade blast radius. Fired-state
    is engine session state (see the interpreter section), so once-only survives save/load and
    rebuilds under replay.
  • Deterministic ordering. Multiple triggers matching one event fire in document order —
    stable and author-controllable. Consequences within a trigger execute in authored order. All
    consequences of a batch execute before any subsequent player command.
  • Bounded cascades. SetFlag is a consequence and FlagSetEvent is an observable, so
    trigger chains are constructible — and flag-chains are legitimate wiring, so the answer is a
    bound, not suppression. Events produced by a player command are depth 0; events produced by a
    trigger's consequences are one deeper than the events that fired it. Triggers match events of
    depth 4 or less; deeper events are not offered to the interpreter, and truncation records a
    referee-visibility note. The editor carries an advisory cycle lint (see consumers); runtime
    boundedness is the guarantee, so a cycle is suspicious authoring, not invalid content.
  • Rejected consequences are dropped and recorded. Referee spawns reject when an encounter is
    already open — and MoveParty can emit LocationEnteredEvent and open a wandering encounter
    in the same command result, so "ambush at the shrine" can collide with a random encounter
    immediately. A dropped consequence records a referee-visibility note; the editor lints
    collision-prone pairings. No retry or queue — deferred consequences firing later would be
    undebuggable.
  • Rewards and treasure XP. Trigger-granted treasure follows the same valuation rules as any
    treasure at the moment it lands: granted mid-dungeon it counts in the next return delta;
    granted at town it lands outside any delta and earns no treasure XP. No valuation bookkeeping
    is added or changed. Authored XP belongs in AwardXP — treasure XP is for recovered treasure —
    and the editor carries an advisory lint for a coin or item reward with no accompanying
    AwardXP.
  • Timing honesty. MonsterDefeatedEvent emits at battle end, so "the portcullis opens the
    moment the boss falls, mid-fight" is not authorable in version one. Stated so authors'
    expectations are set.

The interpreter and the command log

The interpreter observes events, decides, and acts exclusively by issuing commands — because
replays run with no listeners, anything the interpreter merely remembered would be lost to a
replay. Quest, trigger, and journal state therefore live in the engine session, mutated only by
a small family of lifecycle referee commands the interpreter issues (authors author QuestSpecs
and triggers, never these commands):

  • MarkTriggerFired(trigger_id) — records the fired mark in session trigger state and emits a
    referee-visibility TriggerFiredEvent. Issued before the trigger's consequences.
  • ActivateQuest(quest_id), RevealObjective(quest_id, objective_id),
    CompleteObjective(quest_id, objective_id), CompleteQuest(quest_id) — advance session quest
    state; their execution reads the QuestSpec from the adventure document, emits the
    player-visible events carrying the authored narrative, and appends the journal entries.
    CompleteQuest on a quest marked as concluding the adventure transitions the session to
    victory (below).
  • AddJournalEntry(...) — appends a journal entry outside a quest lifecycle beat; how one-off
    triggers get a journal voice.
  • RecordNote(...) — no state effect; emits a referee-visibility event. The mechanism behind
    the dropped-consequence and cascade-truncation records, and generally useful to game-side
    listeners.

These are ordinary logged, replayed commands; re-executing them rebuilds quest state, journal,
and fired-marks exactly, with no interpreter present. In addition, the base Command gains an
optional source: str | None = None field — ignored by execution, additive within the schema
version, replay-safe — which the interpreter stamps with the owning trigger or quest id on every
consequence it issues. Without it, "why did the party get 500 XP" is unanswerable from the log.

Quest reward commands issue immediately after CompleteQuest, in authored order, before any
subsequent player command.

Quests

A QuestSpec composes the primitives:

  • id, name, and a narrative block
  • Activation: a quest is dormant until its activation trigger fires; a quest with no
    activation trigger is active from session start. There is no accept/decline interaction — a
    B/X module frames the objective, it does not negotiate. The offer beat displays at activation.
  • Objectives: each an authored trigger plus narrative, optionally hidden. A hidden objective
    reveals when its optional reveal trigger fires, or on completion if it has none.
  • Rewards: referee commands issued on completion.
  • Completion rule: all objectives, or any.
  • An optional marker designating that completing this quest concludes the adventure.

The journal

The journal is an appended, event-sourced list in session state — never derived on demand.
Entries append when beats land: quest activation, objective reveal and completion, quest
completion, and any one-off trigger whose narrative block carries a journal form. Appending
preserves order-of-discovery, keeps beats whose source state has since changed (the third firing
of a repeatable trigger, a revealed-then-completed objective), gives triggers a journal voice
without a quest attached — and derivation is foreclosed anyway, since consumers cannot evaluate
quest state by construction. The journal persists in saves and ships to clients verbatim through
PlayerView.

Narrative blocks

Every mechanical object (quest, objective, gate, trigger) carries an optional narrative block
with three distinct audiences:

  • Display beats — text the deterministic renderer shows verbatim: offer, progress, and
    completion text for quests; refusal and success text for gates ("the door doesn't budge; a
    shallow slot suggests something might fit"); fired text for triggers.
  • Journal form — the entry recorded when the beat lands, phrased for a persistent journal
    rather than a transcript.
  • LLM guidance — authored steering that is never displayed verbatim. The guidance field
    lives on the narrative block itself, so every object that carries narrative carries steering
    uniformly, plus one ambient guidance slot per dungeon level ("this level should feel
    increasingly wrong the deeper they go" attaches to no mechanical object). No other scoping
    machinery: guidance applies while its carrier is in play — quest active, gate encountered,
    trigger fired, level occupied. Trust posture: guidance is trusted as content, the same posture
    as description prose, which already flows into narration prompts today.

An optional speaker attribution stays free prose (see the NPC seam).

Visibility

Player-visible events carry authored text; the wiring that produced them — flags, conditions,
trigger internals — stays referee-only. The new player-visible event kinds are the quest
lifecycle events (QuestActivatedEvent, ObjectiveRevealedEvent, ObjectiveCompletedEvent,
QuestCompletedEvent), JournalEntryAddedEvent, and AdventureCompletedEvent; the new
referee-visibility kinds are TriggerFiredEvent and the RecordNote event. Gate refusal text
rides the command rejection; gate success text rides the existing door and transition events.

Condition-keyed prose variants, when they land, are engine-resolved at emission time and
shipped as text
. Consumers cannot evaluate conditions by construction (PlayerView is a
closed whitelist and the flags exclusion is asserted in code), and growing a consumer-facing
condition surface would breach the backend-is-authoritative division all three repos are built
on. Engine-side resolution also keeps the secret wiring secret: the client sees the resolved
prose, never the condition.

PlayerView gains active quests (id, name, display narrative, visible objectives and their
states) and the journal.

Victory and session end

Adventure completion gets both a terminal event and a terminal mode: CompleteQuest on the
concluding quest emits a player-visible AdventureCompletedEvent carrying the authored
completion narrative and transitions the session to SessionMode.VICTORY (wire value
"victory"), a terminal mode mirroring GAME_OVER — play commands are illegal, referee
commands remain legal (which is also what lets rewards land after the transition).

The session concludes rather than continuing. The adventure module is the playable unit
throughout the ecosystem; campaign continuity is a non-goal; and post-victory play drags in a
swamp of underspecified questions (what is legal after winning, does XP accrue, can you die
after victory). Concluding now and adding a post-victory mode later is additive; shipping
"continue" first and tightening later is breaking.

This work also fixes an existing gap in the terminal-state machinery rather than inheriting it:
a non-battle party wipe (trap, deprivation) currently leaves the session in EXPLORING forever.
Non-battle wipes route to GAME_OVER. The delicate machinery is opened once, for both changes.

Engine changes implied

  • Adventure.items and the _effective_items union, at session start and in validation
  • Gate evaluation in the command handlers — a legality check beside locked, plus the
    gate-refusal rejection codes and the consume-on-success effect
  • The trigger/quest interpreter as a library-shipped Listener; the quest lifecycle referee
    commands; the optional source field on the base Command
  • Session state for quest, trigger, and journal progress, persisted in saves
  • PlayerView additions: active quests, journal
  • The new player-visible and referee-visibility event kinds
  • SessionMode.VICTORY, AdventureCompletedEvent, and the non-battle-TPK routing to GAME_OVER
  • validate_adventure grows hard reference checks — item ids in conditions, caches, and
    consequences against the effective catalog; monster, area, and dungeon/level references in
    triggers and transitions; quest and objective ids in lifecycle references

Consumers

  • osr-web: registers the library interpreter; the renderer passes authored text through; a
    persistent journal panel (distinct from the ephemeral log); a victory screen on
    SessionMode.VICTORY; the LLM narration prompt gains active quest state, recent journal
    entries, and authored steering — replacing or augmenting today's hardcoded tone hints.
  • osr-editor: regenerated types pick up the schema; the ops vocabulary grows additively; a
    quests panel joins the nav (an eighth target); gates and triggers place on the map the way
    transitions do. Lint grows: dangling-reference diagnostics mirroring validate_adventure;
    advisory lints for flag reads with no writer, trigger cycles, collision-prone
    trigger/encounter pairings, and treasure rewards with no accompanying AwardXP; and a
    reachability class ("door requires a key that is not placed anywhere") scoped to the decidable
    core — an id appearing in no cache, no trigger consequence, and no purchasable catalog entry —
    and advisory forever, because trigger-granted items and random treasure make the general
    problem undecidable, and lint warns while only validate_adventure gates publish.

Delivery order

  1. osrlib — the schema and engine work, spec first. Everything downstream hangs off it. The
    slice that ships the interpreter also converts the worked example: examples/tui_crawler
    registers the library interpreter and authors its fetch quest as data in its adventure
    document, and the hand-rolled quest.py listener is deleted. Its job was proving the
    extension surface sufficed; once the library ships the interpreter, its job is demonstrating
    the authored surface, and keeping the hand-rolled version would leave two quest patterns in
    the repo. Closing deliverable of this phase: file the osr-forge issue recording the
    overrides-vocabulary gap (see the forge seam).
  2. osr-web — prove the model plays before freezing authoring on it: interpreter
    registration, journal, victory, LLM context.
  3. osr-editor — the authoring surface, as its spec already prescribes.

The first slice is gates on doors and transitions. It is the right first slice not because it is
small and independent but because its tiny surface area forces the two hardest schema questions —
item identity and lock composition — while everything else is still cheap to change, and it
pressure-tests the condition vocabulary before quests build on it.

Seams and scope guards

NPCs: seam only, not folded in

osrlib's spec puts dialogue in the same belongs-to-the-game exclusion as quests, and an NPC model
without dialogue is just prose — which narrative blocks already carry. The engine already has
NPC-shaped verbs (SpawnNpcParty — whose docstring names quest listeners as an intended caller —
and Parley); what it lacks is persistent named NPC entities, which nothing here requires. The
seam commitments:

  • Conditions and triggers are discriminated unions; spoke_to(npc_id) or npc_present arrive
    later as additive variants.
  • Narrative speaker stays free prose now; a typed npc_id arrives alongside it additively
    when NPCs land. Prose is never retrofitted into a reference.
  • NPCs get their own id namespace when they arrive; the dangling-reference lint pattern extends.
  • The consequence surface is "referee commands," full stop — already open.
  • No closed enumerations anywhere an NPC would eventually slot (no literal "town" as the only
    quest source, no "monsters" as the only encounterable thing).

Adventure hooks

hooks stays exactly as it is: inert, diegetic rumor prose. With quests carrying offer
narrative, the document has two "why the party goes" surfaces, and the overlap was examined
deliberately: they are different registers — rumors are town color a game renders
atmospherically, offers are the mechanical entry beat of a specific quest — and neither subsumes
the other. No unification, and no mechanical linkage from a rumor to a quest id; if a typed
linkage ever wants to exist it arrives additively, under the same rule as speaker (prose is
never retrofitted into a reference).

Forge-backed projects

Quest authoring is native-project-only at first. In a forge-backed project the editor writes
overrides.yaml — osr-forge's schema — and that schema has no quest surface; growing one is
osr-forge's decision, made in its own repo against a settled osrlib schema. The editor disables
quest surfaces in forge-backed projects with an affordance explaining why. No shim is built:
this is sequencing, not accommodation, and the osr-forge issue recording the vocabulary gap is a
closing deliverable of the osrlib phase, not a someday.

Content packs

Quests are adventure-scoped and stay out of packs. The deferral has a sharp edge the v1 scoping
exists to blunt: ContentPackEntry embeds FeatureSpec, trap, and treasure directly
(crawl/content_pack.py:104-107), so a gate on those specs would ride into arbitrary target
adventures — carrying dangling item and flag references — the same release it shipped. Hence
gates v1 lands on DoorSpec/TransitionSpec only (which packs do not carry), and
TrapSpec.bypass plus prose variants ride the pack-interaction decision when it comes. Pack
features referencing item ids reference the shipped catalog only — adventure-bundled item ids
are adventure-scoped by construction, and pack insertion validates references against the target
adventure's effective catalog.

Serialization and versioning

Everything here is additive and nothing bumps SCHEMA_VERSION:

  • New optional fields (requires, Adventure.items, Command.source, narrative blocks) and
    new event types are within-version by written policy (versioning.py:30-38).
  • The new save-state blocks (quest, trigger, journal state) are new optional fields — same rule.
  • Adding the SessionMode.VICTORY value is additive-within-version, and this work codifies that
    rule into the versioning policy: a new enum value has the same risk profile as a new event
    type, which the policy already blesses — old artifacts never contain it, and an older reader
    rejecting a newer artifact is a possibility the new-event-type rule already accepts. (The
    existing docstring says changing wire values is a bump; adding one is now addressed in
    writing, so the next addition does not reopen the question.)

The design of the shared single SCHEMA_VERSION integer itself — one number across saves,
characters, parties, packs, and stamped documents — is out of scope here; it deserves its own
issue if a bump-forcing change ever approaches. The editor's canonical-serialization
byte-stability invariant must hold for documents that use none of the new fields, and its
open-fields fidelity guard already protects older editors from newer documents in the interim.

Commitments at a glance

  • Item identity: Adventure.items bundles existing item templates into the effective catalog,
    mirroring monsters; ValuableSpec stays id-less.
  • Gates: in-engine legality checks on DoorSpec/TransitionSpec; conditions are has_item
    (any member's carried inventory, optional consume-on-success), flag_equals,
    effect_active(kind).
  • Locks: orthogonal to gates; both must pass; PickLock never bypasses a gate; SetDoorState
    never touches requires; an open door admits passage; distinct gate-refusal rejection codes.
  • Triggers: once-only default, document-order firing, cascade depth bounded at 4, rejected
    consequences dropped and recorded, treasure rewards follow normal valuation with authored XP
    via AwardXP.
  • Interpreter: library-shipped Listener; all state engine-owned and command-mutated (lifecycle
    referee commands); optional source provenance on every command.
  • Quests: activation-trigger model, no accept/decline; hidden objectives reveal by trigger or on
    completion; rewards issue immediately on completion.
  • Journal: appended event-sourced session state, shipped verbatim in PlayerView.
  • LLM guidance: on the narrative block uniformly plus a per-dungeon-level ambient slot; trusted
    as content.
  • Visibility: player-visible events carry authored text; wiring stays referee-only; prose
    variants engine-resolved.
  • Victory: SessionMode.VICTORY plus AdventureCompletedEvent; the session concludes; the
    non-battle-TPK gap routes to GAME_OVER in the same work.
  • Worked example: examples/tui_crawler/quest.py is deleted in the slice that ships the
    interpreter; the example authors its quest as data.
  • Adventure.hooks: kept as inert rumor prose; distinct register from quest offers; no
    mechanical linkage.
  • Forge: native-only first; the vocabulary-gap issue files at the close of the osrlib phase.
  • Versioning: fully additive; no SCHEMA_VERSION bump; the enum-value-additive rule gets
    written into the policy.

Non-goals

  • NPC entities, dialogue systems, and any interaction surface beyond what Parley already does
  • Branching narrative or dialogue trees
  • Quest accept/decline interactions
  • Post-victory play (additive later if ever wanted; concluding is the v1 semantics)
  • Procedurally generated quests
  • Quest content in content packs
  • Campaign or multi-adventure continuity

Key references

  • osrlib: crawl/adventure.py:47-67 (Adventure), crawl/adventure.py:94 (_effective_monsters,
    the union pattern items mirror), core/items.py:320 (EquipmentCatalog and its four template
    tuples), core/effects.py:464 (EffectDefinition, whose kind is the authorable effect
    identity), crawl/dungeon.py:183-196 (DoorSpec), crawl/dungeon.py:214-228 (TransitionSpec),
    crawl/dungeon.py:293-308 (ValuableSpec — no id field), crawl/dungeon.py:365 (FeatureSpec,
    whose item_ids/magic_item_ids place catalog items in caches), crawl/commands.py:95
    (SessionMode), crawl/commands.py:1459-1714 (referee commands incl. SetDoorState:1631,
    SpawnNpcParty:1596), crawl/commands.py:384 (PickLock), crawl/events.py
    (LocationEnteredEvent:94, ItemAcquiredEvent:202, MonsterDefeatedEvent:502,
    FlagSetEvent:660), crawl/views.py:134-156 (PlayerView), crawl/session.py:802-804 (the
    flags exclusion), crawl/battle.py:542 (the only terminal state), persistence.py:281-331
    (replay without listeners), versioning.py:30-38 (additive-within-version policy),
    crawl/content_pack.py:104-107 (packs embed FeatureSpec), docs/spec.md:169-176 (extension
    points, incl. the lever/portcullis line), examples/tui_crawler/quest.py (the hand-rolled
    listener this design retires)
  • osr-web: server/narrate.py (deterministic renderer), server/narration.py (LLM tone hints),
    server/library.py:49-127 (raw-JSON metadata path), static/app.js:3234 (ephemeral log)
  • osr-editor: docs/spec.md:267 (the quests future-extension entry), src/osreditor/ops.py
    (the op vocabulary), src/osreditor/lint.py (the lint rule set)

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions