diff --git a/docs/front-ends/fastapi-pattern.md b/docs/front-ends/fastapi-pattern.md index 942372d..a9cbca2 100644 --- a/docs/front-ends/fastapi-pattern.md +++ b/docs/front-ends/fastapi-pattern.md @@ -1,22 +1,22 @@ # The FastAPI pattern -The library's second example front end puts the [TUI crawler's](tui-crawler.md) barrow adventure behind an HTTP API — the same authored content behind a terminal and a web server, which is the point: osrlib doesn't care what's on the other side of the [`GameSession`][osrlib.crawl.session.GameSession]. This page teaches the server patterns the example exists to demonstrate: the per-session lock, the interpreter registered on both session paths, player visibility enforced at the wire, saves that never leave the server, and the mapping from osrlib's typed exceptions to HTTP statuses — this last one makes the page the home of [`osrlib.errors`][osrlib.errors]. Run instructions live in [the example's README on GitHub](https://github.com/mmacy/osrlib-python/tree/main/examples/fastapi_crawler). +The library's second example front end puts the [TUI crawler's](tui-crawler.md) barrow adventure behind an HTTP API: the same authored content behind a terminal and a web server. Nothing in osrlib turns on which of the two sits on the other side of the [`GameSession`][osrlib.crawl.session.GameSession]. The server patterns the example demonstrates are the per-session lock, the interpreter registered on both session paths, player visibility enforced at the wire, saves that never leave the server, and the mapping from osrlib's typed exceptions to HTTP statuses. [The exception hierarchy and the status map](#the-exception-hierarchy-and-the-status-map) below covers [`osrlib.errors`][osrlib.errors] in full. Run instructions live in [the example's README on GitHub](https://github.com/mmacy/osrlib-python/tree/main/examples/fastapi_crawler). -The example is small — five endpoints in `examples/fastapi_crawler/app.py` — and every server fragment below is excerpted directly from that file, so the page cannot drift from the code it teaches. Server fragments don't run standalone; the page's one self-contained runnable block is [the exception demonstration](#the-exception-hierarchy-and-the-status-map). +The example is small, five endpoints in `examples/fastapi_crawler/app.py`, and every server fragment below comes straight out of that file, so the page cannot drift from the code it teaches. Server fragments don't run standalone. The one self-contained runnable block is [the exception demonstration](#the-exception-hierarchy-and-the-status-map). ## One session, one lock -A [`GameSession`][osrlib.crawl.session.GameSession] executes one command at a time and is not safe to share across threads — while FastAPI runs plain `def` endpoints in a threadpool, so any two requests may execute concurrently. The store resolves that tension by pairing every session with its own lock: +A [`GameSession`][osrlib.crawl.session.GameSession] executes one command at a time and is not safe to share across threads, while FastAPI runs plain `def` endpoints in a threadpool, so any two requests may execute concurrently. The store makes that safe by pairing every session with its own lock: ```{.python .no-run} --8<-- "examples/fastapi_crawler/app.py:session-store" ``` -The session's lock is held across every `execute` and every view read, so one session's commands serialize while separate sessions proceed in parallel; the outer `_store_lock` only guards the dictionaries themselves. The endpoints are deliberately synchronous: the engine is synchronous and CPU-bound, so an async facade would add nothing — the threadpool provides the concurrency, and the lock provides the safety. +The session's lock is held across every `execute` and every view read, so one session's commands serialize while separate sessions proceed in parallel. The outer `_store_lock` only guards the dictionaries themselves. The endpoints are deliberately synchronous: the engine is synchronous and CPU-bound, so an async facade would add nothing. The threadpool provides the concurrency, and the lock provides the safety. ## Creating and restoring sessions -A session begins with a stamped party document — the JSON envelope [`party_to_document`][osrlib.core.character.party_to_document] produces and [`party_from_document`][osrlib.core.character.party_from_document] validates — or with a save id from an earlier server-side snapshot. Exactly one of the two, which the request model enforces before the handler ever runs: +A session begins with a stamped party document (the JSON envelope [`party_to_document`][osrlib.core.character.party_to_document] produces and [`party_from_document`][osrlib.core.character.party_from_document] validates) or with a save id from an earlier server-side snapshot. Exactly one of the two, which the request model enforces before the handler ever runs: ```{.python .no-run} --8<-- "examples/fastapi_crawler/app.py:create-session-model" @@ -28,57 +28,57 @@ The handler then branches on which field arrived: --8<-- "examples/fastapi_crawler/app.py:create-session" ``` -Two details carry the trust story: +Two details of that handler matter: -- **The master seed is a server secret.** By default the server draws it (`secrets.randbits(63)`) and no response ever contains it — a client that knows the seed can predict every roll the dungeon will ever make. The optional `seed` field exists for reproducible demos and tests; even when the client supplies it, it never comes back. +- **The master seed is a server secret.** By default the server draws it (`secrets.randbits(63)`) and no response ever contains it, because a client with the seed can predict every roll the engine will make. The optional `seed` field exists for reproducible demos and tests, and even when the client supplies it, the server never sends it back. - **The response is the schema handshake.** `schema_version` and `engine_version` come from [`osrlib.versioning`][osrlib.versioning], so a client can detect a server whose wire schema is ahead of its own before sending anything else. [Determinism, saves, and replay](../guides/determinism-saves-replay.md) covers what each version stamp guarantees. ## The served content and its interpreter -The barrow is authored content — gated doors, a fetch quest, the works — and content plays only when 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. 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 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: ```{.python .no-run} --8<-- "examples/fastapi_crawler/content.py:new-session" ``` -And a restored one registers it again, because a save carries data and a listener is code — the save has the quest's state, the fired-marks, and the journal, but nothing in it can *react* until the code is re-attached: +And a restored one registers it again, because a save contains data and a listener is code: the save has the quest's state, the fired-marks, and the journal, but nothing in it can *react* until the code is re-attached: ```{.python .no-run} --8<-- "examples/fastapi_crawler/content.py:restore-session" ``` -That pair is the page's own lesson — listeners are code, saves are data — made concrete: forget the second registration and a restored barrow still validates, still loads, and silently stops playing its triggers and quests. +Listeners are code and saves are data: forget the second registration and a restored barrow still validates, still loads, and silently stops playing its triggers and quests. ## The command endpoint -One endpoint accepts every command in the engine's registry, each a typed model with its own JSON Schema (see [the command schema reference](../reference/commands/index.md)). [`parse_command`][osrlib.crawl.commands.parse_command] turns the wire payload into a typed command, returning `None` for a `command_type` it has never heard of: +One endpoint accepts every command in the engine's registry, each a typed model with its own JSON Schema (see [the command schema reference](../reference/commands/index.md)). [`parse_command`][osrlib.crawl.commands.parse_command] turns the wire payload into a typed command, returning `None` for a `command_type` that isn't in the registry: ```{.python .no-run} --8<-- "examples/fastapi_crawler/app.py:execute-command" ``` -Three distinct fates for a request, in order: +Three outcomes for a request, in order: -- An **unknown `command_type`** is a 422 before the engine is ever consulted: schemas grow additively, so a newer client may know commands this server doesn't, and the honest answer is "I don't understand", not a guess. +- An **unknown `command_type`** is a 422 before the engine is ever consulted: schemas grow additively, so a newer client may send commands this server doesn't have, and the server answers that it doesn't understand rather than guessing. - A **known command with a malformed payload** (a direction that doesn't exist, a negative quantity) raises [`ContentValidationError`][osrlib.errors.ContentValidationError] inside [`parse_command`][osrlib.crawl.commands.parse_command], which the exception handler below maps to 422. -- A **well-formed command** executes under the session lock and returns the [`CommandResult`][osrlib.crawl.commands.CommandResult] envelope: `accepted`, the rejections, and the events — filtered to [`Visibility.PLAYER`][osrlib.core.events.Visibility] on their way out. Referee-visibility events (hidden rolls, referee bookkeeping) never cross the wire. +- A **well-formed command** executes under the session lock and returns the [`CommandResult`][osrlib.crawl.commands.CommandResult] envelope: `accepted`, the rejections, and the events, filtered to [`Visibility.PLAYER`][osrlib.core.events.Visibility] on their way out. Referee-visibility events (hidden rolls, referee bookkeeping) never cross the wire. ## Rejections are results, not errors -The split that decides every status code on this page: **an in-fiction rejection is a 200.** When the party tries to walk through a wall, the game said no — that's a rules outcome the client should render, not a transport failure. The response arrives with `accepted: false` and a machine-readable rejection code (see [the rejection code reference](../reference/rejection-codes.md)), and it costs the client nothing: a rejected command draws no dice, advances no clock, and appends nothing to the log, so a confused client can probe freely without corrupting the game. [Sessions, commands, and events](../guides/sessions-commands-events.md) teaches the rejection contract in depth. +One split decides every status code in this API: **an in-fiction rejection is a 200.** When the party tries to walk through a wall, the game says no. That's a rules outcome the client should render, not a transport failure. The response arrives with `accepted: false` and a machine-readable rejection code (see [the rejection code reference](../reference/rejection-codes.md)), and it costs the client nothing: a rejected command draws no dice, advances no clock, and appends nothing to the log, so a confused client can probe freely without corrupting the game. [Sessions, commands, and events](../guides/sessions-commands-events.md) teaches the rejection contract in depth. -Exceptions are the opposite case: the caller broke the *out-of-fiction* contract — sent a malformed document, replayed an incompatible save — and those map to 4xx/5xx statuses. +Exceptions are the opposite case. The caller broke the *out-of-fiction* contract by sending a malformed document or replaying an incompatible save, and those map to 4xx/5xx statuses. ## The exception hierarchy and the status map [`osrlib.errors`][osrlib.errors] is a deliberately small, typed hierarchy reserved for out-of-fiction failures: -- [`OsrlibError`][osrlib.errors.OsrlibError] — the root. Catching it catches everything osrlib raises on its own authority. -- [`ContentValidationError`][osrlib.errors.ContentValidationError] — malformed rules content at a library boundary: a dice expression that doesn't parse, an adventure that fails [`validate_adventure`][osrlib.crawl.adventure.validate_adventure], a serialized document whose structure or kind isn't what the loader expects. -- [`SaveVersionError`][osrlib.errors.SaveVersionError] — a document whose `schema_version` is newer than this library understands, raised by [`check_document`][osrlib.versioning.check_document] rather than silently misreading the payload. -- [`ReplayVersionError`][osrlib.errors.ReplayVersionError] — a command log replayed under a different engine version, where any rules change may legitimately alter outcomes. The example never raises it (it exposes no replay endpoint), but a front end that replays command logs owns the same mapping decision. +- [`OsrlibError`][osrlib.errors.OsrlibError] - the root. Catching it catches everything osrlib raises on its own authority. +- [`ContentValidationError`][osrlib.errors.ContentValidationError] - malformed rules content at a library boundary: a dice expression that doesn't parse, an adventure that fails [`validate_adventure`][osrlib.crawl.adventure.validate_adventure], a serialized document whose structure or kind isn't what the loader expects. +- [`SaveVersionError`][osrlib.errors.SaveVersionError] - a document whose `schema_version` is newer than this library understands, raised by [`check_document`][osrlib.versioning.check_document] rather than silently misreading the payload. +- [`ReplayVersionError`][osrlib.errors.ReplayVersionError] - a command log replayed under a different engine version, where any rules change may legitimately alter outcomes. The example never raises it (it exposes no replay endpoint), but a front end that replays command logs faces the same mapping decision. -Two failure families are deliberately *outside* the hierarchy: programmer misuse (bad argument types, out-of-range values) raises stdlib `ValueError` or `TypeError` — a bug in the calling code, not a condition to map — and in-fiction refusals, as above, aren't exceptions at all. +Two failure families are deliberately *outside* the hierarchy. Programmer misuse (bad argument types, out-of-range values) raises stdlib `ValueError` or `TypeError`, a bug in the calling code rather than a condition to map. In-fiction refusals, as above, aren't exceptions at all. The example registers one handler per exception type it expects, plus a 404 helper for ids that miss the store: @@ -97,7 +97,7 @@ Everything the wire can answer, in one table: | Unknown session or save id | the store lookup misses | 404 | | Anything else | a bug, by definition | 500 | -The hierarchy is easy to exercise without a server — this block runs as written: +You can exercise the hierarchy without a server. This block runs as written: ```python from osrlib.errors import ContentValidationError, OsrlibError, ReplayVersionError, SaveVersionError @@ -127,29 +127,29 @@ except SaveVersionError: ## The player view at the wire -The only game-state read the API offers is the player projection — [`session.view(Visibility.PLAYER)`][osrlib.crawl.session.GameSession.view], serialized verbatim: +The only game-state read the API offers is the player projection, [`session.view(Visibility.PLAYER)`][osrlib.crawl.session.GameSession.view], serialized verbatim: ```{.python .no-run} --8<-- "examples/fastapi_crawler/app.py:player-view" ``` -There is no referee-view endpoint at all, and that absence is the pattern: never trust the client. The [`PlayerView`][osrlib.crawl.views.PlayerView] is an enumerated whitelist — explored cells, public character sheets, masked magic items, monster groups without hit points, the journal as written, and the active quests with their revealed objectives — so unexplored geometry, undiscovered secret doors, monster internals, session flags, and the seed can't leak, because they were never in the projection to begin with. A client that renders only what this endpoint returns literally cannot cheat. [Views and visibility](../guides/views-and-visibility.md) walks the whitelist field by field. +There is no referee-view endpoint at all, and that absence is the pattern: never trust the client. The [`PlayerView`][osrlib.crawl.views.PlayerView] is an enumerated whitelist of explored cells, public character sheets, masked magic items, monster groups without hit points, the journal as written, and the active quests with their revealed objectives. Unexplored geometry, undiscovered secret doors, monster internals, session flags, and the seed can't leak, because they were never in the projection to begin with. A client that renders only what this endpoint returns cannot cheat. [Views and visibility](../guides/views-and-visibility.md) walks the whitelist field by field. -The authored layer reaches a web client through two more channels the command endpoint already serves. The player-visible quest and journal events — a quest activated, an objective completed, a beat added — cross in the response's `events` like any other, so an incremental client can render story progress without re-fetching the view. And a gate's refusal crosses in `rejections[].params.refusal`: authored words the player is meant to read, riding an ordinary `accepted: false` response, so a web client's rejection renderer should print that field when it is present (see [Gates, triggers, and quests](../guides/gates-triggers-quests.md)). +The authored layer reaches a web client through two more channels the command endpoint already serves. The player-visible quest and journal events (a quest activated, an objective completed, a beat added) cross in the response's `events` like any other, so an incremental client can render story progress without re-fetching the view. And a gate's refusal crosses in `rejections[].params.refusal`: authored words the player is meant to read, in an ordinary `accepted: false` response, so a web client's rejection renderer should print that field when it is present (see [Gates, triggers, and quests](../guides/gates-triggers-quests.md)). ## Saves stay on the server -A save document contains everything the wire withholds — the master seed, referee state, the full logs — so the example never sends one anywhere. Snapshots go into a server-side store, and only an opaque id crosses the wire: +A save document contains everything the wire withholds: the master seed, referee state, and the full logs. The example never sends one anywhere. Snapshots go into a server-side store, and only an opaque id crosses the wire: ```{.python .no-run} --8<-- "examples/fastapi_crawler/app.py:save-session" ``` -Restoring is the `save_id` path through `POST /sessions` [above](#creating-and-restoring-sessions): the server calls [`load_game`][osrlib.persistence.load_game], re-registers the [`Interpreter`][osrlib.crawl.interpreter.Interpreter] — the one listener this server runs, shown in [the served content section](#the-served-content-and-its-interpreter) — and hands back a fresh session id. The in-memory store is a deliberate simplification — swapping in a database changes nothing about the pattern. +Restoring is the `save_id` path through `POST /sessions` [above](#creating-and-restoring-sessions): the server calls [`load_game`][osrlib.persistence.load_game], re-registers the [`Interpreter`][osrlib.crawl.interpreter.Interpreter] (the one listener this server runs, shown in [the served content section](#the-served-content-and-its-interpreter)), and hands back a fresh session id. The in-memory store is a deliberate simplification, and swapping in a database changes nothing about the pattern. ## Where next -- [Sessions, commands, and events](../guides/sessions-commands-events.md) — the command loop this API wraps: modes, rejections, the event log. -- [Views and visibility](../guides/views-and-visibility.md) — exactly what the player projection contains and why. -- [The command schema reference](../reference/commands/index.md) — every command this endpoint accepts, with its JSON Schema and legal modes. -- [LLM referees](llm-referees.md) — the consumer on the other side of the visibility doctrine: an agent that's *supposed* to see everything. +- [Sessions, commands, and events](../guides/sessions-commands-events.md) - the command loop this API wraps: modes, rejections, the event log. +- [Views and visibility](../guides/views-and-visibility.md) - exactly what the player projection contains and why. +- [The command schema reference](../reference/commands/index.md) - every command this endpoint accepts, with its JSON Schema and legal modes. +- [LLM referees](llm-referees.md) - the consumer on the other side of the visibility doctrine: an agent that's *supposed* to see everything. diff --git a/docs/front-ends/tui-crawler.md b/docs/front-ends/tui-crawler.md index 4df1c19..fa9e1ab 100644 --- a/docs/front-ends/tui-crawler.md +++ b/docs/front-ends/tui-crawler.md @@ -1,24 +1,24 @@ # The TUI crawler -The barrow crawler is a complete, playable game built on osrlib and nothing else — no curses, no Textual, no web framework, just `input()`, `print()`, and the standard library. It exists to make one claim concrete: everything a session needs to run — rules, dice, state, the event log — lives in the library, everything a front end supplies — rendering, input handling — is ordinary application code written against the public surface, and the game's content, its fetch quest included, is authored adventure data the library's own interpreter plays. The same [`GameSession`][osrlib.crawl.session.GameSession] this example drives could sit behind a web API or a graphical client instead; nothing about it assumes a terminal. +The barrow crawler is a complete, playable game built on osrlib alone: no curses, no Textual, no web framework, only `input()`, `print()`, and the standard library. Everything a session needs to run (rules, dice, state, the event log) lives in the library. Everything a front end supplies (rendering and input handling) is ordinary application code written against the public surface. The game's content, its fetch quest included, is authored adventure data that the library's own interpreter plays. The same [`GameSession`][osrlib.crawl.session.GameSession] this example drives could sit behind a web API or a graphical client instead, and nothing about it assumes a terminal. -This page walks that split section by section, excerpting the crawler's real source. For the commands the game understands and how to run it yourself, see the example's own [README on GitHub](https://github.com/mmacy/osrlib-python/tree/main/examples/tui_crawler) — one command starts an interactive game: `uv run python -m examples.tui_crawler`. +Every code snippet below comes from the crawler's own source. For the commands the game understands and how to run it yourself, see the example's [README on GitHub](https://github.com/mmacy/osrlib-python/tree/main/examples/tui_crawler). One command starts an interactive game: `uv run python -m examples.tui_crawler`. ## Reading commands, rendering events -The crawler's loop is a `dispatch` function that turns one line of typed text into a command, and a `run` helper that executes it and prints whatever comes back. Parsing is entirely the game's problem — the library has no idea `"move e"` is a sentence: +The crawler's loop is a `dispatch` function that turns one line of typed text into a command, and a `run` helper that executes it and prints whatever comes back. Parsing is entirely the game's problem: the library takes typed commands, not sentences: ```{.python .no-run} --8<-- "examples/tui_crawler/__main__.py:parse-command" ``` -`_DIRECTIONS` maps single letters to the compass words [`MoveParty`][osrlib.crawl.commands.MoveParty] expects. Once a command exists, running it is the same three steps as everywhere else in osrlib — execute, check acceptance, format the events. The loop is a plain iteration over `result.events` because the envelope already carries everything: whatever a nested listener-issued command logged — the interpreter's reactions above all — folds into the result, in log order, so a front end never needs to read `session.event_log` to see the whole chain. A rejection prints its code, plus the authored refusal text when a gate wrote one — the one rejection family carrying words the player is meant to read: +`_DIRECTIONS` maps single letters to the compass words [`MoveParty`][osrlib.crawl.commands.MoveParty] takes. Once a command exists, running it is the same three steps as everywhere else in osrlib: execute, check acceptance, format the events. The loop is a plain iteration over `result.events` because the result envelope already contains everything. Whatever a nested listener-issued command logged (the interpreter's reactions above all) folds into the result in log order, so a front end never needs to read `session.event_log` to see the whole chain. A rejection prints its code, plus the authored refusal text when a gate has one: the one rejection family that includes words the player is meant to read: ```{.python .no-run} --8<-- "examples/tui_crawler/__main__.py:render-events" ``` -Every event carries a [`Visibility`][osrlib.core.events.Visibility]; filtering on `Visibility.PLAYER` here is what keeps referee-only bookkeeping out of the player's terminal. Running the milestone transcript (`--seed 21 --script examples/tui_crawler/scripts/milestone.txt`) opens like this: +Every event has a [`Visibility`][osrlib.core.events.Visibility]. Filtering on `Visibility.PLAYER` here is what keeps referee-only bookkeeping out of the player's terminal. Running the milestone transcript (`--seed 21 --script examples/tui_crawler/scripts/milestone.txt`) opens like this: ```text > enter @@ -33,39 +33,39 @@ Every event carries a [`Visibility`][osrlib.core.events.Visibility]; filtering o The monsters' bearing: uncertain. ``` -The second line is already the result envelope earning its keep: crossing the threshold activated the adventure's quest, and what printed it was a command the interpreter issued *inside* the player's `enter` — folded into the same result the `enter` came back with. +The second line is what the result envelope is for. Crossing the threshold activated the adventure's quest, and the line came from a command the interpreter issued *inside* the player's `enter`, folded into the same result the `enter` came back with. -Every printed line is [`format_message`][osrlib.messages.format_message] rendering a typed event — a different front end could format the same events into JSON, a chat message, or nothing at all (see [the message code reference](../reference/message-codes.md)). +Every printed line is [`format_message`][osrlib.messages.format_message] rendering a typed event. A different front end could format the same events into JSON, a chat message, or nothing at all (see [the message code reference](../reference/message-codes.md)). ## The player's view -The event-level `Visibility` check above hides individual referee-only lines. The crawler's `status` and `journal` commands take a coarser approach: they ask the session for a whole snapshot built for players, rather than reaching into referee-only state themselves: +The event-level `Visibility` check above hides individual referee-only lines. The crawler's `status` and `journal` commands take a coarser approach: rather than reaching into referee-only state itself, the crawler asks the session for a whole snapshot built for players: ```{.python .no-run} --8<-- "examples/tui_crawler/__main__.py:player-view" ``` -[`GameSession.view`][osrlib.crawl.session.GameSession.view] returns a frozen `PlayerView` when called with `Visibility.PLAYER` — hit points, gold, and carried valuables, and nothing a referee-only view would add. `_status` also walks `PlayerView.quests`: the **active** quests only, each with its revealed objectives by display name and state, which is why the closing status after victory lists no quest at all — a finished quest leaves the projection, and its record is the journal. `_journal` renders `PlayerView.journal`, the authored record in order of discovery, each beat stamped with the clock round it landed at. Both verbs are pure view reads: they execute no command, draw nothing, and log nothing, so a script may sprinkle them anywhere without changing the game. The crawler never touches `session.party` or `session.monsters` directly to render status; it renders the same view any other front end would get by asking for one. [Views and visibility](../guides/views-and-visibility.md) covers what a `PlayerView` includes and how it differs from the referee's. +[`GameSession.view`][osrlib.crawl.session.GameSession.view] returns a frozen `PlayerView` when called with `Visibility.PLAYER`: hit points, gold, and carried valuables, and nothing a referee-only view would add. `_status` also walks `PlayerView.quests`: the **active** quests only, each with its revealed objectives by display name and state, which is why the closing status after victory lists no quest at all. A finished quest is not in the projection, and its record is the journal. `_journal` renders `PlayerView.journal`, the authored record in order of discovery, each beat stamped with the clock round it landed at. Both verbs are pure view reads: they execute no command, draw nothing, and log nothing, so a script may sprinkle them anywhere without changing the game. The crawler never touches `session.party` or `session.monsters` directly to render status. It renders the same view any other front end would get by asking for one. [Views and visibility](../guides/views-and-visibility.md) covers what a `PlayerView` includes and how it differs from the referee's. ## 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 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): ```{.python .no-run} --8<-- "examples/tui_crawler/content.py:idol-shrine-area" ``` -Level 1 also keys a goblin-guarded guard room, but level 2 keys no monsters at all — its only area is an unguarded vault. Instead, level 2's [`WanderingSpec`][osrlib.crawl.dungeon.WanderingSpec] overrides both the odds and the interval so a check happens on *every* turn, against a custom [`EncounterTable`][osrlib.core.tables.EncounterTable] of rival adventuring parties rather than the compiled monster table: +Level 1 also has a goblin-guarded guard room, but level 2 has no keyed monsters at all: its only area is an unguarded vault. Instead, level 2's [`WanderingSpec`][osrlib.crawl.dungeon.WanderingSpec] overrides both the odds and the interval so a check happens on *every* turn, against a custom [`EncounterTable`][osrlib.core.tables.EncounterTable] of rival adventuring parties rather than the compiled monster table: ```{.python .no-run} --8<-- "examples/tui_crawler/content.py:wandering-table" ``` -Level 1's own `WanderingSpec(chance_in_six=0)` disables wandering checks there entirely — every encounter on that level is the keyed goblins, and every encounter on level 2 is a rolled rival party. Both are ordinary [`AreaSpec`][osrlib.crawl.dungeon.AreaSpec] and [`EncounterTable`][osrlib.core.tables.EncounterTable] instances; nothing about authoring them is specific to a terminal front end. +Level 1's own `WanderingSpec(chance_in_six=0)` disables wandering checks there entirely, so every encounter on that level is the keyed goblins, and every encounter on level 2 is a rolled rival party. The guard room is an ordinary [`AreaSpec`][osrlib.crawl.dungeon.AreaSpec] and the rival table an ordinary [`EncounterTable`][osrlib.core.tables.EncounterTable]. Nothing about authoring either one is specific to a terminal front end. ## Building the party -`create.py` drives character creation two ways: an interactive one that prompts for a name, class, and alignment per slot, and a scripted one that builds a fixed roster from starting gold. Both call the same [`create_character`][osrlib.core.character.create_character] function used in the [quickstart](../getting-started/quickstart.md); only where the choices come from differs. The scripted party — one of each core class, fighter, cleric, thief, and magic-user, kitted out from its own starting gold — is what the non-interactive `--script` mode always builds, which is why it plays back identically every time: +`create.py` drives character creation two ways: an interactive one that prompts for a name, class, and alignment per slot, and a scripted one that builds a fixed roster from starting gold. Both call the same [`create_character`][osrlib.core.character.create_character] function used in the [quickstart](../getting-started/quickstart.md). Only where the choices come from differs. The scripted party is one of each core class, fighter, cleric, thief, and magic-user, each kitted out from its own starting gold. That's the party the non-interactive `--script` mode always builds, which is why it plays back identically every time: ```{.python .no-run} --8<-- "examples/tui_crawler/create.py:script-party-roster" @@ -77,13 +77,13 @@ Level 1's own `WanderingSpec(chance_in_six=0)` disables wandering checks there e ## The fetch quest: authored data, not front-end code -The barrow's hook — "the temple pays 200 gp for the Jade Idol's return" — is part of the adventure, not part of the crawler. The idol is a bundled [`GearTemplate`][osrlib.core.items.GearTemplate] the shop never stocks, dropped into the shrine cache by id, so picking it up reports a catalog id anything can match on: +The barrow's hook ("the temple pays 200 gp for the Jade Idol's return") is part of the adventure, not part of the crawler. The idol is a bundled [`GearTemplate`][osrlib.core.items.GearTemplate] the shop never stocks, dropped into the shrine cache by id, so picking it up reports a catalog id anything can match on: ```{.python .no-run} --8<-- "examples/tui_crawler/content.py:bundled-idol" ``` -The quest itself is a [`QuestSpec`][osrlib.crawl.quests.QuestSpec] in the same file — an activation clause, two objectives, three rewards, and the marker that says finishing it finishes the adventure: +The quest itself is a [`QuestSpec`][osrlib.crawl.quests.QuestSpec] in the same file: an activation clause, two objectives, three rewards, and the field that makes finishing it finish the adventure: ```{.python .no-run} --8<-- "examples/tui_crawler/content.py:fetch-quest" @@ -95,7 +95,7 @@ Nothing in the crawler tracks any of it. `__main__.py` registers the library's [ --8<-- "examples/tui_crawler/__main__.py:register-interpreter" ``` -The interpreter is an ordinary [`Listener`][osrlib.crawl.session.Listener]: it runs after every command, matches the events against the adventure's triggers and quests, and acts the only way anything outside the engine may — by executing referee commands, each stamped with what it acted for: `source="quest:the-idol"` on every command this quest causes, `source="trigger:{id}"` when an authored trigger fires, so the command log answers *why* on its own. Two moments from the end of the same milestone run show it, rendered from typed events by the same formatter as everything else. Emptying the shrine cache: +The interpreter is an ordinary [`Listener`][osrlib.crawl.session.Listener]: it runs after every command, matches the events against the adventure's triggers and quests, and acts the only way anything outside the engine may, by executing referee commands. Each command is stamped with what it acted for: `source="quest:the-idol"` on every command the interpreter issues for this quest, and `source="trigger:{id}"` when an authored trigger fires, so the command log answers *why* on its own. Two moments from the end of the same milestone run show it, rendered from typed events by the same formatter as everything else. Emptying the shrine cache: ```text > take idol_shrine @@ -124,27 +124,27 @@ Then, four `move w` steps later, the homecoming: character-0004 gains 1320 XP (base 1200), now level 1. ``` -Two details of that output are the whole chapter in miniature. The cache spreads across the party by the ordinary loot rules, so which character walks home with the idol is whatever the split decided — here the fighter, and the cleric is not in the list at all, having died in the vault — and the objective's `has_item` condition tests whether *the party* holds the idol, not who. And the completion beat appears twice, on the quest's own event and again on the adventure's, because each event carries the authored line and the formatter appends whatever beat rides the event it is given. +Two details of that output are worth reading closely. The engine spreads the cache across the party by the ordinary loot rules, so which character walks home with the idol is whatever the split produced. Here it's the fighter, and the cleric is not in the list at all, having died in the vault. The objective's `has_item` condition tests whether *the party* holds the idol, not which member does. The completion beat then appears twice, on the quest's own event and again on the adventure's, because each event includes the authored line and the formatter appends the beat on whatever event it's given. ### Why the milestone makes two trips -The homecoming objective is a `town_entered` pattern narrowed by a `has_item` condition, so walking back empty-handed is not a return — the objective simply does not fire. That one clause is what gives `scripts/milestone.txt` its shape: +The homecoming objective is a `town_entered` pattern narrowed by a `has_item` condition, so walking back empty-handed is not a return: the objective doesn't fire. That one clause is what gives `scripts/milestone.txt` its shape: 1. **Down**, for the goblins, their lair hoard, and the rival party prowling level 2. -2. **Home without the idol.** The return banks the end-of-adventure award, and the party sells its haul and buys a temple healing — town commands that are legal here and nowhere later, because the adventure has not ended yet. Coin weighs a coin apiece, so the seller spreads the purse with `give` before anybody walks again. +2. **Home without the idol.** The return banks the end-of-adventure award, and the party sells its haul and buys a temple healing. Those town commands are legal here and nowhere later, because the adventure has not ended yet. Coin weighs a coin apiece, so the seller spreads the purse with `give` before anybody walks again. 3. **Down again**, for the idol alone. -4. **Home with it**, which completes the second objective, completes the quest, and — the quest carrying `concludes_adventure=True` — ends the session in `victory`. The rewards land *after* that transition: the 200 gp, the party's XP, and the `quest.idol` flag the crawler prints on its way out. +4. **Home with it**, which completes the second objective, completes the quest, and, because the quest has `concludes_adventure=True`, ends the session in `victory`. The rewards land *after* that transition: the 200 gp, the party's XP, and the `quest.idol` flag the crawler prints on its way out. -A concluded session still takes referee commands and refuses play, so the closing `status` reads `[victory]` and any further `move` would be `wrong_mode`. [`SessionMode.terminal`][osrlib.crawl.commands.SessionMode] is the loop condition a front end checks — true in `victory` and `game_over` alike, it answers "has this session ended?" in one read, and [the LLM referee page](llm-referees.md#the-schemas-are-the-tool-definitions) shows it guarding an agent loop. This crawler deliberately does *not* break on it: the loop stays open after victory so the script's closing `journal` and `status` can still be read, which is exactly the referee-side access a terminal mode preserves. +A concluded session still takes referee commands and refuses play, so the closing `status` reads `[victory]` and any further `move` would be `wrong_mode`. [`SessionMode.terminal`][osrlib.crawl.commands.SessionMode] is the loop condition a front end checks. It's true in `victory` and `game_over` alike, so one read tells a front end whether the session has ended, and [the LLM referee page](llm-referees.md#the-schemas-are-the-tool-definitions) shows it guarding an agent loop. This crawler deliberately does *not* break on it: the loop stays open after victory so the script's closing `journal` and `status` can still be read, which is exactly the referee-side access a terminal mode preserves. -Two beats of authoring discipline fall out of the reward ordering and are worth copying. Put the town business before the concluding return, while play commands are still legal. And put the story's thanks in `AwardXP` rather than in coin: under the default on-return timing, treasure converts to XP when the party comes home, and the concluding return's award has already resolved by the time the rewards issue — so the temple's 200 gp arrives as real, spendable coin, but no XP will ever be minted from it. +Two authoring habits fall out of the reward ordering and are worth copying. Put the town business before the concluding return, while play commands are still legal. And put the story's thanks in `AwardXP` rather than in coin: under the default on-return timing, treasure converts to XP when the party comes home, and the concluding return's award has already resolved by the time the rewards issue. The temple's 200 gp arrives as real, spendable coin, but it never converts to XP. [Listeners and flags](../guides/listeners-and-flags.md) covers the listener contract the interpreter follows, and [Gates, triggers, and quests](../guides/gates-triggers-quests.md) covers authoring quests of your own. ## Where next -- [Building an adventure](../getting-started/building-an-adventure.md) — the dungeon geometry and authoring models the barrow is built from. -- [Gates, triggers, and quests](../guides/gates-triggers-quests.md) — the authored layer behind the fetch quest, and how to write your own. -- [Views and visibility](../guides/views-and-visibility.md) — what a player's view includes, and how it's built from referee-only state. -- [Listeners and flags](../guides/listeners-and-flags.md) — registering listeners, the flag store, and the contract quest and achievement systems rely on. -- [The FastAPI pattern](fastapi-pattern.md) and [LLM referees](llm-referees.md) — the same `GameSession`, driven by different front ends entirely. +- [Building an adventure](../getting-started/building-an-adventure.md) - the dungeon geometry and authoring models the barrow is built from. +- [Gates, triggers, and quests](../guides/gates-triggers-quests.md) - the authored layer behind the fetch quest, and how to write your own. +- [Views and visibility](../guides/views-and-visibility.md) - what a player's view includes, and how it's built from referee-only state. +- [Listeners and flags](../guides/listeners-and-flags.md) - registering listeners, the flag store, and the contract quest and achievement systems rely on. +- [The FastAPI pattern](fastapi-pattern.md) and [LLM referees](llm-referees.md) - the same `GameSession`, driven by different front ends entirely. diff --git a/docs/getting-started/building-an-adventure.md b/docs/getting-started/building-an-adventure.md index 874b459..df6c328 100644 --- a/docs/getting-started/building-an-adventure.md +++ b/docs/getting-started/building-an-adventure.md @@ -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: @@ -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 diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index c38351e..ac22bac 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -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. diff --git a/docs/guides/authoring-custom-content.md b/docs/guides/authoring-custom-content.md index f3e72aa..348b335 100644 --- a/docs/guides/authoring-custom-content.md +++ b/docs/guides/authoring-custom-content.md @@ -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 @@ -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. diff --git a/docs/guides/determinism-saves-replay.md b/docs/guides/determinism-saves-replay.md index bbd04ef..09f4652 100644 --- a/docs/guides/determinism-saves-replay.md +++ b/docs/guides/determinism-saves-replay.md @@ -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 -- diff --git a/docs/guides/gates-triggers-quests.md b/docs/guides/gates-triggers-quests.md index 30f0f54..8bdc3a0 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 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. @@ -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. @@ -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 @@ -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 @@ -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 diff --git a/docs/guides/rules-without-a-session.md b/docs/guides/rules-without-a-session.md index 2850155..ca2450a 100644 --- a/docs/guides/rules-without-a-session.md +++ b/docs/guides/rules-without-a-session.md @@ -2,7 +2,7 @@ You want to roll dice, resolve an attack, or generate a hoard from a script, with no session, no adventure, and no game loop. That's what `osrlib.core`, the rules **kernel**, is for: dice, combat, treasure, spells, and the printed tables, as pure functions over frozen models. The kernel doesn't depend on a running game. The dungeon-crawl framework in `osrlib.crawl` is one consumer of the kernel, built entirely on top of it. A mass-combat simulator, a balance harness, or a content-validation script is just as valid a second consumer. The layering only runs one way: core never imports crawl, so anything you build against `osrlib.core` keeps working no matter what the crawl layer does above it. -Away from a session, you bring your own [`RngStreams`][osrlib.core.rng.RngStreams] and pass the stream each function takes explicitly. There's no default stream and no hidden global RNG. [The RNG streams reference](../reference/rng-streams.md) lists the stream keys a running [`GameSession`][osrlib.crawl.session.GameSession] uses by convention, but standalone code isn't bound by them. A stream's name is just a label, and determinism only requires that the same name draw the same sequence for a given master seed. [The complete program](#the-complete-program) at the end rolls dice, resolves an attack, generates treasure, and looks up a reaction in one script. Every code snippet above it comes from that script. +Away from a session, you bring your own [`RngStreams`][osrlib.core.rng.RngStreams] and pass the stream each function takes explicitly. There's no default stream and no hidden global RNG. [The RNG streams reference](../reference/rng-streams.md) lists the stream keys a running [`GameSession`][osrlib.crawl.session.GameSession] uses by convention, but standalone code isn't bound by them. A stream's name is a label, and determinism only requires that the same name draw the same sequence for a given master seed. [The complete program](#the-complete-program) at the end rolls dice, resolves an attack, generates treasure, and looks up a reaction in one script. Every code snippet above it comes from that script. ## The dice grammar diff --git a/docs/guides/ruleset-options.md b/docs/guides/ruleset-options.md index 25dad63..779744d 100644 --- a/docs/guides/ruleset-options.md +++ b/docs/guides/ruleset-options.md @@ -51,7 +51,7 @@ These exist because OSE's printed text hands the referee an open-ended judgment **`aoe_friendly_fire`** (default on): an area effect (a fireball, a dragon's breath) that lands on a monster group already fighting the party at melee range can catch the party's own engaged front rank in the blast, alongside the monsters. Off keeps party members out of every area effect's candidate list. -**`formation_width_limit`** (default on): caps how many combatants can fight in the same rank at once, at what actually fits in the space the party stands in. OSE gives one number and leaves the rest to the referee: at most 2-3 fit side by side in a 10-foot passage. osrlib takes the conservative end of that, five feet of frontage each, and measures the room, meaning the widest square of unbroken floor around the party. Two fit in a one-cell passage however far it runs, four in a room two cells square, and eight in a room four cells across. The same cap bounds how much of an area effect's footprint a formation absorbs. Off removes the cap: every combatant in the front rank fights, and formation width no longer bounds an area effect's footprint. +**`formation_width_limit`** (default on): caps how many combatants can fight in the same rank at once, at what actually fits in the space the party stands in. OSE gives one number and leaves the rest to the referee: at most 2-3 fit side by side in a 10-foot passage. osrlib takes the conservative end of that, five feet of frontage each, and measures the widest square of unbroken floor around the party. Two fit in a one-cell passage however far it runs, four in a room two cells square, and eight in a room four cells across. The same cap bounds how much of an area effect's footprint a formation absorbs. Off removes the cap: every combatant in the front rank fights, and formation width no longer bounds an area effect's footprint. ## Constructing a `Ruleset` diff --git a/docs/guides/sessions-commands-events.md b/docs/guides/sessions-commands-events.md index 852439a..5b7eb3d 100644 --- a/docs/guides/sessions-commands-events.md +++ b/docs/guides/sessions-commands-events.md @@ -8,7 +8,7 @@ A [`GameSession`][osrlib.crawl.session.GameSession] is a running game. It owns e `execute` runs two phases. First, a mode check: if the session's current mode isn't one of the command's declared `allowed_modes`, `execute` rejects the command immediately with no further work. Otherwise the command's handler runs. Every handler is validate-then-mutate, checking every precondition before drawing a single die or changing a single field. If the handler finds a problem, it returns rejections and the session is untouched: no command-log entry, no event-log entries, no RNG draws, no clock time. Only when a command clears every check does anything change. The session appends the command to the command log and its events to the event log, then runs any registered listeners in registration order, each one seeing the events so far and appending its own reactions to both the result and the log. The `CommandResult` you get back from an accepted command contains the *complete* chain: the handler's events and every listener's events, in the order they happened. -That includes what a listener causes by executing further commands. Those nested commands log their own events, and `execute` folds everything logged while a listener ran into the result: each event exactly once, in log order. So one `MoveParty` can come back with the move, the portcullis a trigger opened in response, and the journal entry that recorded it. Your front end renders all of it from one envelope without ever reading `session.event_log`. +That includes what a listener causes by executing further commands. Those nested commands log their own events, and `execute` folds everything logged while a listener ran into the result: each event exactly once, in log order. So one `MoveParty` can come back with the move, the portcullis a trigger opened in response, and the journal entry that recorded the opening. Your front end renders all of it from one envelope without ever reading `session.event_log`. Listeners are how your game adds its own reactive rules (a quest tracker, an achievement log) without touching the kernel. For the extension point itself, see [Listeners and flags](listeners-and-flags.md). @@ -21,7 +21,7 @@ assert session.command_log[-1].source == "trigger:lever-east" assert session.view(Visibility.PLAYER).journal[-1].text == "The lever grinds." ``` -The library's [`Interpreter`][osrlib.crawl.interpreter.Interpreter] stamps every command it issues this way: `trigger:{id}` for a trigger's firing, `quest:{id}` for everything a quest causes (see [Gates, triggers, and quests](gates-triggers-quests.md) for how you author triggers and quests). A log left behind by authored content then reads as a transcript with attributions: this grant came from `trigger:idol-lifted`, that door opened for `trigger:portcullis-rises`, the coins came from `quest:the-idol`, and the `record_note` beside them says which consequence was dropped and why. +The library's [`Interpreter`][osrlib.crawl.interpreter.Interpreter] stamps every command it issues this way: `trigger:{id}` for a trigger's firing, `quest:{id}` for everything a quest causes (see [Gates, triggers, and quests](gates-triggers-quests.md) for how you author triggers and quests). A log left behind by authored content then reads as a transcript with attributions: this grant came from `trigger:idol-lifted`, that door opened for `trigger:portcullis-rises`, the coins came from `quest:the-idol`, and the `record_note` beside them records which consequence was dropped and why. ## Session modes and mode gating @@ -32,7 +32,7 @@ The library's [`Interpreter`][osrlib.crawl.interpreter.Interpreter] stamps every assert MoveParty.allowed_modes == frozenset({SessionMode.EXPLORING}) ``` -Most dungeon-movement commands ([`MoveParty`][osrlib.crawl.commands.MoveParty], `TurnParty`, `OpenDoor`, `Search`, and the rest) are legal only while `exploring`. Commands that make sense both at rest and on the move (`ReorderParty`, `LightSource`, `Rest`, `CastSpell`) are legal in `town` or `exploring`. Encounter-only commands (`Parley`, `Evade`, `EngageBattle`, `Wait`, `TurnUndead`) require `encounter`, and `ResolveBattleRound` requires `battle`. A handful, like `DropItems`, span two modes on purpose: dropping treasure to distract pursuers works whether the party is still exploring or already in an encounter. Referee commands (`GrantItem`, `SetFlag`, `AwardXP`, `AdvanceTime`, the [lifecycle commands](#the-lifecycle-commands), and the rest of the session-owned surface) are legal in every mode, the two terminal ones included. A referee correcting the world doesn't stop just because the party fell, and an adventure's rewards can land after it ends. Three referee commands are the exception, each because it would resume play in a session that is over. [`PlaceParty`][osrlib.crawl.commands.PlaceParty] teleports the party into `exploring` or `town`, and `SpawnMonsters` and `SpawnNpcParty` open an encounter. The two spawn commands are illegal in both terminal modes. `PlaceParty` is illegal in `victory` alone and stays legal in `game_over`, because carrying the fallen party to town is the first step of the documented revival flow: `PlaceParty(town)` then [`PurchaseHealing`][osrlib.crawl.commands.PurchaseHealing] with `service="raise_dead"`, with the clock still running on the revival window. +Most dungeon-movement commands ([`MoveParty`][osrlib.crawl.commands.MoveParty], `TurnParty`, `OpenDoor`, `Search`, and the rest) are legal only while `exploring`. Commands that make sense both at rest and on the move (`ReorderParty`, `LightSource`, `Rest`, `CastSpell`) are legal in `town` or `exploring`. Encounter-only commands (`Parley`, `Evade`, `EngageBattle`, `Wait`, `TurnUndead`) require `encounter`, and `ResolveBattleRound` requires `battle`. A handful, like `DropItems`, span two modes on purpose: dropping treasure to distract pursuers works whether the party is still exploring or already in an encounter. Referee commands (`GrantItem`, `SetFlag`, `AwardXP`, `AdvanceTime`, the [lifecycle commands](#the-lifecycle-commands), and the rest of the session-owned surface) are legal in every mode, the two terminal ones included. A referee correcting the world doesn't stop just because the party fell, and an adventure's rewards can land after it ends. Three referee commands are the exception, each because it would resume play in a session that is over. [`PlaceParty`][osrlib.crawl.commands.PlaceParty] teleports the party into `exploring` or `town`, and `SpawnMonsters` and `SpawnNpcParty` open an encounter. The two spawn commands are illegal in both terminal modes, `game_over` and `victory`. `PlaceParty` is illegal in `victory` alone and stays legal in `game_over`, because carrying the fallen party to town is the first step of the documented revival flow: `PlaceParty(town)` then [`PurchaseHealing`][osrlib.crawl.commands.PurchaseHealing] with `service="raise_dead"`, with the clock still running on the revival window. The modes form a loop with two ways out. [`EnterDungeon`][osrlib.crawl.commands.EnterDungeon] moves the party from `town` to the dungeon entrance and switches the session to `exploring`. Stepping into a keyed area's cells, a wandering-monster check, or a referee's `SpawnMonsters` or `SpawnNpcParty` opens an encounter and switches to `encounter`. `EngageBattle` opens full combat and switches to `battle`. A battle ends back in `encounter` (the party broke off and a pursuit begins), in `exploring` (the monsters are beaten, the encounter closes, and play continues), or, if the whole party falls, in the terminal `game_over`. `TravelToTown` is the return trip, switching `exploring` back to `town`. @@ -43,7 +43,7 @@ A lost battle is not the only way a session ends. Any command whose events leave The authored layer keeps its own books with seven referee commands. Three of them are the trigger and journal vocabulary: [`MarkTriggerFired`][osrlib.crawl.commands.MarkTriggerFired], [`AddJournalEntry`][osrlib.crawl.commands.AddJournalEntry], and [`RecordNote`][osrlib.crawl.commands.RecordNote]. The other four advance quest state: - [`ActivateQuest`][osrlib.crawl.commands.ActivateQuest] puts a quest in play. -- [`RevealObjective`][osrlib.crawl.commands.RevealObjective] surfaces a hidden objective. +- [`RevealObjective`][osrlib.crawl.commands.RevealObjective] shows the players a hidden objective. - [`CompleteObjective`][osrlib.crawl.commands.CompleteObjective] marks an objective done, and reveals it on the way, because an objective the party finished is one the party can be told about. - [`CompleteQuest`][osrlib.crawl.commands.CompleteQuest] finishes the quest, and on a quest marked as concluding the adventure, ends the session in `victory`. @@ -93,7 +93,7 @@ The full catalog of shipped event classes and message codes lives in [the events ## The wire discriminators -Commands and events share the same discriminated-union shape: a frozen pydantic model with `extra="ignore"` and a single-valued string field that names its concrete class on the wire (`command_type` on `Command`, `event_type` on `Event`). [`parse_command`][osrlib.crawl.commands.parse_command] and [`parse_any_event`][osrlib.crawl.events.parse_any_event] parse a previously-dumped payload back into the right concrete type, and both are deliberately tolerant of *unknown* types: a `command_type` or `event_type` this version of the library has never heard of parses to `None` instead of raising. That's the additive-schema guarantee in practice. A save or a network payload produced by a newer engine version can include command and event kinds an older consumer has never seen, and the older consumer skips them instead of crashing. +Commands and events share the same discriminated-union shape: a frozen pydantic model with `extra="ignore"` and a single-valued string field that names its concrete class on the wire (`command_type` on `Command`, `event_type` on `Event`). [`parse_command`][osrlib.crawl.commands.parse_command] and [`parse_any_event`][osrlib.crawl.events.parse_any_event] parse a previously-dumped payload back into the right concrete type, and both are deliberately tolerant of *unknown* types: a `command_type` or `event_type` this version of the library doesn't define parses to `None` instead of raising. That's the additive-schema guarantee in practice. A save or a network payload produced by a newer engine version can include command and event kinds an older consumer has never seen, and the older consumer skips them instead of crashing. ```{.python .no-run} # Commands and events round-trip through their wire discriminator; unknown types parse to None. @@ -106,7 +106,7 @@ assert parse_any_event(event_payload) == result.events[0] assert parse_any_event({"event_type": "some_future_event", "code": "x.y"}) is None ``` -The tolerance extends **only** to types the parser has never seen. A payload whose `command_type` or `event_type` *is* recognized but whose fields don't validate (a required field missing, a value of the wrong shape) is malformed data, not a forward compatibility case, and raises `ContentValidationError` instead of returning `None`: +The tolerance extends **only** to types the parser doesn't recognize. A payload whose `command_type` or `event_type` *is* recognized but whose fields don't validate (a required field missing, a value of the wrong shape) is malformed data, not a forward compatibility case, and raises `ContentValidationError` instead of returning `None`: ```{.python .no-run} # A malformed payload of a *known* type is a broken API contract: it raises, never rejects. diff --git a/docs/index.md b/docs/index.md index 9200fd0..4e758b5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,7 +15,7 @@ osrlib is designed for four kinds of consumer: - 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. -- [Gates, triggers, and quests](guides/gates-triggers-quests.md) adds the authored behavior: a door that needs a key, a lever that opens a portcullis, and a quest that ends the adventure in victory. +- [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. - The [reference](reference/api/index.md) documents every public symbol, command, event, rejection code, message code, RNG stream, and content id. diff --git a/docs/reference/rng-streams.md b/docs/reference/rng-streams.md index 0843e13..3b8f657 100644 --- a/docs/reference/rng-streams.md +++ b/docs/reference/rng-streams.md @@ -2,7 +2,7 @@ Every random draw in osrlib comes from a named stream, and every stream is forked from the session's master seed. Given the same master seed and the same stream key, a stream -always produces the identical sequence of draws — and it produces that sequence no +always produces the identical sequence of draws, and it produces that sequence no matter what any other stream does. Drawing a hundred rolls from the treasure stream never changes what the combat stream yields next. That per-key independence is what makes deterministic replays and saved games reliable: two sessions built from the same @@ -12,15 +12,17 @@ subsystem's rolls. Each stream is identified by a plain string key, such as `"combat"` or `"treasure"`. [`StreamName`][osrlib.core.rng.StreamName] is where those keys are defined, and every constant in the table below is one of its members, so a key has one spelling in the -library and a mistyped one cannot fork a stream of its own. -Code that uses the kernel functions directly — standalone, outside of a running game — +library. Draw with a member rather than a string you type out: +[`RngStreams.get`][osrlib.core.rng.RngStreams.get] forks a stream of its own for any +string, so a mistyped key draws plausible numbers from that stream instead of raising. + +Code that uses the kernel functions directly (standalone, outside of a running game) passes an explicit stream into each function call. A [`GameSession`][osrlib.crawl.session.GameSession] -does this wiring for you: it owns an `RngStreams` container built from the session's -master seed and hands out the correctly named stream wherever a kernel function needs -one, so ordinary gameplay never requires touching a stream directly. +does this wiring for you: it builds an `RngStreams` container from the session's master +seed and hands out the correctly named stream wherever a kernel function needs one, so +ordinary gameplay never requires touching a stream directly. -The table below lists every key, the constant that names it, and what it governs; the -sections that follow give more detail on each. +The table below lists every key, the constant that names it, and what it governs. | Stream key | Constant | Governs | | --- | --- | --- | @@ -118,7 +120,7 @@ this on its own stream means swapping in a different monster action policy never shifts the combat stream's attack and damage rolls. The [`ADJUDICATION_STREAM`][osrlib.crawl.session.ADJUDICATION_STREAM] (key -`"adjudication"`) covers the referee's ad-hoc rolls for freeform adjudication — +`"adjudication"`) covers the referee's ad-hoc rolls for freeform adjudication: dice commanded through the seeded session to resolve a chance outcome the content model can't express, such as whether a frayed rope holds. Keeping these on their own stream means an ad-hoc referee roll never perturbs the draw sequence of a keyed