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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 31 additions & 31 deletions docs/front-ends/fastapi-pattern.md

Large diffs are not rendered by default.

54 changes: 27 additions & 27 deletions docs/front-ends/tui-crawler.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions docs/getting-started/building-an-adventure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. Left unpinned, surprise and reactions roll normally when the party walks in.
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. Left unpinned, the engine rolls surprise and reactions normally when the party walks in.

Beyond encounters, an area (or the level itself) can contain:

Expand Down Expand Up @@ -85,7 +85,7 @@ validate_adventure(adventure, load_monsters(), load_equipment())

## The complete program

Entering the dungeon and walking east brings the party to the door at the corridor's end. The guard post is beyond the door. Stepping in spawns the goblins, surprise and reaction roll, and the session switches to the encounter:
Entering the dungeon and walking east brings the party to the door at the corridor's end. The guard post is beyond the door. Stepping in spawns the goblins, the engine rolls surprise and reaction, and the session switches to the encounter:

```python
from osrlib.core.alignment import Alignment
Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ assert save_game(restored) == document
## Where next

- [Building an adventure](building-an-adventure.md) - the dungeon itself: the grid and its edges, keyed areas, and the content that binds to those areas.
- [Gates, triggers, and quests](../guides/gates-triggers-quests.md) - the authored layer: a door that needs a key, a lever that opens a portcullis, an errand that ends the adventure.
- [Gates, triggers, and quests](../guides/gates-triggers-quests.md) - the authored layer: a door that opens only with the right key, a lever that opens a portcullis, an errand that ends the adventure.
- [Sessions, commands, and events](../guides/sessions-commands-events.md) - the command loop in depth: modes, rejections, the event log.
- [Determinism, saves, and replay](../guides/determinism-saves-replay.md) - what the seed guarantees and how saves and replay reach the same state.
- [The TUI crawler](../front-ends/tui-crawler.md) - a complete example game built on everything above.
4 changes: 2 additions & 2 deletions docs/guides/authoring-custom-content.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ A class with a `divine_magic` or `arcane_magic` tag is a caster. `caster_profile
},
```

`level_titles[i]` is the title at level `i + 1`, and the tuple may run shorter than `progression` because the SRD's title lists stop at name level. `progression` is one [`ProgressionRow`][osrlib.core.classes.ProgressionRow] per level, and it's the *only* place saves, THAC0, attack bonus, and spell slots live. [`ClassDefinition.row`][osrlib.core.classes.ClassDefinition.row] looks a level up fresh every time, so leveling and energy drain move which row a character reads rather than updating a stored value. `hit_dice` on a row is a [`HitDice`][osrlib.core.classes.HitDice]: `count`, `die`, a flat `bonus` for above-name-level rows, and `con_applies` for the SRD's asterisked "CON no longer applies" rows. `saves` is a [`SavingThrows`][osrlib.core.classes.SavingThrows] naming the five save categories. `spell_slots[i]` is how many level-`i + 1` spells the row's caster can memorize, and it's empty for non-casters.
`level_titles[i]` is the title at level `i + 1`, and the tuple may run shorter than `progression` because the SRD's title lists stop at name level. `progression` is one [`ProgressionRow`][osrlib.core.classes.ProgressionRow] per level, and it's the *only* place saves, THAC0, attack bonus, and spell slots live. [`ClassDefinition.row`][osrlib.core.classes.ClassDefinition.row] looks a level up fresh every time, so leveling and energy drain change which row applies to a character rather than updating a stored value. `hit_dice` on a row is a [`HitDice`][osrlib.core.classes.HitDice]: `count`, `die`, a flat `bonus` for above-name-level rows, and `con_applies` for the SRD's asterisked "CON no longer applies" rows. `saves` is a [`SavingThrows`][osrlib.core.classes.SavingThrows] naming the five save categories. `spell_slots[i]` is how many level-`i + 1` spells the row's caster can memorize, and it's empty for non-casters.

## The shape of a spell

Expand Down Expand Up @@ -547,7 +547,7 @@ Third, some class policies are written as id lists. A class whose [`WeaponPolicy

## What's not supported

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)) unions per session through the adventure document that contains the templates, and the shipped catalog objects never change.
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.

Expand Down
2 changes: 1 addition & 1 deletion docs/guides/determinism-saves-replay.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ The version helpers live in [`osrlib.versioning`][osrlib.versioning]. Every seri

A schema version is additive-only: within one version, only new event types and new optional fields can appear. Anything else, like a rename, a removal, or a change in what a field means, bumps `SCHEMA_VERSION`, and a bump comes with a migration. [`load_game`][osrlib.persistence.load_game] runs a document's payload through the ordered chain in [`MIGRATIONS`][osrlib.persistence.MIGRATIONS] before it rebuilds anything, so a document stamped at an older schema version still loads.

