Skip to content
Merged
6 changes: 6 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

91 changes: 88 additions & 3 deletions docs/getting-started/building-an-adventure.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,62 @@ Locks and gates are separate layers, and a door that carries both requires both:

A [`NarrativeBlock`][osrlib.crawl.narrative.NarrativeBlock] holds the authored text for the mechanical object it hangs on. Gates read two of its beats: `refusal`, returned in the rejection, and `success`, which rides the successful command's event — the [`DoorEvent`][osrlib.crawl.events.DoorEvent] for a door, the [`LocationEnteredEvent`][osrlib.crawl.events.LocationEnteredEvent] for a transition that crosses into a new level or dungeon. [`format_message`][osrlib.messages.format_message] appends the beat verbatim, so it shows up in a bare transcript. A transition whose destination is its own level crosses no boundary and emits no arrival event, so a success beat there has nowhere to display. The block's other fields — `journal`, `guidance` for an LLM narrator, `speaker` — are read by the surfaces that consume them; none of them ever reach the player view, which carries no gate wiring at all.

## Wiring the dungeon with triggers

A gate asks "may the party do this?" every time it tries. A trigger asks the opposite question, once: "did this just happen?" A [`TriggerSpec`][osrlib.crawl.triggers.TriggerSpec] binds an observable event pattern to referee-command consequences — the lever that opens the portcullis, the idol whose theft wakes the temple, the room whose first crossing writes a line in the party's journal:

```{.python .no-run}
sentinel_wakes = TriggerSpec(
id="sentinel-wakes",
when=ItemAcquiredPattern(item_id="brass_key"),
consequences=(SetFlag(key="barrow.key_found", value=True),),
narrative=NarrativeBlock(
fired="The sentinel's head turns a few degrees, and stops.",
journal="The brass key is ours. Something in the barrow noticed.",
),
)
```

Triggers are inert content on their own. They play when your game registers the library's [`Interpreter`][osrlib.crawl.interpreter.Interpreter] on the session — once, right after the session is built (and again after loading a save, since listeners are code and a save carries data):

```{.python .no-run}
session = GameSession.new(Party(members=[hero.character]), adventure, seed=11)
session.register_listener(Interpreter(session))
```

### What a trigger watches

`when` is one pattern from a small union, each naming an event the engine already emits:

- [`AreaEnteredPattern`][osrlib.crawl.triggers.AreaEnteredPattern] — the party stepped into a keyed area. Area ids are scoped to their level, so the pattern names the whole triple: `dungeon_id`, `level_number`, `area_id`.
- [`LevelEnteredPattern`][osrlib.crawl.triggers.LevelEnteredPattern] — the party arrived on a level, by stair or by walking in from town.
- [`DungeonEnteredPattern`][osrlib.crawl.triggers.DungeonEnteredPattern] and [`TownEnteredPattern`][osrlib.crawl.triggers.TownEnteredPattern] — the coarser crossings; the town pattern needs no fields, since an adventure has one town.
- [`ItemAcquiredPattern`][osrlib.crawl.triggers.ItemAcquiredPattern] — a member acquired an item with that catalog id, from a cache, a grant, or another member's hands.
- [`MonsterDefeatedPattern`][osrlib.crawl.triggers.MonsterDefeatedPattern] — a monster of that template was defeated: slain, routed, and surrendered all count. Defeats are reported when the battle ends, so "the portcullis opens the instant the boss falls" is not authorable — it opens when the fighting stops.
- [`FlagSetPattern`][osrlib.crawl.triggers.FlagSetPattern] — a flag was written. This is the lever: your game (or another trigger) executes [`SetFlag`][osrlib.crawl.commands.SetFlag], and the trigger watching that key fires. The match is on the value the write carried, and `value=None` matches any value at all.

