Skip to content

osrlib.crawl.interpreter

The interpreter: the listener that plays an adventure's authored triggers and quests.

Interpreter is what turns the authored hooks in an adventure document into things that happen at the table.

Where the interpreter sits. It reads the triggers and quests of the Adventure the session is playing, and it is an ordinary listener your game registers with GameSession.register_listener. It watches the events of every accepted command, matches them against the adventure's TriggerSpecs and QuestSpecs, and acts the only way anything outside the engine may act: by executing ordinary referee commands, each stamped with the trigger or quest it acted for. What it does shows up in the event stream as TriggerFiredEvent, JournalEntryAddedEvent, QuestActivatedEvent, ObjectiveRevealedEvent, ObjectiveCompletedEvent, QuestCompletedEvent, AdventureCompletedEvent, and NoteRecordedEvent, plus whatever the consequences and rewards themselves emit.

That discipline is what keeps an authored game replayable. The interpreter emits no events of its own and keeps nothing between commands, so a replay, which runs with no listeners at all, rebuilds the same world by re-executing the same log. Every effect a trigger or a quest has is a command in that log, and every one of those commands says whose idea it was.

Write your own listener instead when you want something the authored vocabulary does not cover. The guide Listeners and flags covers the listener contract, and Gates, triggers, and quests covers what this one plays.

Interpreter

Interpreter(session: GameSession)

Plays an adventure's authored triggers and quests by issuing referee commands.

Register one, once, on a session that has already been built:

session.register_listener(Interpreter(session))

Registering twice fires everything twice, which is the rule every listener follows, and a session restored from a save needs the registration again, because listeners are code and a save contains data. Nothing migrates: the interpreter's slot in listener_state is empty and stays empty for the life of the session.

What it does with a command's events. It walks them in the order they happened and, per event, the adventure's triggers in document order and then its quests in document order, which is the only order it ever uses. A trigger matches when its pattern fits the event, its fired-state allows it (once-only unless repeatable), and every one of its conditions holds against session state right now. A match fires immediately, before the walk moves on, so a later trigger's conditions see what an earlier firing has already changed.

What a firing issues, all of it stamped source="trigger:{id}":

  1. MarkTriggerFired, which includes the fired beat. The mark goes in first, which is what makes once-only safe against a trigger whose own consequences would match it again.
  2. The consequences, in authored order, with @party and @first expanded to the living members they name, so the log records concrete character ids and replays exactly.
  3. AddJournalEntry when the trigger's narrative includes a journal form, last, so the beat is stamped with the clock the consequences left behind.

What a quest walk issues, all of it stamped source="quest:{id}". A quest clause (TriggerClause) is matched exactly the way a trigger is, through the same patterns and the same live conditions, and the walk goes:

  1. An inactive quest whose activation clause matches gets ActivateQuest, and the walk continues into the objectives of the quest it just activated: the same event that starts a quest can finish something in it.
  2. An active quest's objectives walk in authored order. A hidden, unrevealed, incomplete objective whose reveal_when matches gets RevealObjective, and an incomplete objective whose when matches gets CompleteObjective. An objective that completes without ever being revealed needs no reveal, because completing shows it.
  3. The moment a completion lands, the quest's completion rule is checked against live state (all or any), and a satisfied rule gets CompleteQuest followed by the rewards in authored order, selectors expanded exactly as a trigger's consequences are. On a quest that concludes the adventure the session is in victory before the first reward is issued, which is why a reward that would resume play there drops with a note.

Everything is evaluated as the walk goes: a flag an earlier firing wrote satisfies a later clause's condition in the same batch, and a quest completed earlier in the walk is completed for everything after it.

A quest walk stops at the completion it issues. The rewards go out and the walk returns, so an objective later in the tuple whose clause also matches this event is left incomplete, and it completes on the next event that matches it. Under the any rule that is the usual case, since the first objective to land finishes the quest.

Where the interpreter's discipline stops and the referee's ruling begins. The completion rule is checked only after a completion the interpreter itself issued, and no pattern matches the quest events, so a game that completes the last objective by hand completes the quest by hand too. For the same reason a hand-driven CompleteQuest grants no rewards: rewards are this listener reading the quest, and what a replay re-executes is the reward commands themselves.

