From f2f9ca607d9de2f899be237a690bf509a6f1718a Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Sun, 13 Sep 2026 22:16:55 -0700 Subject: [PATCH 1/2] Document the crawl hooks and views for the API reference Rewrite the docstrings of gates, triggers, quests, narrative, the interpreter, and views for a developer reading the published reference. Each module docstring now opens with where the thing sits: the authoring model that holds it, the part of the session that evaluates it, and the events that report the result. Every pydantic field gets a PEP 224 attribute docstring so the reference renders prose instead of a bare name, and the runnable examples were all run against the code. Correct two claims in the interpreter that disagreed with the code: the cascade bound suppresses issuing, not matching, and the walk keeps evaluating clauses past the bound. Claude-Session: https://claude.ai/code/session_01GL26QnA6dCrvUc3WmhzFSa --- src/osrlib/crawl/gates.py | 268 ++++++++++++++----- src/osrlib/crawl/interpreter.py | 279 ++++++++++++------- src/osrlib/crawl/narrative.py | 139 ++++++---- src/osrlib/crawl/quests.py | 188 ++++++++----- src/osrlib/crawl/triggers.py | 315 ++++++++++++++++------ src/osrlib/crawl/views.py | 456 +++++++++++++++++++++++++------- 6 files changed, 1188 insertions(+), 457 deletions(-) diff --git a/src/osrlib/crawl/gates.py b/src/osrlib/crawl/gates.py index 98a9545..4232c38 100644 --- a/src/osrlib/crawl/gates.py +++ b/src/osrlib/crawl/gates.py @@ -1,32 +1,52 @@ """Authored gates: the condition vocabulary, the gate model, and pure evaluation. -A gate is an authored predicate the engine checks when the party *attempts* -something — opening a door, taking a stair. Its two carriers are the `requires` -fields of [`DoorSpec`][osrlib.crawl.dungeon.DoorSpec] and -[`TransitionSpec`][osrlib.crawl.dungeon.TransitionSpec] — a gate hangs nowhere -else. It is a stateless content object: -[`condition_holds`][osrlib.crawl.gates.condition_holds] reads live session state -at the moment of the attempt and stores nothing, so a key dropped or sold stops -opening its door and evaluation never drifts from the truth. +A gate is an authored predicate the engine checks when the party attempts something, +such as opening a door or taking a stair. + +Where a gate sits. You write a [`GateSpec`][osrlib.crawl.gates.GateSpec] into the +`requires` field of a [`DoorSpec`][osrlib.crawl.dungeon.DoorSpec] or a +[`TransitionSpec`][osrlib.crawl.dungeon.TransitionSpec], inside the dungeon geometry of +an [`Adventure`][osrlib.crawl.adventure.Adventure]. A gate hangs nowhere else. The +exploration handlers behind +[`GameSession.execute`][osrlib.crawl.session.GameSession.execute] evaluate it as the +last validation step of [`OpenDoor`][osrlib.crawl.commands.OpenDoor], +[`ForceDoor`][osrlib.crawl.commands.ForceDoor], and +[`UseStairs`][osrlib.crawl.commands.UseStairs]. A gate that refuses produces the +rejection `exploration.door.gate_refused` or `exploration.transition.gate_refused`, +which includes the authored refusal beat, and no event at all. A gate that opens puts its +success beat on the command's own event: a +[`DoorEvent`][osrlib.crawl.events.DoorEvent] for a door, a +[`LocationEnteredEvent`][osrlib.crawl.events.LocationEnteredEvent] for a transition +that crosses into a new level or dungeon. A gate that charges a toll reports it as an +[`ItemConsumedEvent`][osrlib.crawl.events.ItemConsumedEvent] ahead of that event. + +A gate is stateless content. [`condition_holds`][osrlib.crawl.gates.condition_holds] +reads live session state at the moment of the attempt and stores nothing, so a key that +gets dropped or sold stops opening its door. The condition vocabulary is a discriminated union that grows additively: -- [`HasItemCondition`][osrlib.crawl.gates.HasItemCondition] — some party member's - carried inventory holds an item with that catalog id - ([`Inventory.carried_item`][osrlib.core.items.Inventory.carried_item] is the - matching rule, equipped slots and all). With `consumes=True`, the successful - command takes one instance from the first holder in marching order. -- [`FlagEqualsCondition`][osrlib.crawl.gates.FlagEqualsCondition] — a session flag - ([`SetFlag`][osrlib.crawl.commands.SetFlag]) holds a value. -- [`EffectActiveCondition`][osrlib.crawl.gates.EffectActiveCondition] — an active - effect of that kind is attached to a party member, so an author can ask for the - talisman invoked rather than merely carried. - -Gates and locks are orthogonal layers: `locked` is stateful mechanics with dice -and per-character memory, a gate is a stateless authored predicate, and a door -carrying both requires both. Evaluation is pure — it draws no dice, costs no game -time, and mutates nothing — which is what lets a gate refusal be an ordinary -command rejection. +- [`HasItemCondition`][osrlib.crawl.gates.HasItemCondition]: some party member's carried + inventory contains an item with that catalog id + ([`Inventory.carried_item`][osrlib.core.items.Inventory.carried_item] is the matching + rule, equipped slots included). With `consumes=True`, the successful command takes one + instance from the first holder in marching order. +- [`FlagEqualsCondition`][osrlib.crawl.gates.FlagEqualsCondition]: a session flag, which + your game writes with [`SetFlag`][osrlib.crawl.commands.SetFlag], is set to a value. +- [`EffectActiveCondition`][osrlib.crawl.gates.EffectActiveCondition]: an active effect + of that kind is attached to a party member, so you can ask for the talisman invoked + rather than merely carried. + +Gates and locks are separate layers. A lock is stateful mechanics with dice and +per-character memory. A gate is a stateless authored predicate. A door with both +requires both. Evaluation draws no dice, costs no game time, and mutates nothing, which +is what lets a gate refusal be an ordinary command rejection. + +Use a gate when the party has to satisfy a condition to get through. When you want +something to happen because the party already did something, author a trigger +([`TriggerSpec`][osrlib.crawl.triggers.TriggerSpec]) instead. The guide +[Gates, triggers, and quests](https://mmacy.github.io/osrlib-python/guides/gates-triggers-quests/) +walks all three from an adventure document you can run. """ from collections.abc import Mapping, Sequence @@ -51,73 +71,169 @@ class HasItemCondition(BaseModel): - """The party carries an item with `item_id` — equipment or magic item. + """The party carries an item with `item_id`, equipment or magic item alike. - Any member's carried inventory satisfies it, equipped slots included: carrying - is the whole test. `consumes=True` makes the item a toll — one instance leaves - the first holder in marching order when the gated command succeeds, per - success, so a door that swings shut wants another key. + Write this into the `condition` of a [`GateSpec`][osrlib.crawl.gates.GateSpec] for + the door that needs a key, and into the `conditions` of a + [`TriggerSpec`][osrlib.crawl.triggers.TriggerSpec] or a quest's + [`TriggerClause`][osrlib.crawl.quests.TriggerClause] to narrow a firing to a party + that is still carrying something. Any member's carried inventory satisfies it, + equipped slots included, because carrying is the test. The party carries its + dead and their packs, so a key in a dead member's pack still counts. + + `item_id` has to resolve against the effective equipment catalog, which is the + shipped catalog plus the adventure's own bundled items, or against the magic-item + catalog. A gate naming an id that is in neither catalog fails adventure validation. + + Examples: + ```python + from osrlib.crawl.gates import HasItemCondition + + brass_key = HasItemCondition(item_id="brass_key") + assert not brass_key.consumes # carrying is enough, and nothing is taken + ``` """ model_config = ConfigDict(frozen=True) condition_type: Literal["has_item"] = "has_item" + """The discriminator value, `has_item`. It appears in every document that + includes this condition, and you never set it yourself.""" item_id: str = Field(min_length=1) + """The catalog id to look for, from the equipment catalog (shipped items plus the + adventure's bundled ones) or the magic-item catalog.""" consumes: bool = False + """Whether a success takes the item. `True` makes it a toll: one instance leaves the + first holder in marching order every time the gated command succeeds, so a door that + swings shut needs another key. A trigger's or a quest's conditions reject `True` at + parse, because they observe an event that has already happened and have no attempt + of their own to charge against.""" class FlagEqualsCondition(BaseModel): - """A session flag holds `value` — the lever that opens the portcullis. + """A session flag holds `value`: the lever that opens the portcullis. - The comparison is - [`flag_values_equal`][osrlib.crawl.gates.flag_values_equal] and it is strict: an - absent key equals nothing (`False` included), and a stored `True` never matches - an authored `1`. + Your game writes flags with [`SetFlag`][osrlib.crawl.commands.SetFlag], and a + trigger's consequence can write one too, so this is how a lever in one room governs + a door in another. The comparison runs through + [`flag_values_equal`][osrlib.crawl.gates.flag_values_equal] and it is strict: a key + that was never written equals nothing, `False` included, and a stored `True` never + matches an authored `1`. + + Examples: + ```python + from osrlib.crawl.gates import FlagEqualsCondition + + raised = FlagEqualsCondition(key="crypt.portcullis", value="raised") + assert raised.condition_type == "flag_equals" + ``` """ model_config = ConfigDict(frozen=True) condition_type: Literal["flag_equals"] = "flag_equals" + """The discriminator value, `flag_equals`. It appears in every document that + includes this condition, and you never set it yourself.""" key: str = Field(min_length=1) + """The flag name to read from the session flag store.""" value: str | int | bool + """The value the flag has to hold. The comparison keeps types apart, so author the + same type your [`SetFlag`][osrlib.crawl.commands.SetFlag] writes.""" class EffectActiveCondition(BaseModel): """An active effect of `kind` is attached to some party member. - Effect kinds are an open, data-driven vocabulary (`"fatigue"`, a custom - [`EffectDefinition.kind`][osrlib.core.effects.EffectDefinition]), so the field - is a free string. Effects attached to a location never count — the test is - what the party carries in its bones. + Use it when the talisman has to be invoked rather than merely carried: the item + grants an effect, and the gate asks for the effect. The session's effects ledger + ([`EffectsLedger`][osrlib.core.effects.EffectsLedger]) is what gets read. Effects + attached to a location never count, because the test is for an effect on a party + member. + + Examples: + ```python + from osrlib.crawl.gates import EffectActiveCondition + + warded = EffectActiveCondition(kind="ward_of_the_deep") + assert warded.condition_type == "effect_active" + ``` """ model_config = ConfigDict(frozen=True) condition_type: Literal["effect_active"] = "effect_active" + """The discriminator value, `effect_active`. It appears in every document that + includes this condition, and you never set it yourself.""" kind: str = Field(min_length=1) + """The effect kind to look for. Effect kinds are an open, data-driven vocabulary + (`"fatigue"`, or the `kind` of an + [`EffectDefinition`][osrlib.core.effects.EffectDefinition] you wrote), so the field + is a free string and nothing validates it against a catalog.""" ConditionSpec = Annotated[ HasItemCondition | FlagEqualsCondition | EffectActiveCondition, Field(discriminator="condition_type"), ] -"""The condition union, discriminated on `condition_type` (`has_item`, `flag_equals`, -`effect_active`). New kinds join it additively; the discriminator values are wire -values and serialize into every document that carries a gate.""" +"""The condition union, discriminated on `condition_type`. + +Its members are [`HasItemCondition`][osrlib.crawl.gates.HasItemCondition] +(`has_item`), [`FlagEqualsCondition`][osrlib.crawl.gates.FlagEqualsCondition] +(`flag_equals`), and +[`EffectActiveCondition`][osrlib.crawl.gates.EffectActiveCondition] (`effect_active`). +Annotate a field with this alias when you write your own model that holds authored +conditions. Pydantic then picks the member from the `condition_type` value in the +document. New kinds join the union additively, and the discriminator values are wire +values that appear in every document that includes a gate. + +A condition is read on the `condition` of a +[`GateSpec`][osrlib.crawl.gates.GateSpec], on the `conditions` of a +[`TriggerSpec`][osrlib.crawl.triggers.TriggerSpec], and on the `conditions` of a quest's +[`TriggerClause`][osrlib.crawl.quests.TriggerClause], and each of those evaluates through +[`condition_holds`][osrlib.crawl.gates.condition_holds].""" class GateSpec(BaseModel): """A condition guarding an attempt, with the authored text for both outcomes. - The gate is the object the narrative hangs on: a refused attempt returns the - block's `refusal` beat in its rejection, and a successful one rides the - `success` beat on the command's event. + Put one in the `requires` field of a + [`DoorSpec`][osrlib.crawl.dungeon.DoorSpec] or a + [`TransitionSpec`][osrlib.crawl.dungeon.TransitionSpec] when you build the dungeon, + and the engine checks it every time the party tries that door or stair. A refused + attempt returns the `refusal` beat inside its rejection and costs the party nothing: + no dice, no game time, no item, and no change to the door. A successful attempt puts + the `success` beat on the command's own event. + + A door standing open is not checked, so a gate on a door your game opens with + [`SetDoorState`][osrlib.crawl.commands.SetDoorState] applies again only once the + door closes. + + Examples: + ```python + from osrlib.crawl.gates import GateSpec, HasItemCondition + from osrlib.crawl.narrative import NarrativeBlock + + sentinel = GateSpec( + condition=HasItemCondition(item_id="brass_key"), + narrative=NarrativeBlock( + refusal="The bronze sentinel folds its arms. Brass, it says. Brass or nothing.", + success="The brass key turns in the sentinel's palm and the door swings wide.", + ), + ) + assert sentinel.condition.item_id == "brass_key" + ``` """ model_config = ConfigDict(frozen=True) condition: ConditionSpec + """The predicate the party has to satisfy. Exactly one condition, evaluated live at + the moment of the attempt. To ask for two things at once, put the second condition on + a trigger that writes the flag this gate reads.""" narrative: NarrativeBlock | None = None + """The authored text for both outcomes. A gate reads two beats of the block, + `refusal` and `success`; the rest are left alone. `None` gates the way with no words + at all, and the rejection then includes no refusal text.""" def condition_holds( @@ -129,18 +245,26 @@ def condition_holds( ) -> bool: """Evaluate one condition against live session state. - Pure: no draws, no clock, no mutation, nothing stored. Every walk is over an - ordered list — the party in marching order, each inventory in carried order — - so the answer is deterministic. + Every surface that asks whether an authored condition holds asks through here: the + gate on a door or a stair, a [`TriggerSpec`][osrlib.crawl.triggers.TriggerSpec]'s + conditions, and a quest [`TriggerClause`][osrlib.crawl.quests.TriggerClause]'s. You + rarely call it yourself, because the engine calls it for you at the moment of an + attempt and the [`Interpreter`][osrlib.crawl.interpreter.Interpreter] calls it at + the moment of a match. Call it directly when your own listener wants to ask the same + question the engine asks, or to check an authored document against a session in a + test. - The member domain is every party member, living or dead. The party carries its - dead and their packs, so a key that rides a corpse still opens its door. + The call draws no dice, advances no clock, mutates nothing, and stores nothing. + Every walk is over an ordered list, the party in marching order and each inventory + in carried order, so the answer is deterministic. The member domain is every party + member, living or dead, because the party carries its dead and their packs. Args: - condition: The condition to evaluate. - members: The party, in marching order. - flags: The session flag store. - ledger: The session's effects ledger. + condition: The condition to evaluate, one member of + [`ConditionSpec`][osrlib.crawl.gates.ConditionSpec]. + members: The party, in marching order, from `session.party.members`. + flags: The session flag store, from `session.flags`. + ledger: The session's effects ledger, from `session.ledger`. Returns: True when the condition holds right now. @@ -167,16 +291,20 @@ def condition_holds( def flag_values_equal(stored: str | int | bool, expected: str | int | bool) -> bool: """Compare two flag values the one strict way the engine compares them. - Equality plus matching boolness. `True == 1` in Python, so a flag somebody set - to `True` must not satisfy an authored `1`, and the reverse: the two are - different values in an authored document even though Python calls them equal. - Every surface that asks "does this flag hold that value" — - [`FlagEqualsCondition`][osrlib.crawl.gates.FlagEqualsCondition] on a gate, an - authored trigger's flag pattern — asks through here, so a condition and a - pattern can never disagree about what equality means. + The test is equality plus matching boolness. `True == 1` in Python, so a flag your + game set to `True` does not satisfy an authored `1`, and an authored `True` does not + match a stored `1`: they are different values in an authored document even though + Python calls them equal. Call it when your own code has to answer the same question + about a flag that the engine answers. + + Every surface that asks whether a flag holds a value asks through here, which is why + [`FlagEqualsCondition`][osrlib.crawl.gates.FlagEqualsCondition] on a gate and + [`FlagSetPattern`][osrlib.crawl.triggers.FlagSetPattern] on a trigger can never + disagree about what equality means. Args: - stored: The value the flag store holds (or the value an event reports written). + stored: The value the flag store holds, or the value a + [`FlagSetEvent`][osrlib.crawl.events.FlagSetEvent] reports written. expected: The value the author wrote. Returns: @@ -195,17 +323,27 @@ def flag_values_equal(stored: str | int | bool, expected: str | int | bool) -> b def first_holder(members: Sequence[Character], item_id: str) -> Character | None: - """The first member in marching order carrying an item with `item_id`. + """Return the first member in marching order carrying an item with `item_id`. - The consumption target of a `has_item` toll, matching exactly what - [`condition_holds`][osrlib.crawl.gates.condition_holds] tests. + This is the consumption target of a `has_item` toll, and it matches exactly what + [`condition_holds`][osrlib.crawl.gates.condition_holds] tests, so the member it + names is the one the engine charges. Call it when your own code has to name the + holder the engine would charge. The search is over every member, living or dead, and + over each inventory in carried order, equipped slots included. Args: - members: The party, in marching order. + members: The party, in marching order, from `session.party.members`. item_id: The catalog id to look for. Returns: The member, or `None` when nobody carries one. + + Examples: + ```python + from osrlib.crawl.gates import first_holder + + assert first_holder([], "brass_key") is None + ``` """ for member in members: if member.inventory.carried_item(item_id) is not None: diff --git a/src/osrlib/crawl/interpreter.py b/src/osrlib/crawl/interpreter.py index 706e524..3700c7a 100644 --- a/src/osrlib/crawl/interpreter.py +++ b/src/osrlib/crawl/interpreter.py @@ -1,19 +1,39 @@ """The interpreter: the listener that plays an adventure's authored triggers and quests. -[`Interpreter`][osrlib.crawl.interpreter.Interpreter] is an ordinary listener the -game registers on its session -([`GameSession.register_listener`][osrlib.crawl.session.GameSession.register_listener]). -It watches the events of every accepted command, matches them against the adventure's +[`Interpreter`][osrlib.crawl.interpreter.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`][osrlib.crawl.adventure.Adventure] the session is playing, and it is an +ordinary listener your game registers with +[`GameSession.register_listener`][osrlib.crawl.session.GameSession.register_listener]. It +watches the events of every accepted command, matches them against the adventure's [`TriggerSpec`][osrlib.crawl.triggers.TriggerSpec]s and -[`QuestSpec`][osrlib.crawl.quests.QuestSpec]s, 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. +[`QuestSpec`][osrlib.crawl.quests.QuestSpec]s, 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`][osrlib.crawl.events.TriggerFiredEvent], +[`JournalEntryAddedEvent`][osrlib.crawl.events.JournalEntryAddedEvent], +[`QuestActivatedEvent`][osrlib.crawl.events.QuestActivatedEvent], +[`ObjectiveRevealedEvent`][osrlib.crawl.events.ObjectiveRevealedEvent], +[`ObjectiveCompletedEvent`][osrlib.crawl.events.ObjectiveCompletedEvent], +[`QuestCompletedEvent`][osrlib.crawl.events.QuestCompletedEvent], +[`AdventureCompletedEvent`][osrlib.crawl.events.AdventureCompletedEvent], and +[`NoteRecordedEvent`][osrlib.crawl.events.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 remembers 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. +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](https://mmacy.github.io/osrlib-python/guides/listeners-and-flags/) +covers the listener contract, and +[Gates, triggers, and quests](https://mmacy.github.io/osrlib-python/guides/gates-triggers-quests/) +covers what this one plays. """ from collections.abc import Sequence @@ -57,9 +77,10 @@ ] _MAX_MATCH_DEPTH = 4 -"""The deepest events a trigger or a quest clause still matches. The events of a +"""The deepest events a firing or a quest advancement still acts on. The events of a player's command are depth 0, and what a firing or a quest advancement issues is one -deeper than the event that caused it.""" +level deeper than the event that caused it, so an event deeper than this is evaluated +and then recorded as a note instead of being acted on.""" class _Owner(NamedTuple): @@ -73,10 +94,11 @@ class _Owner(NamedTuple): """`"trigger"` or `"quest"`.""" id: str + """The authored id of that trigger or quest.""" @property def stamp(self) -> str: - """The `source` every command this owner causes carries: `trigger:{id}`, `quest:{id}`.""" + """The `source` on every command this owner causes: `trigger:{id}`, `quest:{id}`.""" return f"{self.kind}:{self.id}" @property @@ -97,7 +119,7 @@ def _matches_area_entered(pattern: AreaEnteredPattern, event: Event) -> bool: def _matches_level_entered(pattern: LevelEnteredPattern, event: Event) -> bool: - """The party arrived on that level of that dungeon — by stair or by dungeon entry. + """The party arrived on that level of that dungeon, by stair or by dungeon entry. A crossing reports the coarsest boundary it passed, so a party coming in from town reports a dungeon entry and never a level entry beneath it. Both kinds match here: @@ -148,16 +170,16 @@ def _matches_item_acquired(pattern: ItemAcquiredPattern, event: Event, session: def _matches_monster_defeated(pattern: MonsterDefeatedPattern, event: Event) -> bool: - """A monster of that template was defeated — slain, routed, or surrendered alike.""" + """A monster of that template was defeated: slain, routed, or surrendered alike.""" return isinstance(event, MonsterDefeatedEvent) and event.template_id == pattern.template_id def _matches_flag_set(pattern: FlagSetPattern, event: Event) -> bool: - """That flag was written — with that value, or with any value at all. + """That flag was written, with that value or with any value at all. - The comparison is against the value the write carried, not the value the flag - holds now: a trigger watches the edge, and a consequence earlier in the same batch - may already have written the key again. + The comparison is against the value the write set, not the value the flag has now: a + trigger watches the edge, and a consequence earlier in the same batch may already have + written the key again. """ if not isinstance(event, FlagSetEvent) or event.key != pattern.key: return False @@ -195,96 +217,156 @@ class Interpreter: session.register_listener(Interpreter(session)) ``` - Registering twice fires everything twice — the same rule every listener follows — + 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 carries data. Nothing migrates: the interpreter's slot in - `listener_state` is empty and stays empty forever. - - **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 — one rule, and the only order there is. 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`][osrlib.crawl.commands.MarkTriggerFired], carrying 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. + 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`][osrlib.crawl.commands.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. + living members they name, so the log records concrete character ids and replays + exactly. 3. [`AddJournalEntry`][osrlib.crawl.commands.AddJournalEntry] when the trigger's - narrative carries a journal form, last, so the beat is stamped with the clock - the consequences left behind. + 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`][osrlib.crawl.quests.TriggerClause]) is matched exactly - the way a trigger is — the same patterns, the same live conditions — and the walk + What a quest walk issues, all of it stamped `source="quest:{id}"`. A quest clause + ([`TriggerClause`][osrlib.crawl.quests.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`][osrlib.crawl.commands.ActivateQuest], and the walk carries on - into the objectives of the quest it just activated: the same event that starts - a quest can finish something in it. + [`ActivateQuest`][osrlib.crawl.commands.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`][osrlib.crawl.commands.RevealObjective]; an incomplete + [`RevealObjective`][osrlib.crawl.commands.RevealObjective], and an incomplete objective whose `when` matches gets - [`CompleteObjective`][osrlib.crawl.commands.CompleteObjective]. An objective - that completes without ever being revealed needs no reveal — completing shows + [`CompleteObjective`][osrlib.crawl.commands.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`][osrlib.crawl.commands.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. - - **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 referee who completes the last - objective by hand completes the quest by hand too. For the same reason a - hand-driven [`CompleteQuest`][osrlib.crawl.commands.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 — a spawn that meets an open - encounter, a grant to a character who is not there — and a + 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`][osrlib.crawl.commands.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. + + 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`][osrlib.crawl.commands.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`][osrlib.crawl.commands.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. And a cascade is bounded: what a firing or a quest advancement issues is one - deeper than the event that caused it, matching stops below depth five, and every - advancement the bound suppresses is recorded as a note instead of being issued — - no state moves, 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: the suppressed edge 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, + 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, so the walk still evaluates + every trigger and every clause and records each suppressed advancement as a note + instead of issuing it. No state moves when that happens, 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: + ```python + 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.'] + ``` """ key = "osrlib.interpreter" - """The listener key; its state entry exists because registration creates one, and - is the empty dict for the life of the session.""" + """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.""" def __init__(self, session: GameSession) -> None: """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`][osrlib.crawl.session.GameSession.register_listener], + and do the same again after loading a save. One interpreter serves one session. + Args: - session: The session; its adventure's triggers and quests are read once - here, being frozen content. + session: The session to play. Its adventure's triggers and quests are read + once here, being frozen content. """ self._session = session self._triggers = session.adventure.triggers @@ -294,13 +376,18 @@ def __init__(self, session: GameSession) -> None: def handle(self, 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. + Args: events: The command's accumulated events, in the order they happened. state: The listener's state slot, always the empty dict. Returns: - No events and the empty state — everything the interpreter does is a - command it executed, and it remembers nothing. + No events and the empty state. Everything the interpreter does is a command + it executed, and it remembers nothing between commands. """ depth = self._depth for event in events: @@ -369,7 +456,7 @@ def _advance(self, quest: QuestSpec, event: Event, depth: int) -> None: """Walk one quest against one event: activation, reveals, completions, the rule. Everything the walk issues runs one level deeper than the event that caused it, - exactly as a firing does; past the bound the walk still evaluates every clause + exactly as a firing does. Past the bound the walk still evaluates every clause and records what it would have issued instead of issuing it. """ owner = _Owner("quest", quest.id) @@ -429,7 +516,7 @@ def _rule_satisfied(quest: QuestSpec, state: QuestState) -> bool: # ------------------------------------------------------------------ issuing def _issue_authored(self, authored: Sequence[Command], owner: _Owner, slot: str) -> None: - """Issue an authored sequence — a trigger's consequences, a quest's rewards. + """Issue an authored sequence: a trigger's consequences, or a quest's rewards. In authored order, selectors expanded to the members they name, each command standing or dropping on its own so one rejection never stops the rest. @@ -465,7 +552,7 @@ def _note_drop(self, owner: _Owner, site: str, command: Command, reason: str) -> self._issue(RecordNote(text=f"{owner.label}: {site} ({command.command_type}) dropped ({reason})"), owner) def _truncated(self, owner: _Owner, what: str, depth: int) -> None: - """Record what the cascade bound suppressed; nothing moved, so it can happen again.""" + """Record what the cascade bound suppressed. Nothing moved, so it can happen again.""" self._issue( RecordNote( text=(f"{owner.label}: {what}, the cascade reached depth {depth} past the limit of {_MAX_MATCH_DEPTH}") @@ -476,7 +563,7 @@ def _truncated(self, owner: _Owner, what: str, depth: int) -> None: def _issue(self, command: Command, owner: _Owner) -> CommandResult: """Execute one command on the trigger's or quest's behalf, stamped with its id. - Commands are frozen, so the stamp is a copy — the authored consequence in the - document is never touched. + Commands are frozen, so the stamp is a copy, and the authored consequence in + the document is never touched. """ return self._session.execute(command.model_copy(update={"source": owner.stamp})) diff --git a/src/osrlib/crawl/narrative.py b/src/osrlib/crawl/narrative.py index 71fde5c..2d9a6fd 100644 --- a/src/osrlib/crawl/narrative.py +++ b/src/osrlib/crawl/narrative.py @@ -1,31 +1,45 @@ """Authored narrative attached to mechanical objects: the three-audience block. -A [`NarrativeBlock`][osrlib.crawl.narrative.NarrativeBlock] is content a game's -author hangs on a mechanical object — a gate +A [`NarrativeBlock`][osrlib.crawl.narrative.NarrativeBlock] is the authored text you +hang on a mechanical object. + +Where a block sits. You write one into the `narrative` field of a gate ([`GateSpec`][osrlib.crawl.gates.GateSpec]), a trigger -([`TriggerSpec`][osrlib.crawl.triggers.TriggerSpec]), a quest or one of its -objectives ([`QuestSpec`][osrlib.crawl.quests.QuestSpec], -[`ObjectiveSpec`][osrlib.crawl.quests.ObjectiveSpec]) — and it is inert data: it -decides nothing and is evaluated by nobody. Its three audiences are: - -- **Display beats**, shown verbatim by a deterministic renderer. The default - English formatter ([`format_message`][osrlib.messages.format_message]) appends - the beat that rides an event, so a bare transcript reads the authored line - exactly as written. -- **The journal form**, the entry a carrier appends to the party's written record. - A quest beat does not use it: what a quest journals *is* the display text it - showed, so the journal reads as the transcript of what the table was told. This - field is the voice of carriers whose display beat the players never see — a - trigger's `fired` text rides a referee-visibility event, so a trigger that should - say something to the table says it here. -- **LLM guidance**, steering for a narrating front end that is never displayed - verbatim — the same trust posture as an area's description prose, which - already flows into narration. - -Which beats a carrier reads, and who may see them, is the carrier's business: -the block itself carries no visibility. Authored text that reaches a player rides -a player-visible event or a rejection; the wiring that produced it — conditions, -flags, guidance — stays referee-side. +([`TriggerSpec`][osrlib.crawl.triggers.TriggerSpec]), a quest +([`QuestSpec`][osrlib.crawl.quests.QuestSpec]), or a quest objective +([`ObjectiveSpec`][osrlib.crawl.quests.ObjectiveSpec]), all of which travel in the +[`Adventure`][osrlib.crawl.adventure.Adventure] document. The block itself is inert: it +decides nothing, and nothing evaluates it. What reads it is whatever evaluates its +carrier, meaning the gate, trigger, quest, or objective it hangs on: the exploration +handlers of +[`GameSession.execute`][osrlib.crawl.session.GameSession.execute] for a gate and the +[`Interpreter`][osrlib.crawl.interpreter.Interpreter] for a trigger or a quest. The +beats then reach you as the `narrative` field of an event such as +[`DoorEvent`][osrlib.crawl.events.DoorEvent], +[`TriggerFiredEvent`][osrlib.crawl.events.TriggerFiredEvent], or +[`QuestActivatedEvent`][osrlib.crawl.events.QuestActivatedEvent], as the `text` of a +[`JournalEntryAddedEvent`][osrlib.crawl.events.JournalEntryAddedEvent], or inside a +gate refusal's rejection. + +The three audiences are: + +- Display beats, shown as written by a deterministic renderer. The default English + formatter ([`format_message`][osrlib.messages.format_message]) appends the beat from + an event after the templated line, so a bare transcript reads the authored words + exactly as you wrote them. +- The journal form, the entry a carrier appends to the party's written record. A quest + beat does not use it, because what a quest journals is the display text it showed, so + the journal reads as the transcript of what the table was told. The field is the voice + of carriers whose display beat the players never see: a trigger's `fired` text travels + on a referee-visibility event, so write the line meant for the table here. +- LLM guidance, steering for a narrating front end that is never displayed as written. + It has the same trust posture as an area's description prose, which already flows into + narration. + +Which beats a carrier reads, and who may see them, is the carrier's business: the block +itself has no visibility. Authored text that reaches a player travels on a player-visible +event or inside a rejection, while the wiring that produced it, meaning conditions, flags, +and guidance, stays on the referee's side of the screen. """ from pydantic import BaseModel, ConfigDict @@ -38,33 +52,33 @@ class NarrativeBlock(BaseModel): """Authored text for one mechanical object, in three audiences. - Every field is free prose defaulting to the empty string, which means - unauthored — a block with a refusal beat and nothing else is the normal shape. - Which display beats a block speaks depends on what it hangs on: + Construct one and pass it as the `narrative` of the gate, trigger, quest, or + objective it belongs to. Every field is free prose defaulting to the empty string, + which means unauthored, so a block with a refusal beat and nothing else is a normal + shape. Which display beats a block speaks depends on what it hangs on: - - `refusal`, `success` — a gate ([`GateSpec`][osrlib.crawl.gates.GateSpec]): - the line a refused attempt returns, and the line that rides the successful + - `refusal` and `success` on a gate ([`GateSpec`][osrlib.crawl.gates.GateSpec]): the + line a refused attempt returns, and the line that travels on the successful command's event. - - `fired` — a trigger ([`TriggerSpec`][osrlib.crawl.triggers.TriggerSpec]), when - its consequences run. It rides a referee-visibility event, so it is the - referee's line about the wiring; `journal` is the players' line about the - same moment. - - `offer`, `completion` — a quest ([`QuestSpec`][osrlib.crawl.quests.QuestSpec]), + - `fired` on a trigger ([`TriggerSpec`][osrlib.crawl.triggers.TriggerSpec]), when its + consequences run. It travels on a referee-visibility event, so it is the referee's + line about the wiring, and `journal` is the players' line about the same moment. + - `offer` and `completion` on a quest ([`QuestSpec`][osrlib.crawl.quests.QuestSpec]), at its activation and at its own completion. - - `offer`, `progress` — an objective - ([`ObjectiveSpec`][osrlib.crawl.quests.ObjectiveSpec]), when it is revealed - (the objective presenting itself) and when it completes (the story advancing). + - `offer` and `progress` on an objective + ([`ObjectiveSpec`][osrlib.crawl.quests.ObjectiveSpec]), when it is revealed, the + objective presenting itself, and when it completes, the story advancing. - Per-objective beats need a per-objective carrier, which is why the objective - reads the same two field names for moments of its own. A quest's `progress` and - an objective's `completion` are read by nobody, and are silently unread rather - than rejected at parse — the same standing convention by which a gate leaves - `fired` alone and a trigger leaves `offer` alone. + Per-objective beats need a per-objective carrier, which is why an objective reads the + same two field names for moments of its own. A quest's `progress` and an objective's + `completion` are read by nobody. They are silently unread rather than rejected at + parse, by the same standing convention that lets a gate leave `fired` alone and a + trigger leave `offer` alone. - `journal` is the written-record form (unread by the quest layer, which journals - the display text it showed), `guidance` the LLM steering that applies while the - carrier is in play, and `speaker` an attribution ("the bronze sentinel", - "Sister Halda") a renderer may put in front of a beat. + `journal` is the written-record form, unread by the quest layer, which journals the + display text it showed. `guidance` is the LLM steering that applies while the carrier + is in play, and `speaker` an attribution such as "the bronze sentinel" or "Sister + Halda" that a renderer may put in front of a beat. Examples: ```python @@ -82,21 +96,32 @@ class NarrativeBlock(BaseModel): model_config = ConfigDict(frozen=True) refusal: str = "" - """A gate's refusal line, returned in the rejection when the attempt is refused.""" + """A gate's refusal line, returned inside the rejection when the attempt is + refused.""" success: str = "" - """A gate's success line, riding the successful command's event.""" + """A gate's success line, which travels on the successful command's own event: a + [`DoorEvent`][osrlib.crawl.events.DoorEvent] for a door, a + [`LocationEnteredEvent`][osrlib.crawl.events.LocationEnteredEvent] for a transition + that crosses into a new level or dungeon.""" fired: str = "" - """A trigger's firing line — the referee's beat, on a referee-visibility event.""" + """A trigger's firing line: the referee's beat, reported by the referee-visibility + [`TriggerFiredEvent`][osrlib.crawl.events.TriggerFiredEvent].""" offer: str = "" - """A quest's activation line, or an objective's reveal line; shown and journaled.""" + """A quest's activation line, or an objective's reveal line. Shown and journaled.""" progress: str = "" - """An objective's completion line; shown and journaled. Unread on a quest block.""" + """An objective's completion line. Shown and journaled, and unread on a quest + block.""" completion: str = "" - """A quest's completion line; shown and journaled. Unread on an objective block.""" + """A quest's completion line. Shown and journaled, and unread on an objective + block.""" journal: str = "" - """The written-record form, for carriers whose display beat the players never see - (a trigger's `fired`). Unread by quests, which journal the display text they showed.""" + """The written-record form, for carriers whose display beat the players never see, + which in practice means a trigger's `fired`. Unread by quests, which journal the + display text they showed.""" guidance: str = "" - """Steering for an LLM narrator while the carrier is in play; never displayed.""" + """Steering for an LLM narrator while the carrier is in play. It is never displayed + as written.""" speaker: str = "" - """An attribution ("the bronze sentinel") a renderer may put in front of a beat.""" + """An attribution, such as "the bronze sentinel", that a renderer may put in front of + a beat. [`QuestView.speaker`][osrlib.crawl.views.QuestView] ships a quest's to the + player view.""" diff --git a/src/osrlib/crawl/quests.py b/src/osrlib/crawl/quests.py index ad92900..703b371 100644 --- a/src/osrlib/crawl/quests.py +++ b/src/osrlib/crawl/quests.py @@ -1,35 +1,50 @@ """Authored quests: the matching clause, the objective spec, and the quest spec. -A quest composes the trigger vocabulary rather than introducing one of its own. An -activation, an objective's completion, and a hidden objective's reveal are each a -[`TriggerClause`][osrlib.crawl.quests.TriggerClause]: a -[`TriggerPattern`][osrlib.crawl.triggers.TriggerPattern] naming the observable, plus -the [`ConditionSpec`][osrlib.crawl.gates.ConditionSpec]s that must hold when it -matches — the same edge-triggered patterns and the same live condition evaluation an -authored [`TriggerSpec`][osrlib.crawl.triggers.TriggerSpec] uses. Rewards are the -same [`ConsequenceCommand`][osrlib.crawl.commands.ConsequenceCommand] surface under -the same party selectors ([`PARTY_SELECTOR`][osrlib.crawl.triggers.PARTY_SELECTOR] -and [`FIRST_LIVING_SELECTOR`][osrlib.crawl.triggers.FIRST_LIVING_SELECTOR]). - -A quest observes; it does not take. A clause condition with `consumes=True` is -rejected at parse for the reason a trigger's is: the event a clause matches has -already happened, so there is no attempt of the quest's own to charge a toll -against. - -Document order is the order of the -[`Adventure.quests`][osrlib.crawl.adventure.Adventure] tuple, and an objective's -order is its position in -[`QuestSpec.objectives`][osrlib.crawl.quests.QuestSpec] — the order a session's quest -state ([`QuestState`][osrlib.crawl.session.QuestState]) keys its objectives in, so -every walk over either is deterministic. - -A spec is inert data; the [`Interpreter`][osrlib.crawl.interpreter.Interpreter] is -the shipped listener that plays it, advancing quest state through its only four -writers — the lifecycle commands +A quest is the errand the adventure keeps score of: what starts it, what it asks for, +what it pays, and whether finishing it ends the adventure. + +Where a quest sits. You write [`QuestSpec`][osrlib.crawl.quests.QuestSpec]s into the +`quests` tuple of an [`Adventure`][osrlib.crawl.adventure.Adventure], and that tuple's +order is document order. A session seeds one +[`QuestState`][osrlib.crawl.session.QuestState] per quest at construction and keeps them +in `session.quests`, keyed by quest id, with each quest's objectives keyed in the order +[`QuestSpec.objectives`][osrlib.crawl.quests.QuestSpec] authored them, so every walk over +either is deterministic. Nothing advances that state until your game registers an +[`Interpreter`][osrlib.crawl.interpreter.Interpreter] on the session, and even then the +state moves only through the four lifecycle commands [`ActivateQuest`][osrlib.crawl.commands.ActivateQuest], [`RevealObjective`][osrlib.crawl.commands.RevealObjective], [`CompleteObjective`][osrlib.crawl.commands.CompleteObjective], and -[`CompleteQuest`][osrlib.crawl.commands.CompleteQuest]. +[`CompleteQuest`][osrlib.crawl.commands.CompleteQuest]. Each reports itself with a +player-visible event: +[`QuestActivatedEvent`][osrlib.crawl.events.QuestActivatedEvent], +[`ObjectiveRevealedEvent`][osrlib.crawl.events.ObjectiveRevealedEvent], +[`ObjectiveCompletedEvent`][osrlib.crawl.events.ObjectiveCompletedEvent], +[`QuestCompletedEvent`][osrlib.crawl.events.QuestCompletedEvent], and, for the quest +that concludes the adventure, +[`AdventureCompletedEvent`][osrlib.crawl.events.AdventureCompletedEvent]. The active +quests and their revealed objectives reach a front end through +[`PlayerView.quests`][osrlib.crawl.views.PlayerView]. + +A quest composes the trigger vocabulary rather than introducing one of its own. An +activation, an objective's completion, and a hidden objective's reveal are each a +[`TriggerClause`][osrlib.crawl.quests.TriggerClause]: a +[`TriggerPattern`][osrlib.crawl.triggers.TriggerPattern] naming the observable, plus the +[`ConditionSpec`][osrlib.crawl.gates.ConditionSpec]s that have to hold when it matches. +Those are the same edge-triggered patterns and the same live condition evaluation an +authored [`TriggerSpec`][osrlib.crawl.triggers.TriggerSpec] uses. Rewards are the same +[`ConsequenceCommand`][osrlib.crawl.commands.ConsequenceCommand] surface under the same +party selectors ([`PARTY_SELECTOR`][osrlib.crawl.triggers.PARTY_SELECTOR] and +[`FIRST_LIVING_SELECTOR`][osrlib.crawl.triggers.FIRST_LIVING_SELECTOR]). + +A quest observes, it does not take. A clause condition with `consumes=True` is rejected +at parse for the reason a trigger's is: the event a clause matches has already happened, +so there is no attempt of the quest's own to charge a toll against. + +Author a trigger instead when nothing has to be scored and the adventure only has to +react. The guide +[Gates, triggers, and quests](https://mmacy.github.io/osrlib-python/guides/gates-triggers-quests/) +runs a quest end to end from an adventure document. """ from typing import Literal @@ -49,13 +64,14 @@ class TriggerClause(BaseModel): - """One matching clause: the observable, and what must hold when it happens. + """One matching clause: the observable, and what has to hold when it happens. - `conditions` all have to hold: the tuple is an AND with no combinators, each - condition evaluated live against session state at the moment of the match, - through [`condition_holds`][osrlib.crawl.gates.condition_holds]. The field is - `pattern` rather than `when`, so an objective's completion clause reads - `objective.when.pattern`. + A quest uses clauses in three places, and they behave the same in all three: the + `activation` of a [`QuestSpec`][osrlib.crawl.quests.QuestSpec], and the `when` and + `reveal_when` of an [`ObjectiveSpec`][osrlib.crawl.quests.ObjectiveSpec]. The + [`Interpreter`][osrlib.crawl.interpreter.Interpreter] matches a clause exactly the + way it matches a [`TriggerSpec`][osrlib.crawl.triggers.TriggerSpec], so a quest and a + trigger can never disagree about what an event means. Examples: ```python @@ -74,7 +90,15 @@ class TriggerClause(BaseModel): model_config = ConfigDict(frozen=True) pattern: TriggerPattern + """The observable that matches the clause, one member of + [`TriggerPattern`][osrlib.crawl.triggers.TriggerPattern]. The field is `pattern` + rather than `when`, so an objective's completion clause reads + `objective.when.pattern`.""" conditions: tuple[ConditionSpec, ...] = () + """Extra tests that all have to hold at the moment of the match. The tuple is an AND + with no combinators, and each condition is evaluated live against session state + through [`condition_holds`][osrlib.crawl.gates.condition_holds]. A condition with + `consumes=True` is rejected at parse.""" @model_validator(mode="after") def _conditions_never_consume(self) -> TriggerClause: @@ -93,22 +117,19 @@ def _conditions_never_consume(self) -> TriggerClause: class ObjectiveSpec(BaseModel): """One objective: what it is called, how it completes, whether it starts hidden, and its text. - Objectives are monotonic — hidden becomes revealed, incomplete becomes complete, - and neither goes back — because the quest vocabulary authors no repeat. - - `name` is the objective's display label, the words a quest log shows beside its - checkbox. It defaults empty — a document written before the field existed loads - unchanged, additive within the schema version — and empty means unauthored: - everywhere a label is shown (the view, the lifecycle events, the default - formatter), an unauthored name falls back to the objective's id. + Put your objectives in the `objectives` tuple of a + [`QuestSpec`][osrlib.crawl.quests.QuestSpec], in the order the quest log should show + them. Their live state is [`ObjectiveState`][osrlib.crawl.session.ObjectiveState], + and the revealed ones reach a front end as + [`ObjectiveView`][osrlib.crawl.views.ObjectiveView]s. - A hidden objective with no `reveal_when` is a normal shape: it surfaces when it - completes, because completing an objective reveals it. `reveal_when` on an - objective that starts visible is rejected at parse — a reveal clause for - something already on the list is authored dead weight. + Objectives are monotonic: hidden becomes revealed, incomplete becomes complete, and + neither goes back, because the quest vocabulary authors no repeat. - `narrative` carries the objective's own beats: `offer` is the line its reveal - shows and journals, `progress` the line its completion shows and journals. + A hidden objective with no `reveal_when` is a normal shape. It surfaces when it + completes, because completing an objective reveals it. `reveal_when` on an objective + that starts visible is rejected at parse, since a reveal clause for something already + on the list would never be read. Examples: ```python @@ -129,11 +150,26 @@ class ObjectiveSpec(BaseModel): model_config = ConfigDict(frozen=True) id: str = Field(min_length=1) + """The objective's id, unique within its quest and free to repeat in another. It is + the key its state and its view use, and the label everything falls back to when + `name` is unauthored.""" name: str = "" + """The objective's display label, the words a quest log shows beside its checkbox. It + defaults empty, so a document written before the field existed loads unchanged, and + empty means unauthored: the view, the lifecycle events, and the default formatter all + fall back to the id.""" when: TriggerClause + """The clause that completes the objective. Completing it also reveals it, so a + hidden objective needs no reveal clause to show up once it is done.""" hidden: bool = False + """Whether the objective starts off the party's list. A hidden objective has no view + until it is revealed.""" reveal_when: TriggerClause | None = None + """The clause that surfaces a hidden objective ahead of its completion. It is rejected + at parse on an objective that starts visible.""" narrative: NarrativeBlock | None = None + """The objective's own beats. It reads two of the block: `offer`, the line its reveal + shows and journals, and `progress`, the line its completion shows and journals.""" @model_validator(mode="after") def _only_a_hidden_objective_reveals(self) -> ObjectiveSpec: @@ -146,24 +182,17 @@ def _only_a_hidden_objective_reveals(self) -> ObjectiveSpec: class QuestSpec(BaseModel): """One authored quest: when it starts, what it asks for, and what it pays. - `activation` absent means the quest is active from session start — a standing - charge the party carries from round 0, with no activation beat to show, because - there is no command channel before the first command. An authored clause makes - activation an event the party crosses. - - `completion` is `"all"` (every objective) or `"any"` (the first one to land). - `objectives` holds at least one: an objective-less quest under the all rule would - be born complete. `concludes_adventure=True` marks the quest whose completion - ends the adventure in `victory` - ([`CompleteQuest`][osrlib.crawl.commands.CompleteQuest]). - - `rewards` are issued after the quest completes, in authored order, with the party - selectors expanded to the members they name; an authored `source` is rejected at - parse, because whoever issues a command stamps it. + Put your quests in the `quests` tuple of an + [`Adventure`][osrlib.crawl.adventure.Adventure], and register an + [`Interpreter`][osrlib.crawl.interpreter.Interpreter] on the session to play them. A + spec on its own is inert data. Its live state is + [`QuestState`][osrlib.crawl.session.QuestState] in `session.quests`, and an active + quest reaches a front end as a [`QuestView`][osrlib.crawl.views.QuestView]. - `narrative` carries the quest's own beats: `offer` is the line its activation - shows and journals, `completion` the line its completion shows and journals. - Per-objective beats live on the objectives. + Rewards are issued after the quest completes, in authored order, and only for a + completion the interpreter itself ruled: a quest your game completes by hand with + [`CompleteQuest`][osrlib.crawl.commands.CompleteQuest] pays nothing, because paying + is this listener reading the quest. Examples: ```python @@ -192,19 +221,46 @@ class QuestSpec(BaseModel): model_config = ConfigDict(frozen=True) id: str = Field(min_length=1) + """The quest's id, unique across the adventure. It keys the quest's state, and the + `source` stamp on every command the quest issues names it, in the form + `quest:{id}`.""" name: str = Field(min_length=1) + """The quest's display name, included in its lifecycle events and its view so a + renderer needs no document to look it up in.""" activation: TriggerClause | None = None + """The clause that brings the quest into play. `None` means the quest is active from + session start, a standing charge on the party from round 0 with no activation beat + to show, because there is no command channel before the first command. An + authored clause makes activation an event the party crosses.""" objectives: tuple[ObjectiveSpec, ...] = Field(min_length=1) + """What the quest asks for, in the order a quest log should show it. At least one is + required, because an objective-less quest under the all rule would be born + complete.""" rewards: tuple[ConsequenceCommand, ...] = () + """The referee commands issued after the quest completes, in authored order, with + [`PARTY_SELECTOR`][osrlib.crawl.triggers.PARTY_SELECTOR] and + [`FIRST_LIVING_SELECTOR`][osrlib.crawl.triggers.FIRST_LIVING_SELECTOR] expanded to + the members they name. Each stands or drops on its own. An authored `source` is + rejected at parse, because the issuing quest stamps it.""" completion: Literal["all", "any"] = "all" + """The completion rule: `"all"` requires every objective, `"any"` takes the first one + to land.""" concludes_adventure: bool = False + """Whether finishing this quest ends the adventure. The session moves to `victory` + and emits an + [`AdventureCompletedEvent`][osrlib.crawl.events.AdventureCompletedEvent], which + happens before the first reward is issued, so a reward that would resume play there + is dropped with a note.""" narrative: NarrativeBlock | None = None + """The quest's own beats. It reads two of the block: `offer`, the line its activation + shows and journals, and `completion`, the line its completion shows and journals. + Per-objective beats live on the objectives.""" @model_validator(mode="after") def _objective_ids_unique(self) -> QuestSpec: """Objective ids are quest-scoped: unique here, free to repeat elsewhere. - Two quests may both name an objective `"return"`; one quest may not, because + Two quests may both name an objective `"return"`, and one quest may not, because its state keys its objectives by id. """ ids = [objective.id for objective in self.objectives] @@ -217,7 +273,7 @@ def _rewards_carry_no_source(self) -> QuestSpec: """The `source` stamp belongs to whoever issues the command, not the document. Rewards are issued stamped with the quest's own id, so an authored stamp - would either be overwritten or, worse, believed — a line in the log claiming + would either be overwritten or, worse, believed: a line in the log claiming a provenance nothing produced. """ for position, reward in enumerate(self.rewards): diff --git a/src/osrlib/crawl/triggers.py b/src/osrlib/crawl/triggers.py index 5b01379..a45bd04 100644 --- a/src/osrlib/crawl/triggers.py +++ b/src/osrlib/crawl/triggers.py @@ -1,39 +1,50 @@ """Authored triggers: the observable-event patterns and the trigger spec. -A trigger is an authored binding from an observable event pattern, optionally -gated by conditions, to referee-command consequences. Where a gate -([`GateSpec`][osrlib.crawl.gates.GateSpec]) is level-triggered — evaluated live at -the moment the party attempts something — a trigger is edge-triggered: it watches -the events a command produced and fires on the crossing itself. The lever that -opens the portcullis is a trigger; the door that wants the brass key is a gate. +A trigger binds an observable event pattern, optionally narrowed by conditions, to the +referee commands that run when it matches. + +Where a trigger sits. You write [`TriggerSpec`][osrlib.crawl.triggers.TriggerSpec]s into +the `triggers` tuple of an [`Adventure`][osrlib.crawl.adventure.Adventure], and that +tuple's order is document order: triggers matching one event fire in it. Nothing plays +them until your game registers an +[`Interpreter`][osrlib.crawl.interpreter.Interpreter] on the session with +[`GameSession.register_listener`][osrlib.crawl.session.GameSession.register_listener]. +The interpreter matches every accepted command's events against the adventure's triggers +and issues each firing's commands. A firing reports itself through a +[`TriggerFiredEvent`][osrlib.crawl.events.TriggerFiredEvent], which includes the `fired` +beat, through a [`JournalEntryAddedEvent`][osrlib.crawl.events.JournalEntryAddedEvent] +when the block authors a journal form, through whatever events its consequences produce, +and through a [`NoteRecordedEvent`][osrlib.crawl.events.NoteRecordedEvent] for a +consequence the engine refused. The fired-mark lives in `session.fired_triggers`. + +A gate ([`GateSpec`][osrlib.crawl.gates.GateSpec]) is level-triggered, evaluated live at +the moment the party attempts something. A trigger is edge-triggered: it watches the +events a command produced and fires on the crossing itself. The lever that opens the +portcullis is a trigger, and the door that needs the brass key is a gate. The pieces: -- A **pattern** ([`TriggerPattern`][osrlib.crawl.triggers.TriggerPattern]) names the - observable: a location crossed, an item acquired, a monster defeated, a flag - written. Patterns are matched against the events themselves, never against - current state, because a consequence can move the party mid-batch and an event - still carries the facts of the moment it described. -- **Conditions** ([`ConditionSpec`][osrlib.crawl.gates.ConditionSpec]) narrow the - firing further, all of them evaluated live against session state at match time. - A trigger fires, it does not take: a condition with `consumes=True` is rejected - at parse, because a trigger reacts to something that has already happened and has - no attempt of its own to charge a toll against. -- **Consequences** ([`ConsequenceCommand`][osrlib.crawl.commands.ConsequenceCommand]) - are the referee commands the firing issues, in authored order. -- A **narrative block** ([`NarrativeBlock`][osrlib.crawl.narrative.NarrativeBlock]) - carries the trigger's text: `fired` is the referee's beat, `journal` is the - players' — a trigger that should say something to the table authors a journal - form. - -Document order is the order of the [`Adventure.triggers`][osrlib.crawl.adventure.Adventure] -tuple: triggers matching one event fire in that order, and a trigger's consequences -execute in authored order. - -Triggers are inert content on their own. The -[`Interpreter`][osrlib.crawl.interpreter.Interpreter] is the shipped listener that -plays them: registered on a session, it matches every command's events against the -adventure's triggers and issues each firing's commands. +- A pattern ([`TriggerPattern`][osrlib.crawl.triggers.TriggerPattern]) names the + observable: a location crossed, an item acquired, a monster defeated, a flag written. + Patterns are matched against the events themselves and never against current state, + because a consequence can move the party inside the same batch while an event keeps + describing the moment it reported. +- Conditions ([`ConditionSpec`][osrlib.crawl.gates.ConditionSpec]) narrow the firing + further, all of them evaluated live against session state at match time. A trigger + fires, it does not take, so a condition with `consumes=True` is rejected at parse: a + trigger reacts to something that has already happened and has no attempt of its own to + charge a toll against. +- Consequences ([`ConsequenceCommand`][osrlib.crawl.commands.ConsequenceCommand]) are + the referee commands the firing issues, in authored order. +- A narrative block ([`NarrativeBlock`][osrlib.crawl.narrative.NarrativeBlock]) contains + the trigger's text. `fired` is the referee's beat and `journal` the players', so write + a journal form for the line the table should see. + +Reach for a quest ([`QuestSpec`][osrlib.crawl.quests.QuestSpec]) instead when the +adventure has to keep score toward an ending. A quest composes these same patterns and +conditions. The guide +[Gates, triggers, and quests](https://mmacy.github.io/osrlib-python/guides/gates-triggers-quests/) +runs all three from one adventure document. """ from typing import Annotated, Literal @@ -61,116 +72,241 @@ PARTY_SELECTOR = "@party" """The `character_id` an authored consequence writes to address the whole party. -Expanded at issue time to one command per *living* member, in marching order, so the -command log stays fully concrete and replays exactly. A party with nobody left -standing expands to no commands at all — a reward for the dead is nothing, not an -error.""" +Write it as the `character_id` of a +[`GrantItem`][osrlib.crawl.commands.GrantItem], +[`GrantCoins`][osrlib.crawl.commands.GrantCoins], or +[`AwardXP`][osrlib.crawl.commands.AwardXP] in a trigger's consequences or a quest's +rewards, where a concrete character id would be a guess: a document written before play +cannot know the ids a session hands out. + +The [`Interpreter`][osrlib.crawl.interpreter.Interpreter] expands it at issue time into +one command per living member, in marching order, so the command log stays concrete and +replays exactly. A party with nobody left standing expands to no commands at all, +because a reward for the dead is nothing rather than an error. Use +[`FIRST_LIVING_SELECTOR`][osrlib.crawl.triggers.FIRST_LIVING_SELECTOR] when one member +should receive the whole thing. No other command honors a selector, and a literal +character id is passed through untouched.""" FIRST_LIVING_SELECTOR = "@first" """The `character_id` an authored consequence writes to address one member. -Expanded at issue time to the first living member in marching order — the -treasure-recipient convention. With nobody standing there is no recipient, and the -consequence is dropped with a note rather than guessed at.""" +The [`Interpreter`][osrlib.crawl.interpreter.Interpreter] expands it at issue time to +the first living member in marching order, which is the treasure-recipient convention: +one object goes to one member, and the party sorts it out with +[`GiveItems`][osrlib.crawl.commands.GiveItems]. With nobody standing there is no +recipient, so the consequence is dropped and a +[`NoteRecordedEvent`][osrlib.crawl.events.NoteRecordedEvent] says why rather than the +engine guessing. Use [`PARTY_SELECTOR`][osrlib.crawl.triggers.PARTY_SELECTOR] when every +member should receive the reward.""" class AreaEnteredPattern(BaseModel): """The party entered a keyed area. - Area ids are level-scoped, so the pattern names the whole triple: an `id` of - `"crypt"` means nothing without the dungeon and level it belongs to. + Matches a [`LocationEnteredEvent`][osrlib.crawl.events.LocationEnteredEvent] for an + area. Area ids are scoped to their level, so the pattern names the whole triple: an + area id of `"shrine"` means nothing without the dungeon and level it belongs to. + Reach for [`LevelEnteredPattern`][osrlib.crawl.triggers.LevelEnteredPattern] when the + whole level is the crossing you want. + + Examples: + ```python + from osrlib.crawl.triggers import AreaEnteredPattern + + shrine = AreaEnteredPattern(dungeon_id="barrow", level_number=2, area_id="shrine") + assert shrine.pattern_type == "area_entered" + ``` """ model_config = ConfigDict(frozen=True) pattern_type: Literal["area_entered"] = "area_entered" + """The discriminator value, `area_entered`. It appears in the document, and you + never set it yourself.""" dungeon_id: str = Field(min_length=1) + """The id of the dungeon the area belongs to.""" level_number: int = Field(ge=1) + """The 1-based number of the level the area belongs to.""" area_id: str = Field(min_length=1) + """The keyed area's id, as the level's + [`AreaSpec`][osrlib.crawl.dungeon.AreaSpec] authored it.""" class LevelEnteredPattern(BaseModel): """The party arrived on a dungeon level. - However it got there: a stair between levels and an entry from town both land - the party on the level, and both match. (The engine reports the coarser crossing - when a move changes dungeons, and a dungeon crossing is a level arrival too.) + It matches however the party got there: a stair between levels and an entry from + town both land it on the level. The engine reports the coarsest crossing a move + passed, so a party walking in from town reports a dungeon entry, and this pattern + counts that as a level arrival too. + + Examples: + ```python + from osrlib.crawl.triggers import LevelEnteredPattern + + deeper = LevelEnteredPattern(dungeon_id="barrow", level_number=2) + assert deeper.pattern_type == "level_entered" + ``` """ model_config = ConfigDict(frozen=True) pattern_type: Literal["level_entered"] = "level_entered" + """The discriminator value, `level_entered`. It appears in the document, and you + never set it yourself.""" dungeon_id: str = Field(min_length=1) + """The id of the dungeon the level belongs to.""" level_number: int = Field(ge=1) + """The 1-based level number.""" class DungeonEnteredPattern(BaseModel): - """The party crossed into a dungeon — from town, or from another dungeon.""" + """The party crossed into a dungeon, from town or from another dungeon. + + The coarse arrival, matched by a + [`LocationEnteredEvent`][osrlib.crawl.events.LocationEnteredEvent] for a dungeon. + Use it for the beat that belongs to walking in the front door, and + [`LevelEnteredPattern`][osrlib.crawl.triggers.LevelEnteredPattern] for one that + belongs to a particular level. + + Examples: + ```python + from osrlib.crawl.triggers import DungeonEnteredPattern + + arrival = DungeonEnteredPattern(dungeon_id="barrow") + assert arrival.pattern_type == "dungeon_entered" + ``` + """ model_config = ConfigDict(frozen=True) pattern_type: Literal["dungeon_entered"] = "dungeon_entered" + """The discriminator value, `dungeon_entered`. It appears in the document, and you + never set it yourself.""" dungeon_id: str = Field(min_length=1) + """The id of the dungeon the party crossed into.""" class TownEnteredPattern(BaseModel): """The party arrived in the base town, however it got there. - An adventure has one town, so the pattern needs no fields: the homecoming beat - fires on the return trip and on a referee's placement alike. + An adventure has one town, so the pattern needs no fields. The homecoming beat fires + on the return trip and on a referee's placement alike. Pair it with a + [`HasItemCondition`][osrlib.crawl.gates.HasItemCondition] for the errand that is + finished only when the party walks home carrying the thing. + + Examples: + ```python + from osrlib.crawl.triggers import TownEnteredPattern + + home = TownEnteredPattern() + assert home.pattern_type == "town_entered" + ``` """ model_config = ConfigDict(frozen=True) pattern_type: Literal["town_entered"] = "town_entered" + """The discriminator value, `town_entered`. It appears in the document, and you + never set it yourself.""" class ItemAcquiredPattern(BaseModel): """A party member acquired an item with `item_id`. - The id domain is the one `has_item` reads: the effective equipment catalog - (shipped ∪ adventure-bundled) or the magic-item catalog. Acquisitions report - mundane items by catalog id and magic items by their session-scoped instance id, - so a magic `item_id` matches by resolving that instance against the acquiring - character's inventory. + Matched by an [`ItemAcquiredEvent`][osrlib.crawl.events.ItemAcquiredEvent], however + the item arrived: out of a cache, from a referee's grant, or from another member's + hands. The id domain is the one a `has_item` condition reads, which is the effective + equipment catalog, meaning the shipped items plus the adventure's bundled ones, or + the magic-item catalog. + + An acquisition reports mundane items by catalog id and magic items by their + session-scoped instance id, so a magic `item_id` matches by resolving that instance + against the acquiring character's inventory. + + Examples: + ```python + from osrlib.crawl.triggers import ItemAcquiredPattern + + taken = ItemAcquiredPattern(item_id="brass_key") + assert taken.pattern_type == "item_acquired" + ``` """ model_config = ConfigDict(frozen=True) pattern_type: Literal["item_acquired"] = "item_acquired" + """The discriminator value, `item_acquired`. It appears in the document, and you + never set it yourself.""" item_id: str = Field(min_length=1) + """The catalog id of the item to watch for, from the equipment catalog (shipped plus + adventure-bundled) or the magic-item catalog. For a magic item, author the template + id rather than an instance id.""" class MonsterDefeatedPattern(BaseModel): - """A monster of `template_id` was defeated — slain, routed, or surrendered. + """A monster of `template_id` was defeated: slain, routed, or surrendered. + + Every outcome counts as a defeat and the pattern does not filter on one. Defeats are + reported at battle end through + [`MonsterDefeatedEvent`][osrlib.crawl.events.MonsterDefeatedEvent], so a boss falling + opens the portcullis once the fighting stops and never mid-round. Author no trigger + that has to land the instant a blow kills. + + Examples: + ```python + from osrlib.crawl.triggers import MonsterDefeatedPattern - Every outcome is a defeat, so the pattern does not filter on one. Defeats are - reported at battle end, so the boss falling opens the portcullis after the - fighting stops, never mid-round. + beaten = MonsterDefeatedPattern(template_id="ogre") + assert beaten.pattern_type == "monster_defeated" + ``` """ model_config = ConfigDict(frozen=True) pattern_type: Literal["monster_defeated"] = "monster_defeated" + """The discriminator value, `monster_defeated`. It appears in the document, and you + never set it yourself.""" template_id: str = Field(min_length=1) + """The monster template id to watch for, from the monster catalog (shipped plus + adventure-bundled). It is the template, not a session-scoped instance id, so every + monster of that kind matches.""" class FlagSetPattern(BaseModel): - """A session flag was written — the edge, not the state. + """A session flag was written: the edge, not the state. - The match is against the value the write carried, so a flag rewritten with the - value it already held still fires. `value=None` matches any written value, which - is unambiguous because a flag value is a `str`, an `int`, or a `bool` and never - `None`; an authored value compares through - [`flag_values_equal`][osrlib.crawl.gates.flag_values_equal], the same strict - comparison [`FlagEqualsCondition`][osrlib.crawl.gates.FlagEqualsCondition] uses. + This is the lever. Your game, or another trigger's consequence, executes + [`SetFlag`][osrlib.crawl.commands.SetFlag], the write emits a + [`FlagSetEvent`][osrlib.crawl.events.FlagSetEvent], and the trigger watching that key + fires. The match is against the value the write set, so a flag rewritten with the + value it already had still fires. To ask about the value a flag has now instead of a + write that just happened, use a + [`FlagEqualsCondition`][osrlib.crawl.gates.FlagEqualsCondition]. + + Examples: + ```python + from osrlib.crawl.triggers import FlagSetPattern + + pulled = FlagSetPattern(key="crypt.lever", value="pulled") + assert pulled.pattern_type == "flag_set" + ``` """ model_config = ConfigDict(frozen=True) pattern_type: Literal["flag_set"] = "flag_set" + """The discriminator value, `flag_set`. It appears in the document, and you + never set it yourself.""" key: str = Field(min_length=1) + """The flag name to watch.""" value: str | int | bool | None = None + """The written value to match, or `None` to match any write of the key. `None` is + unambiguous because a flag value is a `str`, an `int`, or a `bool` and never `None`. + An authored value compares through + [`flag_values_equal`][osrlib.crawl.gates.flag_values_equal], the same strict + comparison a [`FlagEqualsCondition`][osrlib.crawl.gates.FlagEqualsCondition] uses, + so a stored `True` never matches an authored `1`.""" TriggerPattern = Annotated[ @@ -183,25 +319,30 @@ class FlagSetPattern(BaseModel): | FlagSetPattern, Field(discriminator="pattern_type"), ] -"""The pattern union, discriminated on `pattern_type`. New observables join it -additively; the discriminator values are wire values and serialize into every -document that carries a trigger.""" +"""The pattern union, discriminated on `pattern_type`. + +Annotate a field with this alias when you write your own model that holds an authored +pattern. Pydantic then picks the member from the `pattern_type` value in the document. +It is the type of [`TriggerSpec.when`][osrlib.crawl.triggers.TriggerSpec] and of +[`TriggerClause.pattern`][osrlib.crawl.quests.TriggerClause], so a quest watches exactly +what a trigger watches. New observables join the union additively, and the discriminator +values are wire values that appear in every document that includes a trigger.""" class TriggerSpec(BaseModel): """One authored trigger: when it fires, what must hold, and what happens. - A spec is inert data; the [`Interpreter`][osrlib.crawl.interpreter.Interpreter] - is the shipped listener that plays it. - - Once-only by default — the fired-mark that - [`MarkTriggerFired`][osrlib.crawl.commands.MarkTriggerFired] writes is session - state, so once-only survives a save, a load, and a replay. `repeatable=True` is - the authored opt-in for a trigger that fires every time its pattern matches. + Put your triggers in the `triggers` tuple of an + [`Adventure`][osrlib.crawl.adventure.Adventure], and register an + [`Interpreter`][osrlib.crawl.interpreter.Interpreter] on the session to play them. A + spec on its own is inert data, and nothing in the engine reads it without that + listener. - `conditions` all have to hold: the tuple is an AND with no combinators, each - condition evaluated live at the moment of the match. `consequences` may be empty - — a trigger whose whole job is its journal beat is a normal shape. + A trigger fires once ever by default. The fired-mark that + [`MarkTriggerFired`][osrlib.crawl.commands.MarkTriggerFired] writes is session state, + so once-only survives a save, a load, and a replay, and `session.fired_triggers` is + where you read it. `repeatable=True` is the authored opt-in for a trigger that fires + every time its pattern matches. Examples: ```python @@ -227,11 +368,33 @@ class TriggerSpec(BaseModel): model_config = ConfigDict(frozen=True) id: str = Field(min_length=1) + """The trigger's id, unique across the adventure. It is what the fired-mark records, + and what the `source` stamp on every command the firing issues names, in the form + `trigger:{id}`.""" when: TriggerPattern + """The observable that fires it, one member of + [`TriggerPattern`][osrlib.crawl.triggers.TriggerPattern].""" conditions: tuple[ConditionSpec, ...] = () + """Extra tests that all have to hold at the moment of the match. The tuple is an AND + with no combinators, and each condition is evaluated live against session state + through [`condition_holds`][osrlib.crawl.gates.condition_holds]. A condition with + `consumes=True` is rejected at parse.""" repeatable: bool = False + """Whether the trigger fires every time its pattern matches. The default fires it + once for the life of the session.""" consequences: tuple[ConsequenceCommand, ...] = () + """The referee commands the firing issues, in authored order, with + [`PARTY_SELECTOR`][osrlib.crawl.triggers.PARTY_SELECTOR] and + [`FIRST_LIVING_SELECTOR`][osrlib.crawl.triggers.FIRST_LIVING_SELECTOR] expanded to + the members they name. Each command stands or drops on its own, so one rejection + never stops the rest. The tuple may be empty: a trigger whose whole job is its + journal beat is a normal shape. An authored `source` is rejected at parse, because + the issuing trigger stamps it.""" narrative: NarrativeBlock | None = None + """The trigger's authored text. A trigger reads two beats of the block: `fired`, + reported by the referee-visibility + [`TriggerFiredEvent`][osrlib.crawl.events.TriggerFiredEvent], and `journal`, which + becomes a player-visible journal entry.""" @model_validator(mode="after") def _conditions_never_consume(self) -> TriggerSpec: @@ -251,7 +414,7 @@ def _consequences_carry_no_source(self) -> TriggerSpec: """The `source` stamp belongs to whoever issues the command, not the document. Consequences are issued stamped with the trigger's own id, so an authored - stamp would either be overwritten or, worse, believed — a line in the log + stamp would either be overwritten or, worse, believed: a line in the log claiming a provenance nothing produced. """ for position, consequence in enumerate(self.consequences): diff --git a/src/osrlib/crawl/views.py b/src/osrlib/crawl/views.py index fefcb1e..1bebdaa 100644 --- a/src/osrlib/crawl/views.py +++ b/src/osrlib/crawl/views.py @@ -1,30 +1,40 @@ """The projection API: the player's safe whitelist and the referee's full state. -`execute()` mutates session state; [`build_player_view`][osrlib.crawl.views.build_player_view] -and [`build_referee_view`][osrlib.crawl.views.build_referee_view] build these frozen -projections from that state alone, never from the event log. - -The player view is an enumerated whitelist: party public sheets, location and -facing, the mapped cells with their edges — walked cells, remembered seen cells -the party's light has shown it, and what its light reveals right now (secret -doors only if discovered — an undiscovered secret door renders as wall), known -piles and emptied caches in explored space, active effects on party members -with remaining durations, the elapsed clock, the mode, the journal (the appended -beats verbatim, each with the clock position it landed at), the active quests with -their revealed objectives, the current -encounter/battle public state (names, -counts, distances, visible conditions — never HP), fatigue/exhaustion/deprivation -status, and the adventure's public prose. It never carries unexplored geometry, -undiscovered traps or secret doors, monster HP or stat internals, -referee-visibility roll outcomes, session flags, trigger fired-marks, referee -notes, quest wiring (activation clauses, patterns, conditions, rewards, hidden -objectives, inactive quests), RNG state, or the seed — the seed lives only in the -save, and neither view carries it. - -The referee view carries everything else the save does, minus RNG internals and -the seed, for LLM referees and tests. A front end must never trust the client: -a networked game keeps the session and the referee view server-side, and returns -only the player view — or player-visibility events — over the wire. +A view is the snapshot a front end renders: everything on the screen after a command, +in one frozen object. + +Where the views sit. A command changes the session state that +[`GameSession`][osrlib.crawl.session.GameSession] keeps, and +[`build_player_view`][osrlib.crawl.views.build_player_view] and +[`build_referee_view`][osrlib.crawl.views.build_referee_view] read that state, and only +that state, to build these projections. They never read the event log. +[`GameSession.view`][osrlib.crawl.session.GameSession.view] is the entry point most games +call, with a [`Visibility`][osrlib.core.events.Visibility] to pick which one. Events tell +you what just happened, and a view tells you what is true now. The ids a view includes, +[`MemberView.id`][osrlib.crawl.views.MemberView] and +[`EncounterGroupView.id`][osrlib.crawl.views.EncounterGroupView], are the ids the +commands in [`osrlib.crawl.commands`][osrlib.crawl.commands] name. + +The player view is an enumerated whitelist: party public sheets, location and facing, the +mapped cells with their edges (walked cells, the remembered cells the party's light has +shown it, and what its light reveals right now, with secret doors rendered as wall until +discovered), known piles and emptied caches in explored space, active effects on party +members with their remaining durations, the elapsed clock, the mode, the journal, the +active quests with their revealed objectives, the current encounter or battle's public +state, fatigue, exhaustion, and deprivation status, and the adventure's public prose. + +It never includes unexplored geometry, undiscovered traps or secret doors, monster hit +points or stat internals, referee-visibility roll outcomes, session flags, trigger +fired-marks, referee notes, quest wiring such as activation clauses, patterns, +conditions, rewards, hidden objectives, and inactive quests, RNG state, or the master +seed, which lives only in the save and reaches neither view. + +The referee view includes everything else the save does, minus RNG internals and the seed, +for LLM referees and tests. Never trust the client with it: a networked game keeps the +session and the referee view on the server and sends only the player view, or +player-visibility events, over the wire. The guide +[Views and visibility](https://mmacy.github.io/osrlib-python/guides/views-and-visibility/) +walks the whole projection in a running front end. """ from pydantic import BaseModel, ConfigDict @@ -53,126 +63,242 @@ class MemberView(BaseModel): - """One member's public sheet: the players know their own characters.""" + """One member's public sheet: the players know their own characters. + + You get these from [`PlayerView.party`][osrlib.crawl.views.PlayerView], in marching + order, and every party member has one, living or dead. A member's own numbers are + not secrets, so the sheet is full, and the one thing play hides from the players is + the identity of an unidentified magic item in the pack. + + Examples: + ```python + from osrlib.crawl.views import MemberView + + sheet = MemberView( + id="pc1", + name="Hild", + class_id="fighter", + level=1, + current_hp=7, + max_hp=7, + conditions=(), + inventory={}, + memorized_spells=(), + ) + assert sheet.id == "pc1" # the id a command's character_id names + ``` + """ model_config = ConfigDict(frozen=True) id: str + """The member's character id. This is the id you put in the `character_id` of a + command such as [`GrantItem`][osrlib.crawl.commands.GrantItem] or a battle + declaration, so a wire client can act without holding the session.""" name: str + """The character's name, as the player wrote it.""" class_id: str + """The character class id, such as `"fighter"`, from the class catalog.""" level: int + """The character's experience level.""" current_hp: int + """Current hit points. Zero or less means the member is down, and `conditions` says + what took them out.""" max_hp: int + """Maximum hit points at the current level.""" conditions: tuple[str, ...] + """The conditions on the member right now, as + [`Condition`][osrlib.core.effects.Condition] wire values such as `"dead"` or + `"sleeping"`, in the order the ledger holds them.""" inventory: dict + """The member's pack, in the shape the inventory serializes, with the keys `items`, + `purse`, `valuables`, `worn_armour`, `shield`, `wielded`, and `rings`. Magic items + are masked until identified: an unidentified one shows a category display name + instead of its true name, and charges never appear at any identification level.""" memorized_spells: tuple[dict, ...] + """The prepared spells, one dumped + [`MemorizedSpell`][osrlib.core.spells.MemorizedSpell] per copy, in memorization + order. Empty for a class that casts nothing.""" class MemberEffectView(BaseModel): - """An active effect on a party member — players track their own torches and spells.""" + """An active effect on a party member: the players track their own torches and spells. + + You get these from [`PlayerView.effects`][osrlib.crawl.views.PlayerView]. Only + effects attached to a party member appear. An effect anchored to a dungeon cell is + the referee's business and stays out. + """ model_config = ConfigDict(frozen=True) character_id: str + """The id of the member the effect is attached to, matching a + [`MemberView.id`][osrlib.crawl.views.MemberView].""" kind: str + """The effect kind, such as `"light"` or `"fatigue"`, which is the same string an + [`EffectActiveCondition`][osrlib.crawl.gates.EffectActiveCondition] names.""" remaining_rounds: int | None + """Rounds left before the effect expires, never below zero. `None` means the players + are not told: an effect with no expiry, and any effect a potion granted, because the + referee rolls and tracks a potion's duration and never announces it.""" class EdgeView(BaseModel): - """One visible edge: its kind (undiscovered secret doors render as wall) and door state.""" + """One visible edge: what occupies it, and a door's state. + + You get these from the `edges` of an + [`ExploredLevelView`][osrlib.crawl.views.ExploredLevelView], keyed by canonical edge + key. An undiscovered secret door renders as wall, so a front end that draws what it + is given never reveals one. + """ model_config = ConfigDict(frozen=True) kind: str + """What occupies the edge: `"open"`, `"wall"`, or `"door"`, the wire values of + [`EdgeKind`][osrlib.crawl.dungeon.EdgeKind]. An undiscovered secret door reports + `"wall"`.""" door_open: bool | None = None + """Whether the door stands open. `None` on an edge that is not a door.""" door_wedged: bool | None = None + """Whether the door has been wedged with an iron spike. `None` on an edge that is not + a door.""" class PileView(BaseModel): - """A known dropped pile in explored space.""" + """A known dropped pile in explored space. + + You get these from [`PlayerView.piles`][osrlib.crawl.views.PlayerView], keyed by cell + reference. A pile in a cell the party has not walked is left out, so the view never + tells the players about loot they have not found. + """ model_config = ConfigDict(frozen=True) items: tuple[str, ...] + """One display string per thing in the pile, in the order mundane items, magic items, + then valuables. A mundane entry reads `"{item_id}×{quantity}"`. A magic item shows its + name once identified and its masked display name before that. A valuable shows its + name, or its kind when it has no name.""" coins_gp_value: int + """The pile's coins, converted to their value in gold pieces.""" class ExploredLevelView(BaseModel): - """One level's explored map: cells and their edges.""" + """One level's explored map: the cells the party knows, and the edges around them. + + You get these from [`PlayerView.explored`][osrlib.crawl.views.PlayerView], one per + level the party has any knowledge of. This is the map to draw: nothing outside it is + knowledge the party has. + """ model_config = ConfigDict(frozen=True) dungeon_id: str + """The id of the dungeon this level belongs to.""" level_number: int + """The 1-based level number.""" cells: tuple[Position, ...] + """The known cells as `(x, y)` pairs: the cells the party has walked, then the cells + it remembers having seen, then the cells its light shows from where it stands right + now. Lighting a torch redraws the room in the next view, with no footstep in + between.""" edges: dict[str, EdgeView] + """The edges around those cells, keyed by the canonical edge key that + [`edge_key`][osrlib.crawl.dungeon.edge_key] returns, in the form `"{x},{y}:north"` or + `"{x},{y}:west"`. Each physical edge appears once, so a wall between two known cells + is one entry, not two.""" class EncounterGroupView(BaseModel): - """A monster group as the players see it: id, name, count, distance, behavior — never HP. + """A monster group as the players see it: what it is, how many, how far, how it looks. - The group `id` is the command vocabulary: battle declarations name their - `target_group_id` with it, so the projection must carry it for a wire client - to fight at all — an allocator ordinal, not a secret (the id doctrine - [`MemberView`][osrlib.crawl.views.MemberView] already sets). + You get these from [`EncounterView.groups`][osrlib.crawl.views.EncounterView]. Hit + points and stat internals never appear, because the players work from what they can + see across the room. """ model_config = ConfigDict(frozen=True) id: str + """The group's id, which is the command vocabulary: a battle declaration names its + `target_group_id` with it, so a wire client needs it to fight at all. It is an + allocator ordinal rather than a secret, the same way a + [`MemberView.id`][osrlib.crawl.views.MemberView] is.""" label: str + """What the group is called, such as `"goblins"`, for the line a front end prints.""" count: int + """How many of the group are still standing. The dead are not counted.""" distance_feet: int + """How far away the group is on the range track, in feet.""" visible_conditions: tuple[str, ...] + """The conditions visible on the living members of the group, as + [`Condition`][osrlib.core.effects.Condition] wire values, sorted and deduplicated so + the same condition on six goblins reads once.""" class EncounterView(BaseModel): """The current encounter or battle's public state. - Four id tuples — `declarers`, `front_rank`, `immobile`, and `reloading` — - describe the round's shape as the players know it at the table: who is able to - act, who stands close enough to swing, who is held fast, and who is still - cranking a windlass. Each one corresponds to a rejection the engine would - otherwise raise against the whole round, so a front end that reads all four can - offer only the declarations the engine will accept. Without them a front end - has to assume a rank width, and a wrong assumption costs the party its turn. + You get one from [`PlayerView.encounter`][osrlib.crawl.views.PlayerView], and `None` + there means nothing is happening. + + Four id tuples, `declarers`, `front_rank`, `immobile`, and `reloading`, describe the + round's shape as the players know it at the table: who is able to act, who stands + close enough to swing, who is held fast, and who is still cranking a windlass. Each + one corresponds to a rejection the engine would otherwise raise against the whole + round, so a front end that reads all four offers only the declarations the engine will + accept. Without them a front end has to assume a rank width, and a wrong assumption + costs the party its turn. """ model_config = ConfigDict(frozen=True) groups: tuple[EncounterGroupView, ...] + """The monster groups in the encounter, in the order the encounter holds them.""" stance: str | None + """How the monsters are behaving, as a [`ReactionResult`][osrlib.core.tables.ReactionResult] + wire value: `"attacks"`, `"hostile"`, `"uncertain"`, `"indifferent"`, or `"friendly"`. + `None` before the reaction roll has settled it.""" in_battle: bool + """Whether the encounter has become a battle with rounds and declarations.""" battle_round: int | None = None + """The current battle round, counting from the first. `None` outside a battle.""" pursuit_gap_feet: int | None = None + """How far ahead of its pursuers the fleeing party is, in feet. `None` when nobody is + pursuing.""" declarers: tuple[str, ...] = () - """Every member who must declare this round, in marching order: living and able - to act. A [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] naming - any other roster — a slept or paralysed member included — is rejected whole - (`battle.declaration.roster_mismatch`).""" + """Every member who has to declare this round, in marching order: the ones living and + able to act. A [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] + naming any other roster, a slept or paralysed member included, is rejected whole with + `battle.declaration.roster_mismatch`.""" front_rank: tuple[str, ...] = () - """The living members close enough to attack in melee, in marching order — the - party's first rank at the current formation width, or every living member when - the `formation_width_limit` flag is off. A melee attack declared for anyone else - is rejected (`battle.declaration.not_in_front_rank`), and inside melee reach a + """The living members close enough to attack in melee, in marching order. That is the + party's first rank at the current formation width, or every living member when the + ruleset's `formation_width_limit` flag is off. A melee attack declared for anyone + else is rejected with `battle.declaration.not_in_front_rank`, and inside melee reach a weapon that is both melee and missile counts as a melee weapon.""" immobile: tuple[str, ...] = () - """The declarers who cannot move this round — entangled, in practice, since the - states that stop a move otherwise stop a declaration. Their `close` and - `withdraw` moves are rejected (`battle.declaration.cannot_move`).""" + """The declarers who cannot move this round, which in practice means the entangled, + since the states that stop a move otherwise stop a declaration as well. Their `close` + and `withdraw` moves are rejected with `battle.declaration.cannot_move`.""" reloading: tuple[str, ...] = () - """The members who may not fire a `reload` weapon this round, because they fired - one last round (`combat.attack.reload`). Empty when the ruleset's `weapon_reload` - flag is off, so a front end can combine this list with the weapon's own - qualities and need not read the flag at all.""" + """The members who may not fire a `reload` weapon this round, because they fired one + last round (`combat.attack.reload`). Empty when the ruleset's `weapon_reload` flag is + off, so a front end can combine this list with the weapon's own qualities and never + read the flag itself.""" class ObjectiveView(BaseModel): """One revealed objective as the players know it: what it is called, and whether it is done. - Hidden objectives have no view at all — an objective nobody has been told about - is absent from the list, not listed as unknown — so `state` needs only the two - values a visible objective can be in. + You get these from [`QuestView.objectives`][osrlib.crawl.views.QuestView]. A hidden + objective has no view at all: one nobody has been told about is absent from the list + rather than listed as unknown, which is why `state` needs only the two values a + visible objective can be in. The authored source is + [`ObjectiveSpec`][osrlib.crawl.quests.ObjectiveSpec] and the live state is + [`ObjectiveState`][osrlib.crawl.session.ObjectiveState], neither of which a wire + client holds. """ model_config = ConfigDict(frozen=True) @@ -181,8 +307,8 @@ class ObjectiveView(BaseModel): """The objective's authored id, scoped to its quest.""" name: str """The objective's display label: its authored `name`, or its id when the - document authors none — never empty, because the view's job is what it is - called.""" + document authors none. It is never empty, because the view's job is saying what the + objective is called.""" state: str """`"incomplete"` or `"complete"`.""" @@ -190,11 +316,12 @@ class ObjectiveView(BaseModel): class QuestView(BaseModel): """One active quest as the players know it: the charge, who gave it, and where it stands. - `narrative` is the quest's authored offer beat and `speaker` its attribution, - both empty when unauthored: a wire client holds no adventure document to resolve - either from, so the projection carries the words themselves. The wiring that - starts a quest, checks it off, and pays it — clauses, patterns, conditions, - rewards — never appears; that is the game's secret exactly as a trigger's is. + You get these from [`PlayerView.quests`][osrlib.crawl.views.PlayerView]. The wiring + that starts a quest, checks it off, and pays it, meaning its clauses, patterns, + conditions, and rewards, never appears: that is the game's secret exactly as a + trigger's is. Read the authored quest itself from + [`QuestSpec`][osrlib.crawl.quests.QuestSpec] and its live state from + [`QuestState`][osrlib.crawl.session.QuestState] when you hold the session. """ model_config = ConfigDict(frozen=True) @@ -204,49 +331,99 @@ class QuestView(BaseModel): name: str """The quest's authored display name.""" narrative: str + """The quest's authored offer beat, or the empty string when unauthored. The words + themselves travel, because a wire client holds no adventure document to resolve them + from.""" speaker: str + """Who is speaking the offer, such as `"Sister Halda"`, or the empty string when the + block authors no attribution.""" objectives: tuple[ObjectiveView, ...] """The revealed objectives, in the order the quest authored them.""" class PlayerView(BaseModel): - """The safe projection: an enumerated whitelist of exactly the fields a player may see.""" + """The safe projection: an enumerated whitelist of exactly the fields a player may see. + + Build one with [`build_player_view`][osrlib.crawl.views.build_player_view], or with + [`GameSession.view`][osrlib.crawl.session.GameSession.view] and + `Visibility.PLAYER`. This is the object a networked game sends over the wire: every + field on it is safe to show at the table, and nothing the party has not learned is + on it. Rebuild it after every command, because it is a frozen snapshot and nothing + updates it in place. + """ model_config = ConfigDict(frozen=True) adventure_name: str + """The adventure's title.""" adventure_description: str + """The adventure's public prose, the blurb a front end shows on the title screen.""" town_name: str + """The base town's name.""" town_description: str + """The base town's public prose.""" town_services: tuple[str, ...] + """The services the town offers, as authored prose for a front end to list.""" party: tuple[MemberView, ...] + """The party's public sheets, in marching order, the dead included.""" location: PartyLocation + """Where the party is: the town, or a dungeon cell with its facing.""" clock_rounds: int + """The elapsed game clock in rounds, counting from the start of the session.""" mode: str + """The session mode as a [`SessionMode`][osrlib.crawl.commands.SessionMode] wire + value: `"town"`, `"exploring"`, `"encounter"`, `"battle"`, `"game_over"`, or + `"victory"`. It tells a front end which commands are legal right now.""" explored: tuple[ExploredLevelView, ...] + """The party's map, one entry per level it knows anything about.""" piles: dict[str, PileView] + """The dropped piles the party knows about, keyed by the cell reference + [`cell_ref`][osrlib.crawl.dungeon.cell_ref] returns. A pile in a cell the party has + not walked is left out.""" emptied_caches: tuple[str, ...] + """The treasure caches the party has already emptied, as `"{dungeon}:{level}:{id}"` + references, so a front end can draw a looted cache as looted.""" effects: tuple[MemberEffectView, ...] + """The active effects on party members, in ledger order.""" fatigued: bool + """Whether any member is fatigued.""" exhausted: bool + """Whether any member is exhausted.""" deprivation: dict[str, dict[str, int]] + """Food and water deprivation, keyed by character id, each value holding + `food_days` and `water_days`. A member with no deprivation on either track is left + out, so an empty dict means the party has been eating and drinking.""" journal: tuple[JournalEntry, ...] """The session journal, shipped as written: the players' own record of the - adventure, in order of discovery, each beat carrying the clock position it landed - at. The wiring behind the beats — trigger fired-marks, referee notes — stays out.""" + adventure, in order of discovery, each beat with the clock position it landed + at. The wiring behind the beats, meaning trigger fired-marks and referee notes, stays + out.""" quests: tuple[QuestView, ...] """The quests in play, in the order the adventure authored them: active ones only. A quest nobody has taken on yet is not the party's business, and a finished one - leaves the list — its record is the journal, which keeps every beat it wrote.""" + leaves the list, and its record is the journal, which keeps every beat it wrote.""" encounter: EncounterView | None = None + """The current encounter or battle's public state, or `None` when the party is not in + one.""" class RefereeView(BaseModel): - """The full state projection minus RNG internals, for LLM referees and tests.""" + """The full state projection minus RNG internals, for LLM referees and tests. + + Build one with [`build_referee_view`][osrlib.crawl.views.build_referee_view], or with + [`GameSession.view`][osrlib.crawl.session.GameSession.view] and `Visibility.REFEREE`. + Use it behind the screen: for the context an LLM referee reasons over, for a + debugging panel, for a test that asserts on state a player may not see. Never send it + to a player's client, which is what [`PlayerView`][osrlib.crawl.views.PlayerView] is + for. + """ model_config = ConfigDict(frozen=True) state: dict + """The whole session state as the save serializes it, including the event log, minus + the RNG stream positions and the master seed. The keys are the save's keys, so + `state["flags"]` is the flag store and `state["command_log"]` the command log.""" _MASKED_CATEGORY_NAMES = { @@ -261,18 +438,17 @@ class RefereeView(BaseModel): def _masked_magic_item(instance: MagicItemInstance) -> dict: - """One magic item as the player sees it — masked until identified. - - An unidentified item shows only its category display name (an enchanted arm - shows its base — "a sword with a faint aura", the concession because *detect - magic* exists); an identified one shows its true name and id, and — for an arm — - the `qualities` and `missile_ranges` of the mundane weapon underneath it: how - far the arm reaches, and in what manner. Both fields are rulebook facts about a - weapon the player has already identified, and they are what lets a front end - tell a melee declaration from a missile one; without them an enchanted dagger is - unclassifiable where a plain dagger is not. Charges, sentience, and per-item - state never appear at any identification level: by RAW, charges are - undiscoverable. + """One magic item as the player sees it, masked until identified. + + An unidentified item shows only its category display name, and an enchanted arm shows + its base instead, as in "a sword with a faint aura", the concession made because + *detect magic* exists. An identified one shows its true name and id, and for an arm + the `qualities` and `missile_ranges` of the mundane weapon underneath it: how far the + arm reaches, and in what manner. Both are rulebook facts about a weapon the player + has already identified, and they are what lets a front end tell a melee declaration + from a missile one. Without them an enchanted dagger is unclassifiable where a plain + dagger is not. Charges, sentience, and per-item state never appear at any + identification level, because by the rules as written charges are undiscoverable. """ from osrlib.core.combat import attack_facet from osrlib.data import load_equipment @@ -329,10 +505,10 @@ def _masked_inventory(member) -> dict: def _effect_remaining_rounds(session, effect) -> int | None: - """Remaining rounds for the member-effect view — potion durations stay hidden. + """Remaining rounds for the member-effect view, with potion durations hidden. - By RAW, the referee rolls and tracks a potion's duration and never tells the - player how long it will last, so a potion-sourced effect always reports + By the rules as written the referee rolls and tracks a potion's duration and never + tells the player how long it will last, so a potion-sourced effect always reports `None` here. """ if effect.definition.params.get("item_source") == "potion": @@ -343,13 +519,56 @@ def _effect_remaining_rounds(session, effect) -> int | None: def build_player_view(session) -> PlayerView: - """Build the player view from session state (never from the event log). + """Build the player view from session state, never from the event log. + + Call it after every accepted command to get the snapshot your front end renders, and + send that object rather than the session to any client you do not control. + [`GameSession.view`][osrlib.crawl.session.GameSession.view] with `Visibility.PLAYER` + calls this for you, so use it when you already hold the session and reach for this + function when you want the builder itself. + + The call reads session state and mutates nothing, so building a view twice gives two + equal snapshots and costs the party no game time. Args: session (osrlib.crawl.session.GameSession): The running session. Returns: - The frozen whitelist projection. + The frozen whitelist projection, holding only what the party has learned. + + Examples: + ```python + 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.dungeon import DungeonSpec, LevelSpec + from osrlib.crawl.party import Party + from osrlib.crawl.session import GameSession + from osrlib.crawl.views import build_player_view + + 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,)) + adventure = Adventure(name="A First Delve", town=TownSpec(name="Threshold"), dungeons=(crypt,)) + session = GameSession.new(Party(members=[hero.character]), adventure, seed=7) + + player = build_player_view(session) + print(player.mode) + # town + print(player.party[0].id) + # character-0001 + assert "flags" not in player.model_dump() # session flags are the game's secret + ``` """ members = tuple( MemberView( @@ -427,9 +646,9 @@ def build_player_view(session) -> PlayerView: def _quest_views(session): """The active quests, in document order, each with its revealed objectives. - Walks the authored specs rather than the state block, so the order the view - ships is the order the adventure wrote — and a quest the block does not know is - simply absent, the same way an unresolvable level is. + The walk is over the authored specs rather than the state block, so the order the view + ships is the order the adventure wrote, and a quest the block does not know is absent + rather than an error, the same way an unresolvable level is. """ for quest in session.adventure.quests: state = session.quests.get(quest.id) @@ -479,10 +698,10 @@ def _explored_levels(session): except ValueError: continue # Visible equals walked cells, plus the persisted seen cells the party's - # light has shown it (map memory — see `DungeonState.seen`), plus what its - # light reveals from the current cell right now (the spec's visible flag), - # so lighting a torch draws the room immediately, without a footstep or - # even a command between the ledger and the view. + # light has shown it (map memory; see `DungeonState.seen`), plus what its + # light reveals from the current cell right now, so lighting a torch draws + # the room immediately, without a footstep or even a command between the + # ledger and the view. visible = list(dungeon_state.explored.get(key, [])) known = set(visible) for cell in dungeon_state.seen.get(key, []): @@ -563,11 +782,54 @@ def _encounter_view(session) -> EncounterView | None: def build_referee_view(session) -> RefereeView: """Build the referee view: everything but RNG internals and the seed. + Use it for the context an LLM referee reasons over, for a debugging panel, or for a + test that asserts on state a player may not see. + [`GameSession.view`][osrlib.crawl.session.GameSession.view] with `Visibility.REFEREE` + calls this for you. Never hand the result to a player's client: that is what + [`build_player_view`][osrlib.crawl.views.build_player_view] is for. + + The state it returns is the save's own serialization, including the event log, so a + view of a long session is a large object. Build it when you need it rather than once + per command. + Args: session (osrlib.crawl.session.GameSession): The running session. Returns: - The full-state projection. + The full-state projection, minus the RNG stream positions and the master seed. + + Examples: + ```python + 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.dungeon import DungeonSpec, LevelSpec + from osrlib.crawl.party import Party + from osrlib.crawl.session import GameSession + from osrlib.crawl.views import build_referee_view + + 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,)) + adventure = Adventure(name="A First Delve", town=TownSpec(name="Threshold"), dungeons=(crypt,)) + session = GameSession.new(Party(members=[hero.character]), adventure, seed=7) + + referee = build_referee_view(session) + print(referee.state["mode"], referee.state["clock_rounds"]) + # town 0 + assert "flags" in referee.state # the wiring a player never sees + assert "master_seed" not in referee.state # the seed lives only in the save + ``` """ from osrlib.persistence import session_state From eba3e82651625a63601afa210ec3f5ce2f75a927 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Sun, 13 Sep 2026 22:26:41 -0700 Subject: [PATCH 2/2] Correct six docstring claims found in review The views module docstring said neither builder reads the event log; the referee view is the save's serialization and includes it, so the sentence now separates the two. The player-view exclusion list gains the quests that are not active, since the projection keeps only active ones. RefereeView.state now links the function that names every save key. The interpreter docstring now says that past the cascade bound a quest the event would have activated records one note and stops, so its objective clauses are not evaluated, and that a quest walk returns at the completion it issues, leaving a second objective that matched the same event for the next event. QuestSpec.completion says the same for the author. NarrativeBlock.guidance now says osrlib reads it nowhere and a narrator takes it from the authored document. Claude-Session: https://claude.ai/code/session_01GL26QnA6dCrvUc3WmhzFSa --- src/osrlib/crawl/interpreter.py | 20 ++++++++++++++------ src/osrlib/crawl/narrative.py | 10 ++++++++-- src/osrlib/crawl/quests.py | 7 ++++++- src/osrlib/crawl/views.py | 18 +++++++++++++----- 4 files changed, 41 insertions(+), 14 deletions(-) diff --git a/src/osrlib/crawl/interpreter.py b/src/osrlib/crawl/interpreter.py index 3700c7a..c7ec96b 100644 --- a/src/osrlib/crawl/interpreter.py +++ b/src/osrlib/crawl/interpreter.py @@ -270,6 +270,11 @@ class Interpreter: 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 @@ -286,12 +291,15 @@ class Interpreter: 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, so the walk still evaluates - every trigger and every clause and records each suppressed advancement as a note - instead of issuing it. No state moves when that happens, 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. + 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 diff --git a/src/osrlib/crawl/narrative.py b/src/osrlib/crawl/narrative.py index 2d9a6fd..1a660b0 100644 --- a/src/osrlib/crawl/narrative.py +++ b/src/osrlib/crawl/narrative.py @@ -119,8 +119,14 @@ class NarrativeBlock(BaseModel): which in practice means a trigger's `fired`. Unread by quests, which journal the display text they showed.""" guidance: str = "" - """Steering for an LLM narrator while the carrier is in play. It is never displayed - as written.""" + """Steering for an LLM narrator while the carrier is in play, and never displayed as + written. + + Inert authored data, the way a quest's `progress` beat is: osrlib reads it nowhere, no + event includes it, and no rule turns on it. A narrator reaches it through the adventure + document, which stays on the referee's side of the screen, so read it from the + authored model yourself when you write the narration. The same field on + [`LevelSpec`][osrlib.crawl.dungeon.LevelSpec] does the same job for a whole level.""" speaker: str = "" """An attribution, such as "the bronze sentinel", that a renderer may put in front of a beat. [`QuestView.speaker`][osrlib.crawl.views.QuestView] ships a quest's to the diff --git a/src/osrlib/crawl/quests.py b/src/osrlib/crawl/quests.py index 703b371..04bf9f7 100644 --- a/src/osrlib/crawl/quests.py +++ b/src/osrlib/crawl/quests.py @@ -244,7 +244,12 @@ class QuestSpec(BaseModel): rejected at parse, because the issuing quest stamps it.""" completion: Literal["all", "any"] = "all" """The completion rule: `"all"` requires every objective, `"any"` takes the first one - to land.""" + to land. + + The quest walk stops at the completion it issues, so when one event would complete two + objectives at once, the second one is left incomplete and finishes on the next event + that matches its clause. Under `"any"` that is what usually happens, since the first + objective to land finishes the quest and the rest stay open.""" concludes_adventure: bool = False """Whether finishing this quest ends the adventure. The session moves to `victory` and emits an diff --git a/src/osrlib/crawl/views.py b/src/osrlib/crawl/views.py index 1bebdaa..17d25b2 100644 --- a/src/osrlib/crawl/views.py +++ b/src/osrlib/crawl/views.py @@ -6,8 +6,10 @@ Where the views sit. A command changes the session state that [`GameSession`][osrlib.crawl.session.GameSession] keeps, and [`build_player_view`][osrlib.crawl.views.build_player_view] and -[`build_referee_view`][osrlib.crawl.views.build_referee_view] read that state, and only -that state, to build these projections. They never read the event log. +[`build_referee_view`][osrlib.crawl.views.build_referee_view] read that state to build +these projections. The player view is built from session state alone and never from the +event log; the referee view is the save's own serialization, so it includes the event log +along with everything else the save keeps. [`GameSession.view`][osrlib.crawl.session.GameSession.view] is the entry point most games call, with a [`Visibility`][osrlib.core.events.Visibility] to pick which one. Events tell you what just happened, and a view tells you what is true now. The ids a view includes, @@ -26,7 +28,8 @@ It never includes unexplored geometry, undiscovered traps or secret doors, monster hit points or stat internals, referee-visibility roll outcomes, session flags, trigger fired-marks, referee notes, quest wiring such as activation clauses, patterns, -conditions, rewards, hidden objectives, and inactive quests, RNG state, or the master +conditions, rewards, and hidden objectives, the quests that are not active, meaning both +the ones nobody has taken on yet and the ones already finished, RNG state, or the master seed, which lives only in the save and reaches neither view. The referee view includes everything else the save does, minus RNG internals and the seed, @@ -422,8 +425,13 @@ class RefereeView(BaseModel): state: dict """The whole session state as the save serializes it, including the event log, minus - the RNG stream positions and the master seed. The keys are the save's keys, so - `state["flags"]` is the flag store and `state["command_log"]` the command log.""" + the RNG stream positions and the master seed. + + The keys are the save's keys, so `state["flags"]` is the flag store, + `state["command_log"]` the command log, and `state["dungeon_state"]` the map overlay. + [`session_state`][osrlib.persistence.session_state] is the function that builds the + dict and names every key, and [`osrlib.persistence`][osrlib.persistence] describes + what a save holds.""" _MASKED_CATEGORY_NAMES = {