`conditions` narrows it further with the same [condition union the gates use](#gating-a-door-or-a-stair) — all of them must hold, evaluated live at the moment of the match, so a trigger can ask "…and only if somebody is still carrying the talisman". One difference from a gate: a trigger's condition may not set `consumes=True`. A trigger reacts to something that has already happened, and there is no attempt of its own to charge a toll against.

By default a trigger fires once ever, and the fired-mark is session state that survives a save, a load, and a replay. `repeatable=True` opts into firing every time the pattern matches.

### What a firing does

The interpreter issues ordinary referee commands, every one of them stamped `source="trigger:{id}"` so the command log answers *why* on its own:

1. [`MarkTriggerFired`][osrlib.crawl.commands.MarkTriggerFired], first, carrying the `fired` beat.
2. Your `consequences`, in the order you wrote them.
3. [`AddJournalEntry`][osrlib.crawl.commands.AddJournalEntry], last, when the narrative block carries a `journal` form.

Consequences are drawn from [`ConsequenceCommand`][osrlib.crawl.commands.ConsequenceCommand] — `GrantItem`, `GrantCoins`, `AwardXP`, `SetFlag`, `SpawnMonsters`, `SpawnNpcParty`, `SetDoorState`, `PlaceParty`, `AdvanceTime`. Anything else fails to parse. A consequence that hands something to a character names it with a party selector rather than an id: [`PARTY_SELECTOR`][osrlib.crawl.triggers.PARTY_SELECTOR] (`"@party"`) becomes one command per living member in marching order, and [`FIRST_LIVING_SELECTOR`][osrlib.crawl.triggers.FIRST_LIVING_SELECTOR] (`"@first"`) the lead survivor. Character ids are allocated per session, so a document that named one would be naming something that does not exist when it is read — validation rejects it.

The two beats have two different audiences, and the rule is worth stating plainly: **`fired` is the referee's line and `journal` is the players'.** The `fired` text rides a referee-visibility event, because content wiring is your game's secret; the journal entry is player-visible and ships verbatim in the [`PlayerView`][osrlib.crawl.views.PlayerView]. If you want the table to read something when a trigger fires, write the journal form.

### When something doesn't land

Nothing about a trigger firing is all-or-nothing. A consequence the session rejects — a spawn arriving to find an encounter already open, a grant naming an item the catalog lost — is dropped by itself, the consequences after it still run, and a [`RecordNote`][osrlib.crawl.commands.RecordNote] records the trigger, the consequence's position and type, and the rejection code. There is no retry and no queue: a consequence that fired later, out of order, would be impossible to debug.

Cascades are bounded. A trigger's own events are one level deeper than the event that fired it, matching stops below depth five, and a firing the bound suppresses is recorded as a note rather than a mark — so a once-only trigger cut short there is still fireable later. Flag-chains are perfectly good wiring; the bound is the guarantee that a loop in them ends.

## The dungeon, the town, and the root

The level slots into a [`DungeonSpec`][osrlib.crawl.dungeon.DungeonSpec], and the dungeon into an [`Adventure`][osrlib.crawl.adventure.Adventure] beside the [`TownSpec`][osrlib.crawl.adventure.TownSpec] — the safe base where the party rests, buys equipment, and sells treasure. `travel_turns` maps each dungeon id to the town-to-entrance travel cost in exploration turns:
Expand All @@ -96,10 +152,11 @@ adventure = Adventure(
town=town,
dungeons=(barrow,),
items=(GearTemplate(id="brass_key", name="Brass key", cost_gp=0),),
triggers=(sentinel_wakes,),
)
```

`items` bundles the adventure's own item templates — the brass key the sentinel wants is content, not shipped equipment. See [authoring custom content](../guides/authoring-custom-content.md) for the whole bundling contract.
`items` bundles the adventure's own item templates — the brass key the sentinel wants is content, not shipped equipment. See [authoring custom content](../guides/authoring-custom-content.md) for the whole bundling contract. `triggers` is the adventure's wiring, and the tuple's order is document order: when two triggers match the same event, they fire in the order you wrote them.

## Validate before play

Expand All @@ -121,7 +178,7 @@ from osrlib.core.items import GearTemplate
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.crawl.adventure import Adventure, TownSpec, validate_adventure
from osrlib.crawl.commands import EnterDungeon, GrantItem, MoveParty, OpenDoor, SessionMode
from osrlib.crawl.commands import EnterDungeon, GrantItem, MoveParty, OpenDoor, SessionMode, SetFlag
from osrlib.crawl.dungeon import (
AreaSpec,
Direction,
Expand All @@ -134,9 +191,11 @@ from osrlib.crawl.dungeon import (
LevelSpec,
)
from osrlib.crawl.gates import GateSpec, HasItemCondition
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 ItemAcquiredPattern, TriggerSpec
from osrlib.data import load_equipment, load_monsters

sentinel = GateSpec(
Expand Down Expand Up @@ -169,13 +228,24 @@ level = LevelSpec(
),
)

sentinel_wakes = TriggerSpec(
id="sentinel-wakes",
when=ItemAcquiredPattern(item_id="brass_key"),
consequences=(SetFlag(key="barrow.key_found", value=True),),
narrative=NarrativeBlock(
fired="The sentinel's head turns a few degrees, and stops.",
journal="The brass key is ours. Something in the barrow noticed.",
),
)

barrow = DungeonSpec(id="barrow", name="The Barrow", levels=(level,))
town = TownSpec(name="Threshold", travel_turns={"barrow": 2})
adventure = Adventure(
name="The Barrow of the Knucklebone Goblins",
town=town,
dungeons=(barrow,),
items=(GearTemplate(id="brass_key", name="Brass key", cost_gp=0),),
triggers=(sentinel_wakes,),
)

# Validation catches unknown ids and broken geometry before play ever starts.
Expand All @@ -185,6 +255,7 @@ rules = Ruleset()
creation = RngStreams(master_seed=11).get(CHARACTER_CREATION_STREAM)
hero = create_character(name="Brakka", class_id="dwarf", alignment=Alignment.LAWFUL, ruleset=rules, stream=creation)
session = GameSession.new(Party(members=[hero.character]), adventure, seed=11)
session.register_listener(Interpreter(session))

session.execute(EnterDungeon(dungeon_id="barrow"))
session.execute(MoveParty(direction=Direction.EAST))
Expand All @@ -196,7 +267,21 @@ assert not refused.accepted
assert refused.rejections[0].code == "exploration.door.gate_refused"
assert refused.rejections[0].params["refusal"].startswith("The bronze sentinel")

session.execute(GrantItem(character_id="character-0001", item_id="brass_key"))
# The key lands, and the trigger watching for it fires inside the same command:
# the result carries the acquisition and everything the trigger caused after it.
granted = session.execute(GrantItem(character_id="character-0001", item_id="brass_key"))
assert [event.code for event in granted.events] == [
"exploration.item.acquired",
"session.trigger.fired",
"session.flag.set",
"session.journal.entry_added",
]
assert session.fired_triggers == ["sentinel-wakes"]
assert session.flags["barrow.key_found"] is True
assert session.journal[0].text == "The brass key is ours. Something in the barrow noticed."
# Every command the trigger issued says whose idea it was.
assert session.command_log[-1].source == "trigger:sentinel-wakes"

opened = session.execute(OpenDoor(direction=Direction.EAST))
assert opened.accepted
assert opened.events[0].narrative == "The brass key turns in the sentinel's palm and the door swings wide."
Expand Down
39 changes: 37 additions & 2 deletions docs/guides/listeners-and-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ by executing its own commands must return an empty list. A nested `session.execu
already appends that command's events to the session's event log itself; returning them again
from `handle` would log the same event twice.

Returning nothing costs the caller nothing. `execute` notes where the event log ends before it
calls each listener and folds everything logged while that listener ran into the result it hands
back — the nested commands' events, however deeply they nest, each exactly once and in log order,
followed by whatever the listener authored. So the `CommandResult` from a player's `MoveParty`
carries the portcullis grinding open and the journal entry that recorded it, and a front end
renders the whole chain from one envelope.

The nested-`execute` call matters for a second reason: it re-enters the entire dispatch pipeline,
listener loop included. If a listener issues a command from inside `handle`, every registered
listener — itself included — runs again against *that* command's events, with whatever `state`
Expand Down Expand Up @@ -119,8 +126,36 @@ remembering things itself, the discipline this page opened with.
The optional `source` stamp (see
[Sessions, commands, and events](sessions-commands-events.md)) is what ties the vocabulary
together: a listener that stamps the commands it issues with its own quest or trigger id leaves a
log that answers *why* every entry is there. A library-shipped trigger and quest interpreter will
be built on exactly this surface when it arrives; a game's own listener can drive it today.
log that answers *why* every entry is there. The library's own trigger interpreter is built on
exactly this surface, and a game's own listener drives it the same way.

## The trigger interpreter: this pattern, shipped

[`Interpreter`][osrlib.crawl.interpreter.Interpreter] is a listener like any other, and it is the
worked reference for everything above. Register one, once, after the session exists — and again
after loading a save, because listeners are code and a save carries data:

```{.python .no-run}
session.register_listener(Interpreter(session))
```

From then on it watches every command's events, matches them against the adventure's authored
[triggers](../getting-started/building-an-adventure.md#wiring-the-dungeon-with-triggers), and
reacts the only way a listener may: by executing referee commands, each stamped
`source="trigger:{id}"`. Three properties are worth copying into your own listeners:

- **It returns no events.** Every event it causes was logged by a command it executed, and the
result envelope picks those up from the log. `handle` returns `[], {}` unconditionally.
- **It keeps no state.** Its `listener_state` slot exists — `register_listener` creates one — and
stays the empty dict for the life of the session. Fired-marks live in `session.fired_triggers`,
beats in `session.journal`, everything else in the world the commands changed. That is what
makes a triggered game replay exactly: a replay runs with no listeners at all, and re-executing
the log rebuilds every one of those blocks.
- **It has no re-entrancy guard, on purpose.** The fetch quest below needs one because its trigger
condition can look unsatisfied from inside its own reaction. The interpreter instead records the
fired-mark *before* running a trigger's consequences, so a consequence that re-matches its own
trigger finds it already fired; re-entrant self-invocation is how one trigger's consequences fire
the next, and a depth bound rather than a latch is what stops a cascade.

## The fetch quest, worked

Expand Down
13 changes: 13 additions & 0 deletions docs/guides/sessions-commands-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ far and appending its own reactions to both the result and the log. The
`CommandResult` a caller receives after an accepted command carries the *complete*
chain — the handler's events and every listener's events, in the order they happened.

That includes what a listener causes by executing further commands. Those nested
commands log their own events, and `execute` folds everything logged while a listener
ran into the result — each event exactly once, in log order. So one `MoveParty` can
come back carrying the move, the portcullis a trigger opened in response, and the
journal entry that recorded it, and a front end renders all of it from one envelope
without ever reading `session.event_log`.

Listeners are how a game adds its own reactive rules (a quest tracker, an achievement
log) without touching the kernel; see
[Listeners and flags](listeners-and-flags.md) for the extension point itself.
Expand All @@ -49,6 +56,12 @@ assert session.command_log[-1].source == "trigger:lever-east"
assert session.view(Visibility.PLAYER).journal[-1].text == "The lever grinds."
```

The library's [`Interpreter`][osrlib.crawl.interpreter.Interpreter] stamps every command
it issues this way, so a log left behind by authored content reads as a transcript with
attributions: this grant came from `trigger:idol-lifted`, that door opened for
`trigger:portcullis-rises`, and the `record_note` beside them says which consequence was
dropped and why.

## Session modes and mode gating

[`SessionMode`][osrlib.crawl.commands.SessionMode] is a small, closed set: `town`,
Expand Down
Loading
Loading