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:
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}":
MarkTriggerFired, which includes thefiredbeat. The mark goes in first, which is what makes once-only safe against a trigger whose own consequences would match it again.- The consequences, in authored order, with
@partyand@firstexpanded to the living members they name, so the log records concrete character ids and replays exactly. AddJournalEntrywhen 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:
- 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. - An active quest's objectives walk in authored order. A hidden, unrevealed,
incomplete objective whose
reveal_whenmatches getsRevealObjective, and an incomplete objective whosewhenmatches getsCompleteObjective. An objective that completes without ever being revealed needs no reveal, because completing shows it. - The moment a completion lands, the quest's completion rule is checked against live
state (
allorany), and a satisfied rule getsCompleteQuestfollowed 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 invictorybefore 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
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
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. |