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..c7ec96b 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,164 @@ 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. + + A quest walk stops at the completion it issues. The rewards go out and the walk + returns, so an objective later in the tuple whose clause also matches this event is + left incomplete, and it completes on the next event that matches it. Under the `any` + rule that is the usual case, since the first objective to land finishes the quest. + + Where the interpreter's discipline stops and the referee's ruling begins. The + completion rule is checked only after a completion the interpreter itself issued, and + no pattern matches the quest events, so a game that completes the last objective by + hand completes the quest by hand too. For the same reason a hand-driven + [`CompleteQuest`][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. Every trigger is still + evaluated, and so is every clause of a quest that is already active, with each + suppressed advancement recorded as a note instead of being issued. The exception is a + quest the event would have activated: that walk records one note for the activation it + did not issue and stops there, so the objective clauses of that quest are not evaluated + for this event. No state moves in any of these cases, so a once-only trigger cut short + here is still fireable later, and a suppressed quest advancement waits for its clause + to match again. Clauses are edge-triggered on both surfaces, so the suppressed edge + itself is gone. + + What it never does. It returns no events, because everything it causes is already + logged by the commands it executed, and it keeps no memory between commands. Read + what a trigger or a quest did from the command log, the journal, `session.fired_triggers`, and `session.quests`, all of which a replay rebuilds. + + Examples: + ```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 +384,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 +464,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 +524,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 +560,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 +571,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..1a660b0 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,38 @@ 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, 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 ("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..04bf9f7 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]). + 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]. - `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. - - `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,51 @@ 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. + + 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 + [`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 +278,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..17d25b2 100644 --- a/src/osrlib/crawl/views.py +++ b/src/osrlib/crawl/views.py @@ -1,30 +1,43 @@ """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 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, +[`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, 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, +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 +66,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 +310,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 +319,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 +334,104 @@ 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, + `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 = { @@ -261,18 +446,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 +513,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 +527,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 +654,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 +706,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 +790,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