Three migrations have shipped. The step from version 1 to version 2 drops a `recovered_treasure` field that a version-2 payload no longer includes, and adds the empty `npcs` list that arrived with version 2. A payload contains the NPC roster as a list, and `load_game` rebuilds it into the session's `npcs` dict keyed by id, which is what the assertion below reads back. The step from version 2 to version 3 is a lossless rewrite: version 3 rejects `trigger="enter"` on a treasure trap, a value the cache path never read, so the migration rewrites it to `"open"`, the one springing action a cache has. The step from version 3 to version 4 is another: version 4 drops `"withdraw"` from a battle declaration's `move`, a value the round resolver never moved the party for, so the migration clears it off a logged declaration. A declaration whose action was `move` becomes `action="hold"` with no move, which is what that round played as, and a declaration that carried the value beside some other action keeps that action and loses a field nothing read. A document saved at the floor, schema version 1, runs the whole chain and loads the same way a fresh one does:
Three migrations have shipped. The step from version 1 to version 2 drops a `recovered_treasure` field that a version-2 payload no longer includes, and adds the empty `npcs` list that arrived with version 2. A payload contains the NPC roster as a list, and `load_game` rebuilds it into the session's `npcs` dict keyed by id, which is what the assertion below reads back. The step from version 2 to version 3 is a lossless rewrite: version 3 rejects `trigger="enter"` on a treasure trap, a value the cache path never read, so the migration rewrites it to `"open"`, the one springing action a cache has. The step from version 3 to version 4 is another: version 4 drops `"withdraw"` from a battle declaration's `move`, a value the round resolver never moved the party for, so the migration clears it off a logged declaration. A declaration whose action was `move` becomes `action="hold"` with no move, which is what that round played as, and a declaration that had the value beside some other action keeps that action and loses a field nothing read. A document saved at the floor, schema version 1, runs the whole chain and loads the same way a fresh one does:

```{.python .no-run}
# A version-1 document -- no "npcs" key, and the ledger field version 2 dropped --
Expand Down
12 changes: 6 additions & 6 deletions docs/guides/gates-triggers-quests.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Gates, triggers, and quests

You want a door that needs a 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 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.

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.

Expand All @@ -24,7 +24,7 @@ sentinel = GateSpec(
)
```

Locks and gates are separate layers, and a door with both requires both. The engine checks the lock first (`exploration.door.locked`), and once a thief has picked the lock ([`PickLock`][osrlib.crawl.commands.PickLock] addresses the lock and nothing else), the gate still applies. A door standing open lets the party through unchecked. Set a gated door open with [`SetDoorState`][osrlib.crawl.commands.SetDoorState] and the party passes freely until the door closes again, at which point the gate applies once more. For a one-time unlock that flips a door's state for good, like the lever thrown once that leaves the portcullis up, use a [trigger](#wiring-the-dungeon-with-triggers): a `SetDoorState` consequence fired on the lever's flag.
Locks and gates are separate layers: on a door with both, the party must open the lock and satisfy the gate. The engine checks the lock first (`exploration.door.locked`), and once a thief has picked the lock ([`PickLock`][osrlib.crawl.commands.PickLock] addresses the lock and nothing else), the gate still applies. A door standing open lets the party through unchecked. Set a gated door open with [`SetDoorState`][osrlib.crawl.commands.SetDoorState] and the party passes freely until the door closes again, at which point the gate applies once more. For a one-time unlock that flips a door's state for good, like the lever thrown once that leaves the portcullis up, use a [trigger](#wiring-the-dungeon-with-triggers): a `SetDoorState` consequence fired on the lever's flag.

`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.

Expand Down Expand Up @@ -84,9 +84,9 @@ The two beats have two different audiences: **`fired` is the referee's line and

### When something doesn't land

A trigger firing is not all-or-nothing. When the session rejects one consequence, like a spawn that arrives to find an encounter already open or a grant naming an item the catalog lost, 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 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. A firing the bound suppresses is recorded as 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.
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.

## Authoring a quest

Expand Down Expand Up @@ -114,7 +114,7 @@ Drop the idol into a cache by id (`item_ids=("jade-idol",)`) and add its templat

### Activation, and the quest that needs none

`activation` is a clause like any other. When it matches, the quest becomes active, its `offer` beat displays and lands in the journal, and the interpreter starts matching its objectives. Omit it and the quest is active from session start, a standing charge from round 0. A quest with no `activation` has no activation event and no offer entry in the journal, because there's no command channel before the first command. Its offer stands in the first player view instead.
`activation` is a clause like any other. When it matches, the quest becomes active, its `offer` beat goes on the activation event and into the journal, and the interpreter starts matching its objectives. Omit it and the quest is active from session start, a standing charge from round 0. A quest with no `activation` has no activation event and no offer entry in the journal, because there's no command channel before the first command. Its offer stands in the first player view instead.

### Hidden objectives and reveals

Expand All @@ -130,7 +130,7 @@ Drop the idol into a cache by id (`item_ids=("jade-idol",)`) and add its templat

`rewards` use the same [`ConsequenceCommand`][osrlib.crawl.commands.ConsequenceCommand] surface a trigger's consequences do. The interpreter issues them in authored order *after* the quest completes, each stamped `source="quest:{id}"`. They address characters through the same selectors, `@party` and `@first`, and validation rejects a literal character id for the same reason it does on a trigger.

Two consequences of that ordering are worth authoring around. On a concluding quest the session is already in `victory` when the rewards issue, so it refuses a reward that would resume play (`SpawnMonsters`, `SpawnNpcParty`, `PlaceParty`) and drops it with a note. Grants, awards, and flags land fine. Coin paid on the doorstep earns no treasure XP, because the end-of-adventure award has already fired by then. Put the story's thanks in `AwardXP` rather than in a purse of coin.
Two consequences of that ordering are worth authoring around. On a concluding quest the session is already in `victory` when the rewards issue, so it refuses a reward that would resume play (`SpawnMonsters`, `SpawnNpcParty`, `PlaceParty`) and drops it with a note. Grants, awards, and flags land fine. Coin paid on the doorstep never converts to treasure XP, because the end-of-adventure award has already fired by then. Put the story's thanks in `AwardXP` rather than in a purse of coin.

### Which beat goes where

Expand Down
Loading
Loading