When something does not work out, the run continues and the log says why. A rejected consequence or reward is dropped on its own, whether it is a spawn that meets an open encounter or a grant to a character who is not there, and a RecordNote records the trigger or quest, the slot's position and type, and the rejection. If a wipe mid-cascade ends the session, the remaining commands land or drop by the ordinary rules of a terminal mode. A cascade is bounded too: what a firing or a quest advancement issues is one level deeper than the event that caused it, and an event at depth five or deeper issues nothing further. Matching itself carries on at that depth. Every trigger is still evaluated, and so is every clause of a quest that is already active, with each suppressed advancement recorded as a note instead of being issued. The exception is a quest the event would have activated: that walk records one note for the activation it did not issue and stops there, so the objective clauses of that quest are not evaluated for this event. No state moves in any of these cases, so a once-only trigger cut short here is still fireable later, and a suppressed quest advancement waits for its clause to match again. Clauses are edge-triggered on both surfaces, so the suppressed edge itself is gone.

What it never does. It returns no events, because everything it causes is already logged by the commands it executed, and it keeps no memory between commands. Read what a trigger or a quest did from the command log, the journal, session.fired_triggers, and session.quests, all of which a replay rebuilds.

Examples:

from osrlib.core.alignment import Alignment
from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.crawl.adventure import Adventure, TownSpec
from osrlib.crawl.commands import EnterDungeon, SetFlag
from osrlib.crawl.dungeon import DungeonSpec, LevelSpec
from osrlib.crawl.interpreter import Interpreter
from osrlib.crawl.narrative import NarrativeBlock
from osrlib.crawl.party import Party
from osrlib.crawl.session import GameSession
from osrlib.crawl.triggers import DungeonEnteredPattern, TriggerSpec

rules = Ruleset()
rng = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM)
hero = create_character(
    name="Hild",
    class_id="fighter",
    alignment=Alignment.LAWFUL,
    ruleset=rules,
    stream=rng,
)
level = LevelSpec(number=1, width=1, height=1, entrance=(0, 0))
crypt = DungeonSpec(id="crypt", name="The Old Crypt", levels=(level,))
door_shuts = TriggerSpec(
    id="the-door-shuts",
    when=DungeonEnteredPattern(dungeon_id="crypt"),
    consequences=(SetFlag(key="crypt.entered", value=True),),
    narrative=NarrativeBlock(
        fired="The door shuts behind the party.",
        journal="The crypt door shut behind us.",
    ),
)
adventure = Adventure(
    name="A First Delve",
    town=TownSpec(name="Threshold"),
    dungeons=(crypt,),
    triggers=(door_shuts,),
)
session = GameSession.new(Party(members=[hero.character]), adventure, seed=7)
session.register_listener(Interpreter(session))
session.execute(EnterDungeon(dungeon_id="crypt"))

print(session.fired_triggers)
# ['the-door-shuts']
print(session.flags)
# {'crypt.entered': True}
print([entry.text for entry in session.journal])
# ['The crypt door shut behind us.']

Bind the interpreter to the session it watches and issues commands through.

Construct it after the session exists, pass it straight to GameSession.register_listener, and do the same again after loading a save. One interpreter serves one session.

Parameters:

Name Type Description Default
session GameSession

The session to play. Its adventure's triggers and quests are read once here, being frozen content.

required

key class-attribute instance-attribute

key = 'osrlib.interpreter'

The listener key, which names this listener's slot in the session's listener_state. Registration creates the entry, and it is the empty dict for the life of the session, because the interpreter keeps no memory between commands.

handle

handle(events: Sequence[Event], state: dict) -> tuple[list[Event], dict]

Match one command's events and act on what they crossed.

The session calls this after every accepted command, and you do not call it yourself. It is here because it is the listener contract every listener implements, and reading it tells you what the session hands a listener of your own.

Parameters:

Name Type Description Default
events Sequence[Event]

The command's accumulated events, in the order they happened.

required
state dict

The listener's state slot, always the empty dict.

required

Returns:

Type Description
list[Event]

No events and the empty state. Everything the interpreter does is a command

dict

it executed, and it remembers nothing between commands.