Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Added

- The lifecycle command surface an authored trigger or quest layer writes its bookkeeping with, plus the annotation that says on whose behalf a command was issued. Every `Command` gains an optional `source` — a string naming the authored object (a trigger or quest id) or the game system that issued it, never the empty string. Execution never reads it, so a stamped command does exactly what the unstamped one does; it rides the command into the log and survives a save, a load, and a replay, which is what makes "why did the party get that item?" answerable from the log alone. Three new referee commands, legal in every mode (terminal ones included) and rejecting nothing: `MarkTriggerFired` appends a trigger id to the new `session.fired_triggers`, the state that answers once-only semantics — marking an already-marked trigger is accepted, appends nothing, and still emits its referee-visibility `TriggerFiredEvent` (`session.trigger.fired`), so state records that a trigger has fired while the log records each firing; `AddJournalEntry` appends a `JournalEntry` — the authored text plus the clock position it landed at — to the new `session.journal` and emits the player-visible `JournalEntryAddedEvent` (`session.journal.entry_added`) carrying the whole entry; and `RecordNote` records an annotation with no state effect at all, emitting the referee-visibility `NoteRecordedEvent` (`session.note.recorded`) — the mechanism for machine-issued records and a referee's own margin notes alike. `PlayerView` gains `journal`, the entries shipped verbatim; the fired-marks and the notes stay referee-only, since content wiring is the game's secret. Both blocks persist under new payload keys with empty defaults, so there is no schema bump and no migration: a save written before them loads with both empty and starts remembering, and because these commands are the blocks' only writers, a replay — which runs with no listeners registered — rebuilds them exactly by re-executing the log. Nothing in the library issues these commands yet; they are the documented referee surface, and the library-shipped interpreter that drives them arrives with the authored trigger and quest layer.
- `SessionMode.VICTORY` — the second terminal mode, the session that ended by finishing what it set out to do, beside `game_over`'s ending by wipe. Both answer the new `SessionMode.terminal` property, the one place "has this session ended?" is decided, for the engine and for a front end's loop alike. The legality contract in a terminal mode: every play command is illegal (`session.command.wrong_mode`, its `mode` param carrying `victory`), and every referee command is legal — grants, awards, flags, door writes, identification, time, dice — which is what lets an adventure's rewards land after it concludes. Three referee commands are the exception, each because it would resume play in a session that is over: `SpawnMonsters` and `SpawnNpcParty` open an encounter and are illegal in both terminal modes, and `PlaceParty` teleports the party into a play mode and is illegal in `victory`. `PlaceParty` remains legal in `game_over`, where it is the salvage door — `PlaceParty(town)` then `PurchaseHealing(service="raise_dead")`, with the clock still running on the revival window. Nothing in the library transitions *into* `victory` yet; the entrance arrives with the authored quest layer, and the mode, its property, and its legality rules ship first so that transition has a contract to land on. The new enum value is additive within the current `schema_version`: a `victory` save is one an older engine has never seen, the documented accepted risk for a new serialized enum value, and there is no migration.
- Authored gates on doors and level transitions: `DoorSpec.requires` and `TransitionSpec.requires` carry a `GateSpec` — a condition the party must satisfy for opening the door or taking the stair to be a legal command. The condition vocabulary is the new discriminated union in `osrlib.crawl.gates`: `HasItemCondition` (some member's carried inventory holds an item with that catalog id — equipment or magic item, equipped slots included — optionally `consumes=True`), `FlagEqualsCondition` (a session flag holds a value, compared strictly: an absent key matches nothing, not even `False`, and a stored `True` never satisfies an authored `1`), and `EffectActiveCondition` (an active effect of that kind is attached to a party member). Evaluation is pure and level-triggered — `condition_holds` reads live state at the moment of the attempt and stores nothing, so a key dropped or sold stops opening its door — and the member domain is the whole party, living or dead, because the party carries its dead and their packs. A failed gate is an ordinary rejection with its own codes, `exploration.door.gate_refused` and `exploration.transition.gate_refused`, each carrying the author's refusal text when one was written; it is checked last, after every mundane refusal, so it fires exactly when the gate alone bars the way, and on `ForceDoor` that means a refused attempt makes no noise, denies no surprise, and rolls no die. Gates and locks are orthogonal layers: a door carrying both requires both, `PickLock` addresses only the lock, `SetDoorState` rewrites only the overlay, and a door standing open admits passage unchecked until it closes again. `validate_adventure` resolves `has_item` ids against the effective equipment catalog or the magic-item catalog; flag keys and effect kinds are open domains and stay unchecked. The fields are additive with `None` defaults, so there is no schema bump and no migration: existing documents and saves load unchanged, and an ungated adventure plays exactly as before, consuming no extra draws.
- `NarrativeBlock` (`osrlib.crawl.narrative`) — the authored text attached to a mechanical object, in three audiences: display beats a deterministic renderer shows verbatim (`refusal` and `success` for gates; `fired` for triggers; `offer`, `progress`, and `completion` for quests), a `journal` form, and `guidance` for an LLM narrator that is never displayed as written, plus a free-prose `speaker` attribution. Gates read the first two: `refusal` rides the rejection, and `success` rides the successful command's own event through the new optional `DoorEvent.narrative` and `LocationEnteredEvent.narrative` fields, which `format_message` appends verbatim after the templated line. Authored text on an event is content data in a structured field — events still carry message codes and never engine-baked English.
Expand Down
55 changes: 54 additions & 1 deletion docs/guides/listeners-and-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,39 @@ to decide whether to narrate the portcullis creaking open, say — reads `sessio
when it holds the session, or `session.view(Visibility.REFEREE).state["flags"]` when it works
from views alone.

## Lifecycle commands: fired-marks, the journal, and notes

Flags are one vocabulary a reactive listener writes with. Three more referee commands cover the
bookkeeping an authored trigger or quest layer needs, and they behave exactly like `SetFlag` —
legal in every mode, never rejected, issued through `execute`, and logged and replayed like any
other command:

- [`MarkTriggerFired`][osrlib.crawl.commands.MarkTriggerFired] records that an authored trigger has
fired, appending its id to `session.fired_triggers` — the state that answers once-only
semantics. Marking a trigger that has already fired is accepted, appends nothing, and still
emits its [`TriggerFiredEvent`][osrlib.crawl.events.TriggerFiredEvent], so a repeatable
trigger's every firing shows up in the log while the state stays a list of ids in first-fired
order.
- [`AddJournalEntry`][osrlib.crawl.commands.AddJournalEntry] appends a beat to `session.journal`,
stamped with the clock position it landed at. The journal is the one part of this vocabulary the
players see: it ships verbatim in the [`PlayerView`][osrlib.crawl.views.PlayerView], and its
[`JournalEntryAddedEvent`][osrlib.crawl.events.JournalEntryAddedEvent] is player-visible.
- [`RecordNote`][osrlib.crawl.commands.RecordNote] records an annotation with no state effect at
all — the mechanism for machine-issued records (a consequence that was dropped, a cascade cut
short) and for a referee's own margin notes alike. Its event is referee-visibility, like the
fired-mark's.

Both blocks are engine-owned session state: they persist in saves, and because these commands are
their only writers, a replay — which runs with no listeners registered — rebuilds them exactly by
re-executing the log. That is also why a listener must act by issuing commands rather than by
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.

## The fetch quest, worked

The TUI crawler (see [the complete front end](../front-ends/tui-crawler.md)) hides a named
Expand Down Expand Up @@ -141,7 +174,14 @@ from osrlib.core.events import Event, Visibility
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, MoveParty, SetFlag
from osrlib.crawl.commands import (
AddJournalEntry,
EnterDungeon,
MarkTriggerFired,
MoveParty,
RecordNote,
SetFlag,
)
from osrlib.crawl.dungeon import Direction, DungeonSpec, Edge, EdgeKind, LevelSpec
from osrlib.crawl.events import PartyMovedEvent
from osrlib.crawl.party import Party
Expand Down Expand Up @@ -194,6 +234,19 @@ assert session.flags == {"crypt.lever_pulled": True}
# A front end working from views alone reads flags off the referee view instead.
referee_state = session.view(Visibility.REFEREE).state
assert referee_state["flags"] == {"crypt.lever_pulled": True}

# The lifecycle vocabulary: mark the trigger, write the beat, annotate the margin. The
# source stamp says on whose behalf each command was issued.
session.execute(MarkTriggerFired(trigger_id="crypt.lever", source="trigger:crypt.lever"))
session.execute(AddJournalEntry(text="The lever grinds.", source="trigger:crypt.lever"))
session.execute(MarkTriggerFired(trigger_id="crypt.lever", source="trigger:crypt.lever"))
session.execute(RecordNote(text="The portcullis consequence had nothing to open."))

# A re-mark appends nothing; the journal is player-visible state, the marks are not.
assert session.fired_triggers == ["crypt.lever"]
assert [entry.text for entry in session.journal] == ["The lever grinds."]
assert session.view(Visibility.PLAYER).journal == tuple(session.journal)
assert session.command_log[-1].source is None # the note was the referee's own
```

## Where next
Expand Down
28 changes: 26 additions & 2 deletions docs/guides/sessions-commands-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,20 @@ Listeners are how a game adds its own reactive rules (a quest tracker, an achiev
log) without touching the kernel; see
[Listeners and flags](listeners-and-flags.md) for the extension point itself.

Every command also carries an optional `source`: a string naming the authored object —
a trigger or quest id — or the game system on whose behalf the command was issued.
Execution never reads it, so a stamped command does exactly what the unstamped one
does; it rides the command into the log and survives a save, a load, and a replay. The
log therefore records not just *who* acted but *on whose behalf*, which is what makes
"why did the party get that item?" answerable from the log alone:

```{.python .no-run}
# The source stamp annotates the log and changes nothing about execution.
session.execute(AddJournalEntry(text="The lever grinds.", source="trigger:lever-east"))
assert session.command_log[-1].source == "trigger:lever-east"
assert session.view(Visibility.PLAYER).journal[-1].text == "The lever grinds."
```

## Session modes and mode gating

[`SessionMode`][osrlib.crawl.commands.SessionMode] is a small, closed set: `town`,
Expand All @@ -56,7 +70,11 @@ Commands that make sense both at rest and on the move (`ReorderParty`, `LightSou
`ResolveBattleRound` requires `battle`. A handful, like `DropItems`, span two modes on
purpose — dropping treasure to distract pursuers works whether the party is still
exploring or already in an encounter. Referee commands (`GrantItem`, `SetFlag`,
`AwardXP`, `AdvanceTime`, and the rest of the session-owned surface) are legal in
`AwardXP`, `AdvanceTime`, the lifecycle trio
[`MarkTriggerFired`][osrlib.crawl.commands.MarkTriggerFired],
[`AddJournalEntry`][osrlib.crawl.commands.AddJournalEntry] and
[`RecordNote`][osrlib.crawl.commands.RecordNote], and the rest of the
session-owned surface) are legal in
every mode, the two terminal ones included — a referee correcting the world
doesn't stop just because the party fell, and an adventure's rewards can land
after it ends. Three of them are the exception, each because it would resume play
Expand Down Expand Up @@ -216,10 +234,11 @@ crawl events together and is what the session's own log uses.
```python
from osrlib.core.alignment import Alignment
from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character
from osrlib.core.events import Visibility
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, MoveParty, SessionMode, parse_command
from osrlib.crawl.commands import AddJournalEntry, EnterDungeon, MoveParty, SessionMode, parse_command
from osrlib.crawl.dungeon import Direction, DungeonSpec, Edge, EdgeKind, LevelSpec
from osrlib.crawl.events import parse_any_event
from osrlib.crawl.party import Party
Expand Down Expand Up @@ -274,6 +293,11 @@ assert result.accepted
lines = [format_message(event) for event in result.events]
assert lines # every accepted command's events format to a default English line

# The source stamp annotates the log and changes nothing about execution.
session.execute(AddJournalEntry(text="The lever grinds.", source="trigger:lever-east"))
assert session.command_log[-1].source == "trigger:lever-east"
assert session.view(Visibility.PLAYER).journal[-1].text == "The lever grinds."

# Commands and events round-trip through their wire discriminator; unknown types parse to None.
move_payload = MoveParty(direction=Direction.EAST).model_dump(mode="json")
assert parse_command(move_payload) == MoveParty(direction=Direction.EAST)
Expand Down
46 changes: 44 additions & 2 deletions docs/guides/views-and-visibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ that streams or narrates the raw event log as it happens — an LLM referee doin
turn-by-turn narration, say — is responsible for checking `.visibility` itself before
showing an event to a player, the same way it would filter a database query.

The authored layer splits the same way. A journal beat is written for the table, so
[`JournalEntryAddedEvent`][osrlib.crawl.events.JournalEntryAddedEvent] is
player-visible and carries the authored text itself — content data in a structured
field, alongside the event's message code, never engine-baked English. The wiring that
produced the beat is not: a fired trigger
([`TriggerFiredEvent`][osrlib.crawl.events.TriggerFiredEvent]) and a referee note
([`NoteRecordedEvent`][osrlib.crawl.events.NoteRecordedEvent]) are referee-visibility,
exactly as a flag write is, because content wiring is the game's secret.

Most front ends never need to do that filtering by hand, though, because osrlib also
ships two ready-made projections of the *whole session*, one per audience, and either
one already applies this filtering for its consumer.
Expand Down Expand Up @@ -54,7 +63,10 @@ a plain wall throughout ([`ExploredLevelView`][osrlib.crawl.views.ExploredLevelV
and [`EdgeView`][osrlib.crawl.views.EdgeView]); known dropped piles and emptied
treasure caches in that explored space; active effects on party members with their
remaining duration (except a potion's — RAW has the referee track that secretly, so
the view reports it as unknown); fatigue, exhaustion, and deprivation status; and, when
the view reports it as unknown); fatigue, exhaustion, and deprivation status; the
session journal as written ([`JournalEntry`][osrlib.crawl.session.JournalEntry] — the
beats in order of discovery, each carrying the clock position it landed at, while the
trigger fired-marks behind them stay out of the view entirely); and, when
one is running, the current encounter or battle's public shape
([`EncounterView`][osrlib.crawl.views.EncounterView] and
[`EncounterGroupView`][osrlib.crawl.views.EncounterGroupView] — a monster group's id,
Expand Down Expand Up @@ -88,6 +100,16 @@ assert player_group.count == 1
assert "current_hp" not in player_group.model_dump()
```

The authored layer shows the same shape from the other side: the journal reaches the
player view whole, while the trigger that wrote it does not reach it at all.

```{.python .no-run}
# The beat is for the table; the trigger that produced it is referee-only wiring.
assert [entry.text for entry in journal_view.journal] == ["The lever grinds."]
assert "lever-east" not in journal_view.model_dump_json()
assert referee_state["fired_triggers"] == ["lever-east"]
```

## Never trust the client

The moment a game goes over a network, this split becomes a security boundary, not
Expand All @@ -112,7 +134,14 @@ from osrlib.core.events import Visibility
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, SessionMode, SpawnMonsters
from osrlib.crawl.commands import (
AddJournalEntry,
EnterDungeon,
MarkTriggerFired,
RecordNote,
SessionMode,
SpawnMonsters,
)
from osrlib.crawl.dungeon import DungeonSpec, LevelSpec
from osrlib.crawl.party import Party
from osrlib.crawl.session import GameSession
Expand Down Expand Up @@ -151,6 +180,19 @@ assert "current_hp" in referee_monster
player_group = player_view.encounter.groups[0]
assert player_group.count == 1
assert "current_hp" not in player_group.model_dump()

# A trigger fires: it is marked, it writes a journal beat, and the referee annotates it.
session.execute(MarkTriggerFired(trigger_id="lever-east"))
session.execute(AddJournalEntry(text="The lever grinds.", source="trigger:lever-east"))
session.execute(RecordNote(text="The east lever is the only one that answers."))

journal_view = session.view(Visibility.PLAYER)
referee_state = session.view(Visibility.REFEREE).state

# The beat is for the table; the trigger that produced it is referee-only wiring.
assert [entry.text for entry in journal_view.journal] == ["The lever grinds."]
assert "lever-east" not in journal_view.model_dump_json()
assert referee_state["fired_triggers"] == ["lever-east"]
```

## Where next
Expand Down
Loading
Loading