diff --git a/docs/front-ends/fastapi-pattern.md b/docs/front-ends/fastapi-pattern.md index a9cbca2..0f0a1bb 100644 --- a/docs/front-ends/fastapi-pattern.md +++ b/docs/front-ends/fastapi-pattern.md @@ -35,7 +35,7 @@ Two details of that handler matter: ## The served content and its interpreter -The barrow is authored content: gated doors, a fetch quest, the works. Nothing plays that content until the [`Interpreter`][osrlib.crawl.interpreter.Interpreter] is registered on the session (see [Gates, triggers, and quests](../guides/gates-triggers-quests.md)). The server owns that wiring in `content.py`, and it happens on **both** entry paths, the fresh session and the restored one. A fresh session registers the interpreter the moment it is built: +The barrow is authored content: gated doors, a fetch quest, the works. Nothing plays that content until the [`Interpreter`][osrlib.crawl.interpreter.Interpreter] is registered on the session (see [Gates, triggers, and quests](../guides/gates-triggers-quests.md)). The server owns that setup in `content.py`, and it happens on **both** entry paths, the fresh session and the restored one. A fresh session registers the interpreter the moment it is built: ```{.python .no-run} --8<-- "examples/fastapi_crawler/content.py:new-session" diff --git a/docs/front-ends/llm-referees.md b/docs/front-ends/llm-referees.md index d06c863..70a76e8 100644 --- a/docs/front-ends/llm-referees.md +++ b/docs/front-ends/llm-referees.md @@ -91,7 +91,7 @@ A practical narrator prompt sends the structured events (or their codes and fiel An adventure written for the authored layer ([Gates, triggers, and quests](../guides/gates-triggers-quests.md)) arrives with material aimed squarely at a narrating referee, and an agent serving one should use all of it. -**Register the library's [`Interpreter`][osrlib.crawl.interpreter.Interpreter] and do no bookkeeping.** One `session.register_listener(Interpreter(session))` after the session is built (and again after a load), and the interpreter plays the triggers, the quests, the fired-marks, and the rewards as ordinary logged commands. The agent referees, and the interpreter runs the adventure's wiring. +**Register the library's [`Interpreter`][osrlib.crawl.interpreter.Interpreter] and do no bookkeeping.** One `session.register_listener(Interpreter(session))` after the session is built (and again after a load), and the interpreter plays the triggers, the quests, the fired-marks, and the rewards as ordinary logged commands. The agent referees, and the interpreter runs the adventure's setup. **`guidance` is steering, never script.** [`NarrativeBlock`][osrlib.crawl.narrative.NarrativeBlock] has a `guidance` field on any authored object, and [`LevelSpec.guidance`][osrlib.crawl.dungeon.LevelSpec] holds whole-level ambience that hangs on no object at all. The TUI barrow's first level reads: diff --git a/docs/front-ends/tui-crawler.md b/docs/front-ends/tui-crawler.md index fa9e1ab..ea4d4c0 100644 --- a/docs/front-ends/tui-crawler.md +++ b/docs/front-ends/tui-crawler.md @@ -49,7 +49,7 @@ The event-level `Visibility` check above hides individual referee-only lines. Th ## The authored adventure -`content.py` builds the game's whole world: a town, a two-level barrow, and the errand that ends it, assembled from the same authoring models [Building an adventure](../getting-started/building-an-adventure.md) walks through. A keyed area binds content (descriptive text, an encounter, features) to a set of cells. The shrine below binds prose and the cache that holds the quest's MacGuffin, named by id so that taking it is something the quest can match on (the goblins are keyed to a different room): +`content.py` builds the game's whole world: a town, a two-level barrow, and the errand that ends it, assembled from the same authoring models [Building an adventure](../getting-started/building-an-adventure.md) walks through. A keyed area attaches content (descriptive text, an encounter, features) to a set of cells. The shrine below attaches prose and the cache that holds the quest's MacGuffin, named by id so that taking it is something the quest can match on (the goblins are keyed to a different room): ```{.python .no-run} --8<-- "examples/tui_crawler/content.py:idol-shrine-area" diff --git a/docs/getting-started/building-an-adventure.md b/docs/getting-started/building-an-adventure.md index a360695..ff60686 100644 --- a/docs/getting-started/building-an-adventure.md +++ b/docs/getting-started/building-an-adventure.md @@ -33,7 +33,7 @@ An [`Edge`][osrlib.crawl.dungeon.Edge] is `open`, `wall`, or `door`. A door edge ## Keyed areas -Cells not covered by any area are corridor. An [`AreaSpec`][osrlib.crawl.dungeon.AreaSpec] names a region (a room, a cave, a shrine) and binds content to it: descriptive prose for your front end, an encounter, a trap, treasure. The party triggers an area's content by stepping into any of its cells: +Cells not covered by any area are corridor. An [`AreaSpec`][osrlib.crawl.dungeon.AreaSpec] names a region (a room, a cave, a shrine) and attaches content to it: descriptive prose for your front end, an encounter, a trap, treasure. The party triggers an area's content by stepping into any of its cells: ```{.python .no-run} areas=( @@ -48,7 +48,7 @@ Cells not covered by any area are corridor. An [`AreaSpec`][osrlib.crawl.dungeon ) ``` -A [`KeyedEncounter`][osrlib.crawl.dungeon.KeyedEncounter] lists its monsters by template id, each with a fixed count or count dice. A template id is any id from [`load_monsters`][osrlib.data.load_monsters], listed in [the monster id index][monsters-index], or the id of a monster the adventure bundles (see [Bundling custom monsters with an adventure](../guides/authoring-custom-content.md#bundling-custom-monsters-with-an-adventure)). You can also pin the monsters' awareness, stance, or alignment. +A [`KeyedEncounter`][osrlib.crawl.dungeon.KeyedEncounter] lists its monsters by template id, each with a fixed count or count dice. A template id is any id from [`load_monsters`][osrlib.data.load_monsters], listed in [the monster id index][monsters-index], or the id of a monster the adventure bundles (see [Bundling custom monsters with an adventure](../guides/authoring-custom-content.md#bundling-custom-monsters-with-an-adventure)). You can also set the monsters' awareness, stance, or alignment. Beyond encounters, an area (or the level itself) can contain: @@ -60,7 +60,7 @@ Beyond encounters, an area (or the level itself) can contain: ## The dungeon, the town, and the root -You slot the level into a [`DungeonSpec`][osrlib.crawl.dungeon.DungeonSpec], and the dungeon into an [`Adventure`][osrlib.crawl.adventure.Adventure] beside the [`TownSpec`][osrlib.crawl.adventure.TownSpec]. The town is the safe base where the party rests, buys equipment, and sells treasure. `travel_turns` maps each dungeon id to the town-to-entrance travel cost in exploration turns: +You put the level into a [`DungeonSpec`][osrlib.crawl.dungeon.DungeonSpec], and the dungeon into an [`Adventure`][osrlib.crawl.adventure.Adventure] beside the [`TownSpec`][osrlib.crawl.adventure.TownSpec]. The town is the safe base where the party rests, buys equipment, and sells treasure. `travel_turns` maps each dungeon id to the town-to-entrance travel cost in exploration turns: ```{.python .no-run} barrow = DungeonSpec(id="barrow", name="The Barrow", levels=(level,)) @@ -157,7 +157,7 @@ assert opened.accepted result = session.execute(MoveParty(direction=Direction.EAST)) assert result.accepted -# Stepping into the keyed area spawns the goblins and starts an encounter. +# Stepping into the keyed area creates the goblins and starts an encounter. assert session.mode is SessionMode.ENCOUNTER assert len(session.monsters) == 2 ``` diff --git a/docs/guides/authoring-custom-content.md b/docs/guides/authoring-custom-content.md index 348b335..784293b 100644 --- a/docs/guides/authoring-custom-content.md +++ b/docs/guides/authoring-custom-content.md @@ -1,6 +1,6 @@ # Authoring custom classes, spells, monsters, and items -A build pipeline compiles the OSE SRD into the package's data files: the classes, the spell list, the monster catalog, and the equipment lists that ship with osrlib. That pipeline isn't the extension point, and you can't feed your own content into it. What you can do is write your own class, spell, monster, and item definitions in code, validate them the same way the shipped catalogs validate their own content, and run them through the same kernel that plays a fighter or a goblin: creation, advancement, memorization, casting, and (for monsters) spawning, combat, XP, and treasure. A class's `race` and a spell's `spell_list` are both open, validated string ids for exactly this reason. Nothing in the kernel restricts them to the values the shipped classes use. +A build pipeline compiles the OSE SRD into the package's data files: the classes, the spell list, the monster catalog, and the equipment lists that ship with osrlib. That pipeline isn't the extension point, and you can't feed your own content into it. What you can do is write your own class, spell, monster, and item definitions in code, validate them the same way the shipped catalogs validate their own content, and run them through the same kernel that plays a fighter or a goblin: creation, advancement, memorization, casting, and (for monsters) creation, combat, XP, and treasure. A class's `race` and a spell's `spell_list` are both open, validated string ids for exactly this reason. Nothing in the kernel restricts them to the values the shipped classes use. Start with one small custom class and one custom spell for it. The [complete program](#the-complete-program) runs every step shown along the way. After that come [bundling custom monsters with an adventure](#bundling-custom-monsters-with-an-adventure) and [bundling custom items with an adventure](#bundling-custom-items-with-an-adventure), both for the crawl layer. The scope here is the content catalogs, the *things* a game and its adventures can contain. Authored *behavior* is a different surface: the gated door, the trigger, and the quest are covered in [Gates, triggers, and quests](gates-triggers-quests.md). @@ -134,11 +134,11 @@ assert warden.thac0 == 19 assert warden.saves.spells == 14 ``` -That's a plain Python module attribute, not a documented plugin point with its own function. If you want characters of a custom class, do the reassignment once, at startup, before you build or load any character, rather than calling it per character. `race` needs no such wiring. Both `ClassDefinition` and `Character` validate it against a slug pattern and nothing else, and no procedure looks it up anywhere, so any race string the two sides agree on already works. +That's a plain Python module attribute, not a documented plugin point with its own function. If you want characters of a custom class, do the reassignment once, at startup, before you build or load any character, rather than calling it per character. `race` needs no such setup. Both `ClassDefinition` and `Character` validate it against a slug pattern and nothing else, and no procedure looks it up anywhere, so any race string the two sides agree on already works. ## Advancing and casting -With the catalogs extended and the loader binding pointed at them, the rest of the lifecycle is the same kernel calls a shipped class goes through. `level_up` reads next level's row straight off `WARDEN`. `memorize_spells` checks the caster's list and slot capacity against the extended spell catalog. Casting consumes the memorized copy and resolves the mode's effect, which here heals a wounded ally by touch: +With the catalogs extended and the loader pointed at them, the rest of the lifecycle is the same kernel calls a shipped class goes through. `level_up` reads next level's row straight off `WARDEN`. `memorize_spells` checks the caster's list and slot capacity against the extended spell catalog. Casting consumes the memorized copy and resolves the mode's effect, which here heals a wounded ally by touch: ```{.python .no-run} streams = RngStreams(master_seed=2026) @@ -292,7 +292,7 @@ rejections = validate_class_choice(low_scores, WARDEN) assert [rejection.code for rejection in rejections] == ["creation.class.requirements_not_met"] # The one seam: Character.definition resolves load_classes() from this module's -# namespace, so a game holding custom definitions swaps that binding once, up front. +# namespace, so a game holding custom definitions reassigns it once, up front. character_module.load_classes = lambda: classes scores = {ability: 11 for ability in AbilityScore} | {AbilityScore.WIS: 13, AbilityScore.CON: 13} @@ -353,7 +353,7 @@ assert warden.memorized_spells == () ## Bundling custom monsters with an adventure -Monsters take a different transport than classes and spells, because the crawl layer already has a document that contains content: the adventure. `Adventure.monsters` bundles your own [`MonsterTemplate`][osrlib.core.monsters.MonsterTemplate]s with the adventure document, and every session running that adventure resolves them everywhere it resolves a shipped template id: keyed encounters, [`SpawnMonsters`][osrlib.crawl.commands.SpawnMonsters], inline wandering tables, listen checks, and [`GameSession.spawn`][osrlib.crawl.session.GameSession.spawn]. No loader reassignment, no registration. The document contains the content, and [`GameSession.effective_monsters`][osrlib.crawl.session.GameSession.effective_monsters] is the shipped catalog plus the bundle. Downstream of spawning, nothing is different for a bundled monster. A spawned [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] embeds its full template, so combat, morale, XP, treasure, saves, and replay never look the id up again. +Monsters take a different transport than classes and spells, because the crawl layer already has a document that contains content: the adventure. `Adventure.monsters` bundles your own [`MonsterTemplate`][osrlib.core.monsters.MonsterTemplate]s with the adventure document, and every session running that adventure resolves them everywhere it resolves a shipped template id: keyed encounters, [`SpawnMonsters`][osrlib.crawl.commands.SpawnMonsters], inline wandering tables, listen checks, and [`GameSession.spawn`][osrlib.crawl.session.GameSession.spawn]. No loader reassignment, no registration. The document contains the content, and [`GameSession.effective_monsters`][osrlib.crawl.session.GameSession.effective_monsters] is the shipped catalog plus the bundle. Downstream of creation, nothing is different for a bundled monster. A created [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] embeds its full template, so combat, morale, XP, treasure, saves, and replay never look the id up again. A template is a frozen model you build with `model_validate`, exactly like the class and spell above. Three table helpers derive the stat-block numbers the SRD would print, so your creation matches the attack matrix, the monster save bands, and the XP awards table: [`thac0_for_hd`][osrlib.core.tables.thac0_for_hd], [`monster_save_band_label`][osrlib.core.tables.monster_save_band_label], and [`monster_xp`][osrlib.core.tables.monster_xp]. The one rule is the collision rule: a bundled id must not collide with the shipped catalog or another bundled id. [`validate_adventure`][osrlib.crawl.adventure.validate_adventure] rejects a collision outright and never overrides, so give a variant orc a variant id. [The monster id index][monsters-index] documents the shipped catalog only. Bundled ids live in the adventure that includes them: @@ -431,7 +431,7 @@ hero = create_character(name="Hild", class_id="fighter", alignment=Alignment.LAW session = GameSession.new(Party(members=[hero.character]), adventure, seed=7) # The session's effective catalog resolves the bundled id — the very object the -# adventure carries — and spawning embeds it in each instance. +# adventure carries — and creation embeds it in each instance. assert session.effective_monsters.get("bone_warden") is BONE_WARDEN guards = session.spawn("bone_warden", 2) assert [guard.template.id for guard in guards] == ["bone_warden", "bone_warden"] @@ -549,7 +549,7 @@ Third, some class policies are written as id lists. A class whose [`WeaponPolicy There's no merge path into the shipped content. `load_classes` and `load_spells` are cached loaders that read the generated `classes.json` and `spells.json` shipped inside the package. There's no append or register call, so an extended catalog is always a value your own code builds and keeps: `classes` and `spells` above, never something fed back into the loaders themselves. `load_monsters` and `load_equipment` are just as closed. Bundling ([monsters](#bundling-custom-monsters-with-an-adventure), [items](#bundling-custom-items-with-an-adventure)) joins your templates to the shipped catalog per session through the adventure document that contains them, and the shipped catalog objects never change. -[`create_character`][osrlib.core.character.create_character], the one-call wrapper used in [the quickstart](../getting-started/quickstart.md), resolves its `class_id` argument through `load_classes().get(class_id)`. That's the same module attribute `Character.definition` reads, so [the seam above](#the-one-seam-characters-of-a-custom-class) covers the wrapper too: reassign `load_classes` and `create_character(class_id="warden", ...)` rolls a warden. Leave the binding alone and the wrapper finds shipped ids only. Not one of the stepwise creation functions resolves a class by id, so none of them needs the seam: [`roll_ability_scores`][osrlib.core.character.roll_ability_scores], `validate_class_choice`, [`roll_hit_points`][osrlib.core.character.roll_hit_points], [`validate_extra_languages`][osrlib.core.character.validate_extra_languages], [`roll_starting_gold`][osrlib.core.character.roll_starting_gold], and [`choose_starting_spells`][osrlib.core.character.choose_starting_spells] take a `ClassDefinition` object or nothing but a stream, and they run the identical procedure `create_character` composes. +[`create_character`][osrlib.core.character.create_character], the one-call wrapper used in [the quickstart](../getting-started/quickstart.md), resolves its `class_id` argument through `load_classes().get(class_id)`. That's the same module attribute `Character.definition` reads, so [the seam above](#the-one-seam-characters-of-a-custom-class) covers the wrapper too: reassign `load_classes` and `create_character(class_id="warden", ...)` rolls a warden. Leave the assignment alone and the wrapper finds shipped ids only. Not one of the stepwise creation functions resolves a class by id, so none of them needs the seam: [`roll_ability_scores`][osrlib.core.character.roll_ability_scores], `validate_class_choice`, [`roll_hit_points`][osrlib.core.character.roll_hit_points], [`validate_extra_languages`][osrlib.core.character.validate_extra_languages], [`roll_starting_gold`][osrlib.core.character.roll_starting_gold], and [`choose_starting_spells`][osrlib.core.character.choose_starting_spells] take a `ClassDefinition` object or nothing but a stream, and they run the identical procedure `create_character` composes. The `load_classes` reassignment above is a plain module attribute, not a supported extension API with its own function or parameter. There's nothing to call except swapping the name, and nothing checks that you swapped it back. If your game has custom classes, reassign it once at startup and keep your extended catalog as the only `load_classes` your characters ever see for the life of the process, the same way the [complete program](#the-complete-program) above does. Spells need no equivalent seam. Nothing resolves a spell by id off a character the way `Character.definition` resolves a class, so `SpellCatalog.get` and `SpellCatalog.by_list` calls against your own extended catalog are all a caster needs. @@ -557,7 +557,7 @@ For the ids the shipped catalogs already use, see [the class id index][classes-i ## Where next -- [Building an adventure](../getting-started/building-an-adventure.md) - the dungeon geometry and keyed content the bundled monsters and items above bind into. +- [Building an adventure](../getting-started/building-an-adventure.md) - the dungeon geometry and keyed content the bundled monsters and items above attach to. - [Gates, triggers, and quests](gates-triggers-quests.md) - authored behavior: the gate that a bundled key opens, the trigger that matches a bundled id, the quest that ends the adventure. - [Sessions, commands, and events](sessions-commands-events.md) - running a character, custom class or not, through an actual session once it exists. - [The API reference](../reference/api/index.md) - the full model and function reference for everything named on this page. diff --git a/docs/guides/determinism-saves-replay.md b/docs/guides/determinism-saves-replay.md index 09f4652..ac147f5 100644 --- a/docs/guides/determinism-saves-replay.md +++ b/docs/guides/determinism-saves-replay.md @@ -19,7 +19,7 @@ assert save_game(session_a) == save_game(session_b) ## Saves -[`save_game`][osrlib.persistence.save_game] serializes a running [`GameSession`][osrlib.crawl.session.GameSession] to a JSON-compatible dict: the party, the embedded adventure content, dungeon state, the clock, every exported RNG stream position, the master seed, the session-state blocks the extension and authored layers write (the flag store, each registered listener's state slot, the trigger fired-marks, the journal, and quest state), the accepted-command log, and the event log. Pass `include_event_log=False` to leave the event log out. Your authored progress survives, all of it, by construction. For more about the session-state blocks, see [Listeners and flags](listeners-and-flags.md) and [Gates, triggers, and quests](gates-triggers-quests.md). +[`save_game`][osrlib.persistence.save_game] serializes a running [`GameSession`][osrlib.crawl.session.GameSession] to a JSON-compatible dict: the party, the embedded adventure content, dungeon state, the clock, every exported RNG stream position, the master seed, the session-state blocks the extension and authored layers write (the flag store, each registered listener's state entry, the trigger fired-marks, the journal, and quest state), the accepted-command log, and the event log. Pass `include_event_log=False` to leave the event log out. Your authored progress survives, all of it, by construction. For more about the session-state blocks, see [Listeners and flags](listeners-and-flags.md) and [Gates, triggers, and quests](gates-triggers-quests.md). [`load_game`][osrlib.persistence.load_game] reconstructs a session from that dict by restoring each piece exactly, RNG stream positions included, so a loaded game continues drawing from precisely where the saved game left off: diff --git a/docs/guides/gates-triggers-quests.md b/docs/guides/gates-triggers-quests.md index 8bdc3a0..6cd8cf1 100644 --- a/docs/guides/gates-triggers-quests.md +++ b/docs/guides/gates-triggers-quests.md @@ -1,6 +1,6 @@ # Gates, triggers, and quests -You want a door that opens only with the right key, a lever that opens a portcullis across the map, an errand that ends the adventure when the party finishes it. You author all three as data: a **gate** guards an attempt, a **trigger** reacts to an event, and a **quest** keeps score toward an ending. All three live in the adventure document beside the dungeons they wire. Nothing plays them until your game registers the library's [`Interpreter`][osrlib.crawl.interpreter.Interpreter], a listener like the ones in [Listeners and flags](listeners-and-flags.md). The library ships it because you need one for every authored adventure. [The complete program](#the-complete-program) at the end runs as written, and every snippet along the way comes from it. Where a snippet comes from [the TUI crawler's](../front-ends/tui-crawler.md) authored adventure instead, the text says so. +You want a door that opens only with the right key, a lever that opens a portcullis across the map, an errand that ends the adventure when the party finishes it. You author all three as data: a **gate** guards an attempt, a **trigger** reacts to an event, and a **quest** keeps score toward an ending. All three live in the adventure document beside the dungeons they connect. Nothing plays them until your game registers the library's [`Interpreter`][osrlib.crawl.interpreter.Interpreter], a listener like the ones in [Listeners and flags](listeners-and-flags.md). The library ships it because you need one for every authored adventure. [The complete program](#the-complete-program) at the end runs as written, and every snippet along the way comes from it. Where a snippet comes from [the TUI crawler's](../front-ends/tui-crawler.md) authored adventure instead, the text says so. The door itself, the edge and its [`DoorSpec`][osrlib.crawl.dungeon.DoorSpec], is dungeon geometry. [Building an adventure](../getting-started/building-an-adventure.md#the-grid-and-its-edges) covers it, along with the keyed areas and transitions you hang these conditions on. @@ -9,7 +9,7 @@ The door itself, the edge and its [`DoorSpec`][osrlib.crawl.dungeon.DoorSpec], i A door or a level transition can have a [`GateSpec`][osrlib.crawl.gates.GateSpec] on its `requires` field: an authored condition the party must satisfy for the attempt to be legal. The engine evaluates the condition live, at the moment the party tries the door, and stores nothing, so a key that gets dropped or sold stops opening it: - [`HasItemCondition`][osrlib.crawl.gates.HasItemCondition] - some member's carried inventory contains an item with that catalog id. Any member's pack counts, equipped slots included. The id must resolve against the equipment catalog (bundled items included, see [authoring custom content](authoring-custom-content.md)) or the magic-item catalog. A gate naming an unknown id fails validation. -- [`FlagEqualsCondition`][osrlib.crawl.gates.FlagEqualsCondition] - a session flag equals a value. Your game sets flags with [`SetFlag`][osrlib.crawl.commands.SetFlag], so this is the lever-opens-the-portcullis wiring. The comparison is strict: an absent key matches nothing, not even `False`, and a stored `True` never satisfies an authored `1`. +- [`FlagEqualsCondition`][osrlib.crawl.gates.FlagEqualsCondition] - a session flag equals a value. Your game sets flags with [`SetFlag`][osrlib.crawl.commands.SetFlag], so this is the lever-opens-the-portcullis setup. The comparison is strict: an absent key matches nothing, not even `False`, and a stored `True` never satisfies an authored `1`. - [`EffectActiveCondition`][osrlib.crawl.gates.EffectActiveCondition] - an active effect of that kind is attached to a party member. Use it when the talisman has to be invoked rather than merely carried. A refused attempt is an ordinary rejection, `exploration.door.gate_refused` or `exploration.transition.gate_refused`, with the gate's authored refusal text in it. It costs nothing: no dice, no game time, no items, and no change to the door. @@ -28,11 +28,11 @@ Locks and gates are separate layers: on a door with both, the party must open th `consumes=True` turns a `has_item` condition into a toll: each time the gated command succeeds, one instance leaves the first holder in marching order, reported by [`ItemConsumedEvent`][osrlib.crawl.events.ItemConsumedEvent] just before the door or arrival event. Every success charges again, so a consumed key-door that swings shut takes another key. Coins are not items and can't be tolled. To charge one, mint a token as a bundled item and gate on that. -A [`NarrativeBlock`][osrlib.crawl.narrative.NarrativeBlock] contains the authored text for the mechanical object it hangs on. Gates read two of its beats: `refusal`, returned in the rejection, and `success`, which goes on the successful command's event, the [`DoorEvent`][osrlib.crawl.events.DoorEvent] for a door and the [`LocationEnteredEvent`][osrlib.crawl.events.LocationEnteredEvent] for a transition that crosses into a new level or dungeon. [`format_message`][osrlib.messages.format_message] appends the beat verbatim, so it shows up in a bare transcript. A transition whose destination is its own level crosses no boundary and emits no arrival event, so a success beat there has nowhere to display. A narrator that wants the gate's `guidance` alongside its success text maps the event back to the object the gate hangs on: a door event names the cell and the side, and a transition's arrival names the cell the transition stands on in `transition_ref`, so neither mapping has to guess from where the party is standing now. The block's other fields are `journal`, `guidance` for an LLM narrator, and `speaker`, each read by the surface that consumes it. A gate's `journal` beat has no consumer at all: journaling a door is [a trigger's](#wiring-the-dungeon-with-triggers) job. No part of a gate's narrative block reaches the player view, which contains no gate wiring at all. +A [`NarrativeBlock`][osrlib.crawl.narrative.NarrativeBlock] contains the authored text for the mechanical object it hangs on. Gates read two of its beats: `refusal`, returned in the rejection, and `success`, which goes on the successful command's event, the [`DoorEvent`][osrlib.crawl.events.DoorEvent] for a door and the [`LocationEnteredEvent`][osrlib.crawl.events.LocationEnteredEvent] for a transition that crosses into a new level or dungeon. [`format_message`][osrlib.messages.format_message] appends the beat verbatim, so it shows up in a bare transcript. A transition whose destination is its own level crosses no boundary and emits no arrival event, so a success beat there has nowhere to display. A narrator that wants the gate's `guidance` alongside its success text maps the event back to the object the gate hangs on: a door event names the cell and the side, and a transition's arrival names the cell the transition stands on in `transition_ref`, so neither mapping has to guess from where the party is standing now. The block's other fields are `journal`, `guidance` for an LLM narrator, and `speaker`, each read by the surface that consumes it. A gate's `journal` beat has no consumer at all: journaling a door is [a trigger's](#wiring-the-dungeon-with-triggers) job. No part of a gate's narrative block reaches the player view, which contains no gate setup at all. ## Wiring the dungeon with triggers -A gate governs an attempt, and the engine checks it every time the party makes one. A trigger works the other way: it reacts to something that already happened. A [`TriggerSpec`][osrlib.crawl.triggers.TriggerSpec] binds an observable event pattern to referee-command consequences, like the lever that opens the portcullis, the idol whose theft wakes the temple, or the room whose first crossing writes a line in the party's journal: +A gate governs an attempt, and the engine checks it every time the party makes one. A trigger works the other way: it reacts to something that already happened. A [`TriggerSpec`][osrlib.crawl.triggers.TriggerSpec] attaches an observable event pattern to referee-command consequences, like the lever that opens the portcullis, the idol whose theft wakes the temple, or the room whose first crossing writes a line in the party's journal: ```{.python .no-run} sentinel_wakes = TriggerSpec( @@ -46,7 +46,7 @@ sentinel_wakes = TriggerSpec( ) ``` -Triggers live in the adventure document alongside the content they wire. `Adventure.items` contains the adventure's own item templates: the brass key the sentinel wants is content, not shipped equipment (see [authoring custom content](authoring-custom-content.md) for the whole bundling contract). `Adventure.triggers` contains the wiring, and the tuple is document order. When two triggers match the same event, they fire in the order you wrote them. +Triggers live in the adventure document alongside the content they connect. `Adventure.items` contains the adventure's own item templates: the brass key the sentinel wants is content, not shipped equipment (see [authoring custom content](authoring-custom-content.md) for the whole bundling contract). `Adventure.triggers` contains the connections, and the tuple is document order. When two triggers match the same event, they fire in the order you wrote them. Triggers are inert content on their own. They play once your game registers the library's [`Interpreter`][osrlib.crawl.interpreter.Interpreter] on the session. Register it right after you build the session, and again after you load a save, because listeners are code and a save contains only data: @@ -80,11 +80,11 @@ The interpreter issues ordinary referee commands, and stamps every one `source=" A consequence is one of the [`ConsequenceCommand`][osrlib.crawl.commands.ConsequenceCommand] types: `GrantItem`, `GrantCoins`, `AwardXP`, `SetFlag`, `SpawnMonsters`, `SpawnNpcParty`, `SetDoorState`, `PlaceParty`, and `AdvanceTime`. Anything else fails to parse. A consequence that hands something to a character names the character with a party selector rather than an id: [`PARTY_SELECTOR`][osrlib.crawl.triggers.PARTY_SELECTOR] (`"@party"`) becomes one command per living member in marching order, and [`FIRST_LIVING_SELECTOR`][osrlib.crawl.triggers.FIRST_LIVING_SELECTOR] (`"@first"`) becomes one command for the lead survivor. Character ids are allocated per session, so a document naming one would name something that doesn't exist when the engine reads the document. Validation rejects it. -The two beats have two different audiences: **`fired` is the referee's line and `journal` is the players'.** The `fired` text goes on a referee-visibility event, because content wiring is your game's secret. The journal entry is player-visible and ships verbatim in the [`PlayerView`][osrlib.crawl.views.PlayerView]. If you want the table to read something when a trigger fires, write the journal form. +The two beats have two different audiences: **`fired` is the referee's line and `journal` is the players'.** The `fired` text goes on a referee-visibility event, because these connections are your game's secret. The journal entry is player-visible and ships verbatim in the [`PlayerView`][osrlib.crawl.views.PlayerView]. If you want the table to read something when a trigger fires, write the journal form. ### When something doesn't land -A trigger firing is not all-or-nothing. When the session rejects one consequence, like a spawn refused because an encounter is already open, or a grant naming an item the catalog no longer has, that consequence alone is dropped and the consequences after it still run. A [`RecordNote`][osrlib.crawl.commands.RecordNote] records the trigger, the consequence's position and type, and the rejection code. There is no retry and no queue, because a consequence that fired later, out of order, would be impossible to debug. +A trigger firing is not all-or-nothing. When the session rejects one consequence, like a spawn command refused because an encounter is already open, or a grant naming an item the catalog no longer has, that consequence alone is dropped and the consequences after it still run. A [`RecordNote`][osrlib.crawl.commands.RecordNote] records the trigger, the consequence's position and type, and the rejection code. There is no retry and no queue, because a consequence that fired later, out of order, would be impossible to debug. Cascades are bounded. A trigger's own events are one level deeper than the event that fired it, and matching stops below depth five. When the bound suppresses a firing, the interpreter records a note rather than a mark, so a once-only trigger cut short there can still fire later. Chaining flags is a normal thing to author, and the bound is what guarantees a chain that loops still ends. @@ -118,7 +118,7 @@ Drop the idol into a cache by id (`item_ids=("jade-idol",)`) and add its templat ### Hidden objectives and reveals -`objectives` contains at least one [`ObjectiveSpec`][osrlib.crawl.quests.ObjectiveSpec], in the order you write them, and each one has a completion clause of its own. `hidden=True` keeps an objective out of the player view until either its own `reveal_when` clause matches or the party completes it, because completing an objective reveals it. A hidden objective with no reveal clause is a normal shape: the party learns about it by doing it. A `reveal_when` on an objective that was never hidden is rejected at parse, because nothing would read that wiring. +`objectives` contains at least one [`ObjectiveSpec`][osrlib.crawl.quests.ObjectiveSpec], in the order you write them, and each one has a completion clause of its own. `hidden=True` keeps an objective out of the player view until either its own `reveal_when` clause matches or the party completes it, because completing an objective reveals it. A hidden objective with no reveal clause is a normal shape: the party learns about it by doing it. A `reveal_when` on an objective that was never hidden is rejected at parse, because nothing would read that setup. ### The completion rule and the ending @@ -311,7 +311,7 @@ assert opened.events[0].narrative == "The brass key turns in the sentinel's palm result = session.execute(MoveParty(direction=Direction.EAST)) assert result.accepted -# Stepping into the keyed area spawns the goblins and starts an encounter. +# Stepping into the keyed area creates the goblins and starts an encounter. assert session.mode is SessionMode.ENCOUNTER assert len(session.monsters) == 2 ``` diff --git a/docs/guides/listeners-and-flags.md b/docs/guides/listeners-and-flags.md index 21adafa..bbf5695 100644 --- a/docs/guides/listeners-and-flags.md +++ b/docs/guides/listeners-and-flags.md @@ -6,7 +6,7 @@ If you'd like to jump right to the code, [the complete program](#the-complete-pr ## Listeners: reacting to committed events -A listener satisfies the [`Listener`][osrlib.crawl.session.Listener] protocol: a `key` string that names its slot in the session's state, and a `handle` method with this shape: +A listener satisfies the [`Listener`][osrlib.crawl.session.Listener] protocol: a `key` string that names its entry in the session's state, and a `handle` method with this shape: ```{.python .no-run} def handle(self, events: Sequence[Event], state: dict) -> tuple[list[Event], dict]: @@ -34,11 +34,11 @@ The nested-`execute` call matters for a second reason: it re-enters the entire d ## listener_state: what survives, what doesn't -A listener's state dict is the only part of it a save file contains. `register_listener` reserves an empty slot for the listener's key on registration, and every save and load round-trips `listener_state` verbatim as plain JSON-compatible data. The listener *object* itself never serializes, because it's code and not data, so after loading a saved game you must call `register_listener` again, with the same listeners in the same order, before any of them will see another event. For more information about how loading and replay work, see [Determinism, saves, and replay](determinism-saves-replay.md). +A listener's state dict is the only part of it a save file contains. `register_listener` reserves an empty entry for the listener's key on registration, and every save and load round-trips `listener_state` verbatim as plain JSON-compatible data. The listener *object* itself never serializes, because it's code and not data, so after loading a saved game you must call `register_listener` again, with the same listeners in the same order, before any of them will see another event. For more information about how loading and replay work, see [Determinism, saves, and replay](determinism-saves-replay.md). ## Flags: referee-only session state -Flags solve a smaller version of the same problem: content wiring that isn't a rule the engine enforces, like "pulling the lever in the guard room opens the portcullis in the crypt." A flag is one string key mapped to a `str`, `int`, or `bool` value. The referee command [`SetFlag`][osrlib.crawl.commands.SetFlag] sets one: +Flags solve a smaller version of the same problem: content setup that isn't a rule the engine enforces, like "pulling the lever in the guard room opens the portcullis in the crypt." A flag is one string key mapped to a `str`, `int`, or `bool` value. The referee command [`SetFlag`][osrlib.crawl.commands.SetFlag] sets one: ```{.python .no-run} session.execute(SetFlag(key="crypt.lever_pulled", value=True)) @@ -69,7 +69,7 @@ session.register_listener(Interpreter(session)) From then on it watches every command's events, matches them against the adventure's authored [triggers](gates-triggers-quests.md#wiring-the-dungeon-with-triggers) and its [`QuestSpec`][osrlib.crawl.quests.QuestSpec]s, and reacts the only way a listener may: by executing referee commands, each stamped `source="trigger:{id}"` or `source="quest:{id}"`. Three properties are worth copying into your own listeners: - **It returns no events.** Each event it causes comes from a command it executed, and `execute` folds those into the result from the log. `handle` returns `[], {}` unconditionally. -- **It keeps no state.** Its `listener_state` slot exists, because `register_listener` creates one, and stays the empty dict for the life of the session. Fired-marks live in `session.fired_triggers`, beats in `session.journal`, and everything else in the world state the commands changed. That's what makes a triggered game replay exactly: a replay runs with no listeners at all, and re-executing the log rebuilds all of that state. +- **It keeps no state.** Its `listener_state` entry exists, because `register_listener` creates one, and stays the empty dict for the life of the session. Fired-marks live in `session.fired_triggers`, beats in `session.journal`, and everything else in the world state the commands changed. That's what makes a triggered game replay exactly: a replay runs with no listeners at all, and re-executing the log rebuilds all of that state. - **It has no re-entrancy guard, on purpose.** The fetch quest below needs one because its trigger condition can look unsatisfied from inside its own reaction. The interpreter instead records the fired-mark *before* running a trigger's consequences, so the trigger is already marked when one of its own consequences re-matches it. Re-entrant self-invocation is how one trigger's consequences fire the next, and a depth bound rather than a latch is what stops a cascade. For more information, see [When something doesn't land](gates-triggers-quests.md#when-something-doesnt-land). ## A fetch quest, worked @@ -115,11 +115,11 @@ class FetchQuestListener: A few points about the listener above: - `state["recovered"]` and `state["completed"]` are the quest's own objective tracking, kept entirely inside `session.listener_state["fetch_quest"]`. The session never interprets these keys. It stores whatever dict `handle` returns. -- `self._reacting` is the re-entrancy guard from the previous section. The commands this listener issues emit events of their own, and `AwardXP` on the last member would otherwise re-enter `handle` while the state slot still held its pre-completion value. +- `self._reacting` is the re-entrancy guard from the previous section. The commands this listener issues emit events of their own, and `AwardXP` on the last member would otherwise re-enter `handle` while the state entry still held its pre-completion value. - The `handle` method returns `[], state` unconditionally. Every event this listener causes travels through `self._session.execute(...)`, which already logs it, so there's nothing left for the returned-events list to contain. - Nothing here reaches into party state to *change* it. The flag and the XP both land as ordinary commands, which is why a save, a load, and a replay all agree about what happened. -The interpreter does all of this for you when the quest is adventure data instead: the objective state lives in `session.quests`, the reward commands are stamped `source="quest:{id}"`, and the listener slot stays empty. Use a listener like the one above when your own systems own the objective, and [`QuestSpec`][osrlib.crawl.quests.QuestSpec] when the adventure does. +The interpreter does all of this for you when the quest is adventure data instead: the objective state lives in `session.quests`, the reward commands are stamped `source="quest:{id}"`, and the listener's state entry stays empty. Use a listener like the one above when your own systems own the objective, and [`QuestSpec`][osrlib.crawl.quests.QuestSpec] when the adventure does. ## The complete program diff --git a/docs/guides/rules-without-a-session.md b/docs/guides/rules-without-a-session.md index ca2450a..5d8f848 100644 --- a/docs/guides/rules-without-a-session.md +++ b/docs/guides/rules-without-a-session.md @@ -22,7 +22,7 @@ assert rolled.total == (sum(rolled.rolls) + 1) * 10 `roll` also accepts the plain string directly (it calls `parse` for you), so `roll("3d6", stream)` is just as valid as parsing first. -## Resolving an attack against a spawned monster +## Resolving an attack against a created monster [`create_character`][osrlib.core.character.create_character] and [`spawn_monster`][osrlib.core.monsters.spawn_monster] both work with no session behind them. Past its own name, class id, and alignment, a character needs only a [`Ruleset`][osrlib.core.ruleset.Ruleset] and a stream. A monster needs only its [`MonsterTemplate`][osrlib.core.monsters.MonsterTemplate] from [`load_monsters`][osrlib.data.load_monsters] (see [the monster id index][monsters-index]) plus an id, conventionally minted by an [`IdAllocator`][osrlib.core.monsters.IdAllocator], the same monotonic counter a `GameSession` uses internally. [`resolve_attack`][osrlib.core.combat.resolve_attack] then runs the whole pipeline (the attack roll, the immunity gate, the damage roll and its application) against an [`AttackContext`][osrlib.core.combat.AttackContext] you build yourself. Pass `attack=None` for an unarmed strike: @@ -135,6 +135,6 @@ assert reaction in ReactionResult ## Where next -- [Sessions, commands, and events](sessions-commands-events.md) - how `GameSession` wires these same kernel functions to its own named streams automatically. +- [Sessions, commands, and events](sessions-commands-events.md) - how `GameSession` connects these same kernel functions to its own named streams automatically. - [Determinism, saves, and replay](determinism-saves-replay.md) - the seed and stream guarantees `RngStreams` relies on. - [The API reference](../reference/api/index.md) - every public symbol in `osrlib.core`, module by module. diff --git a/docs/guides/views-and-visibility.md b/docs/guides/views-and-visibility.md index ef42a06..c4bdeba 100644 --- a/docs/guides/views-and-visibility.md +++ b/docs/guides/views-and-visibility.md @@ -8,7 +8,7 @@ Visibility shows up in two places: on individual events, and on the two whole-st Every [`Event`][osrlib.core.events.Event] has a [`Visibility`][osrlib.core.events.Visibility]: `PLAYER` or `REFEREE`. Most events default to `PLAYER`, like a party move, a door opening, or damage dealt. A specific set default to `REFEREE` because B/X keeps them behind the screen: morale checks, reaction rolls, wandering-monster checks, detection rolls, and the event that reports a creature's current and maximum hit points ([`HitPointsReportedEvent`][osrlib.core.events.HitPointsReportedEvent]). If your front end streams or narrates the raw event log as it happens, like an LLM referee doing turn-by-turn narration, check `.visibility` yourself before you show an event to a player, the same way you'd filter a database query. -The authored layer splits the same way. A journal beat is written for the table, so [`JournalEntryAddedEvent`][osrlib.crawl.events.JournalEntryAddedEvent] is player-visible and includes the authored text itself: content data in a structured field, alongside the event's message code, never engine-baked English. The wiring that produced the beat is referee-visibility instead. A fired trigger ([`TriggerFiredEvent`][osrlib.crawl.events.TriggerFiredEvent]) and a referee note ([`NoteRecordedEvent`][osrlib.crawl.events.NoteRecordedEvent]) are referee-only, exactly as a flag write is, because content wiring is the game's secret. Player-visible events and the player view are two of the three routes authored words take to a player. The third is a gate's `refusal` beat, which the engine returns in an ordinary rejection. Render it like any other refusal (see [Gates, triggers, and quests](gates-triggers-quests.md)). +The authored layer splits the same way. A journal beat is written for the table, so [`JournalEntryAddedEvent`][osrlib.crawl.events.JournalEntryAddedEvent] is player-visible and includes the authored text itself: content data in a structured field, alongside the event's message code, never engine-baked English. The setup that produced the beat is referee-visibility instead. A fired trigger ([`TriggerFiredEvent`][osrlib.crawl.events.TriggerFiredEvent]) and a referee note ([`NoteRecordedEvent`][osrlib.crawl.events.NoteRecordedEvent]) are referee-only, exactly as a flag write is, because these connections are the game's secret. Player-visible events and the player view are two of the three routes authored words take to a player. The third is a gate's `refusal` beat, which the engine returns in an ordinary rejection. Render it like any other refusal (see [Gates, triggers, and quests](gates-triggers-quests.md)). Most front ends never do that filtering by hand, though, because osrlib also ships two ready-made projections of the *whole session*, one per audience, each already filtered for the audience it serves. @@ -35,7 +35,7 @@ The quests in play appear as [`QuestView`][osrlib.crawl.views.QuestView] values ## The split in practice -The clearest way to see the split is a spawned monster. The referee view contains the monster's live hit points. The player-facing encounter group contains only what the party could plausibly perceive: how many are still standing, how far away they are, and what conditions show. +The clearest way to see the split is a created monster. The referee view contains the monster's live hit points. The player-facing encounter group contains only what the party could plausibly perceive: how many are still standing, how far away they are, and what conditions show. ```{.python .no-run} # The referee sees the goblin's hit points; the player view never contains them. @@ -50,16 +50,16 @@ assert "current_hp" not in player_group.model_dump() The authored layer shows the same split from the other side. The journal reaches the player view whole, and the trigger behind a beat doesn't reach the player view at all. ```{.python .no-run} -# The beat is for the table; the trigger that produced it is referee-only wiring. +# The beat is for the table; the trigger that produced it is referee-only setup. assert [entry.text for entry in journal_view.journal][-1] == "The lever grinds." assert "lever-east" not in journal_view.model_dump_json() assert referee_after.fired_triggers == ("lever-east",) ``` -Quests draw the same line, one level finer. `PlayerView.quests` contains the **active** quests only, in document order. A quest nobody has been given yet is absent, because an activation clause is wiring like any other, and a finished quest is no longer in `PlayerView.quests`, because its record is the journal. Under each quest, only the **revealed** objectives appear. A hidden objective's id is not in the projection at all until its `reveal_when` clause fires or the objective completes, which is why `ObjectiveView.state` needs only `"incomplete"` and `"complete"`. Nothing else about a quest reaches the player view: no clause, no pattern, no condition, no reward, and no `guidance` from any narrative block or level. +Quests draw the same line, one level finer. `PlayerView.quests` contains the **active** quests only, in document order. A quest nobody has been given yet is absent, because an activation clause is a connection like any other, and a finished quest is no longer in `PlayerView.quests`, because its record is the journal. Under each quest, only the **revealed** objectives appear. A hidden objective's id is not in the projection at all until its `reveal_when` clause fires or the objective completes, which is why `ObjectiveView.state` needs only `"incomplete"` and `"complete"`. Nothing else about a quest reaches the player view: no clause, no pattern, no condition, no reward, and no `guidance` from any narrative block or level. ```{.python .no-run} -# Active quests only, revealed objectives only, and none of the wiring behind them. +# Active quests only, revealed objectives only, and none of the setup behind them. quest_view = player_view.quests[0] assert (quest_view.id, quest_view.speaker) == ("the-lamps", "Sister Halda") assert [entry.id for entry in quest_view.objectives] == ["find-the-lever"] @@ -141,7 +141,7 @@ session.register_listener(Interpreter(session)) session.execute(EnterDungeon(dungeon_id="crypt")) -# The referee spawns a lone goblin and opens an encounter at 30 feet. +# The referee creates a lone goblin and opens an encounter at 30 feet. result = session.execute(SpawnMonsters(template_id="goblin", count_fixed=1, distance_feet=30)) assert result.accepted assert session.mode is SessionMode.ENCOUNTER @@ -165,7 +165,7 @@ session.execute(RecordNote(text="The east lever is the only one that answers.")) journal_view = session.view(Visibility.PLAYER) referee_after = session.view(Visibility.REFEREE) -# The beat is for the table; the trigger that produced it is referee-only wiring. +# The beat is for the table; the trigger that produced it is referee-only setup. assert [entry.text for entry in journal_view.journal][-1] == "The lever grinds." assert "lever-east" not in journal_view.model_dump_json() assert referee_after.fired_triggers == ("lever-east",) @@ -177,7 +177,7 @@ assert quest_view.narrative == "Light the crypt's lamps before the moon sets." assert quest_view.speaker == "Sister Halda" assert journal_view.journal[0].text == quest_view.narrative -# Only the revealed objective is projected, and none of the wiring behind it. +# Only the revealed objective is projected, and none of the setup behind it. assert [(entry.id, entry.state) for entry in quest_view.objectives] == [("find-the-lever", "incomplete")] blob = journal_view.model_dump_json() assert "name-the-dead" not in blob # a hidden objective has no view at all diff --git a/docs/index.md b/docs/index.md index 4e758b5..9d8a673 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,7 +14,7 @@ osrlib is designed for four kinds of consumer: ## Where to start - The [quickstart](getting-started/quickstart.md) runs the whole loop in one sitting: characters, party, adventure, session, commands, events, save, and load. -- [Building an adventure](getting-started/building-an-adventure.md) teaches the dungeon itself: the grid and its edges, keyed areas, and the content each area binds. +- [Building an adventure](getting-started/building-an-adventure.md) teaches the dungeon itself: the grid and its edges, keyed areas, and the content each area attaches. - [Gates, triggers, and quests](guides/gates-triggers-quests.md) adds the authored behavior: a door that opens only with the right key, a lever that opens a portcullis, and a quest that ends the adventure in victory. - The [guides](guides/sessions-commands-events.md) teach the contracts: sessions and the command/event loop, visibility, determinism, the rules without a session, listeners, authoring, and ruleset options. - The [front end walk-throughs](front-ends/tui-crawler.md) tour the two example games that ship in the repository, and the [LLM referee page](front-ends/llm-referees.md) maps the same surface onto an agent.