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
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ These are contracts, not suggestions — see the corresponding spec sections bef
- **Schema discipline.** Serialized models follow the `schema_version` rules: additive-only within a version; renames, removals, and semantic changes bump it.
- **Frozen data.** SRD data models are frozen; play spawns mutable instances from templates.

## Documentation conventions

- **No numeric surface counts in prose.** Never write "all 44 commands" or "68 events" — the registries and the generated reference pages carry the numbers, and prose counts drift the moment a surface grows.
- **The one-way bridge.** Common, jobs-to-be-done language *locates* a concept (navigation labels, headings, opening sentences); the project term is introduced once, job first ("osrlib calls this a gate"), and from that sentence on the docs commit to the term. No page maintains a parallel vocabulary.
- **The voice gradient.** The funnel top — README, index, quickstart, guide openings — is written plain, every sentence doing instruction; the register stays rich in guide interiors, walkthroughs, and reference prose, where the reader has bought in.
- **Transcripts are captured, never composed.** Any quoted program output — TUI transcripts, command output — is re-captured from a real run after every change that could affect it, and never hand-edited.

## Testing expectations

- Table fidelity tests assert against SRD values directly; golden-seed scenario tests are scoped per RNG stream.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Changed

- The example TUI crawler renders from the result envelope and shows the authored layer whole. Its loop now iterates `result.events` instead of diffing `session.event_log` — the envelope already carries everything a nested listener-issued command logged, so the delta idiom's stated rationale was false — and a rejection now prints the authored `refusal` text after its code when a gate wrote one, so the one rejection family carrying player-facing words stops being silently dropped by the renderer everyone copies. `status` lists the active quests with their revealed objectives' states from `PlayerView.quests`, and a new `journal` verb prints `PlayerView.journal` with its clock stamps — a pure view read, executing no command and drawing nothing; the milestone script gains one `journal` line before its closing `status`. No command sequence or draw changed, and the scenario goldens stand byte-for-byte.
- Both example front ends now author their fetch quest as adventure data and register the library's `Interpreter` to play it; the hand-rolled `FetchQuestListener` is deleted with no shim. The Jade Idol became a bundled `GearTemplate` placed in the shrine cache by id, so taking it reports a catalog id an `ItemAcquiredPattern` matches and a `has_item` condition tests, and the homecoming objective is a `TownEnteredPattern` narrowed by that condition — walking back empty-handed is not a return. The quest concludes the adventure, so the milestone transcript restructured into two trips: the delve and the town business first, because selling and healing are illegal once the session is in `victory`, then back down for the idol and home to the completion, the rewards, and the ending — the TUI gaining a `give` verb along the way, because a sold haul is a purse full of coin and coin weighs a coin apiece. Its economics moved with it — the idol is mundane gear now, worth no treasure XP by RAW, and the reward lands in town after the last award has fired — so the authored `AwardXP` rose from 600 to 1200 per member, which restores the run's XP totals exactly. The example listener was the extension-surface proof; the guides keep teaching that pattern with a self-contained listener of their own, and the interpreter as the shipped instance of it.
- A `CommandResult` now carries the events of commands a listener issued while reacting. A listener that reacts by executing further commands has always logged their events correctly and reported none of them back: the caller of `MoveParty` got the move and nothing of the portcullis that opened in response, and had to read `session.event_log` to find the rest. `execute` now notes where the log ends before each listener runs and folds everything logged while it ran into the result — the nested commands' events, however deeply they nest, each exactly once and in log order, followed by whatever the listener authored. A listener that emits events and issues no commands is unaffected, and nothing about what reaches the log changes.
- `SpawnMonsters` and `SpawnNpcParty` no longer execute in `game_over`. Every referee command used to be legal in every mode without exception, which meant a referee could spawn a wandering patrol onto a party that had already fallen — and the encounter that opened put a concluded session back into `encounter`, or straight into `battle` on an attacking reaction, with corpses on one side of it. Spawning was never part of the salvage flow (that door is `PlaceParty`, and a session salvaged back to town can spawn again the moment it re-enters a dungeon), so both commands now reject with `session.command.wrong_mode` in `game_over` as well as in the new `victory`. Every other referee command still runs in a terminal mode.
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

A Python library implementing the classic 1981 B/X (Basic/Expert) fantasy adventure game rules for turn-based, grid-based dungeon crawlers in the style of the original Bard's Tale. The rules are sourced from the [Old-School Essentials System Reference Document](https://oldschoolessentials.necroticgnome.com/srd/), an Open Game Content restatement of the B/X rules. osrlib is the rules authority and game-state engine; your game supplies presentation, input, and content.

The library is headless and sans-I/O — it never renders, prompts, sleeps, or touches the network — and every game it runs is deterministic: the same seed and the same commands always replay the same game. Four kinds of consumer are first-class: a web or mobile backend (FastAPI over HTTP), a terminal game (a local TUI crawler), an LLM referee or narrator driven by structured events and typed commands, and scripts or simulations using the kernel à la carte.
The library is headless and sans-I/O — it never renders, prompts, sleeps, or touches the network — and every game it runs is deterministic: the same seed and the same commands always replay the same game. Adventures carry their own content and behavior — bundled items, gated doors, triggers, and quests — and the library ships the interpreter that plays them through to a victory ending. Four kinds of consumer are first-class: a web or mobile backend (FastAPI over HTTP), a terminal game (a local TUI crawler), an LLM referee or narrator driven by structured events and typed commands, and scripts or simulations that call the rules kernel with no session at all.

**Status:** released — [osrlib on PyPI](https://pypi.org/project/osrlib/). The public API is frozen, and the [documentation site](https://mmacy.github.io/osrlib-python/) is the place to learn the library — quickstart, guides, front-end walk-throughs, and a full reference for every command, event, rejection code, and content id.
**Status:** released — [osrlib on PyPI](https://pypi.org/project/osrlib/). The public API is frozen, and the [documentation site](https://mmacy.github.io/osrlib-python/) is the place to learn the library — quickstart, guides, front-end walk-throughs, and a full reference for every public symbol, command, event, rejection code, message code, RNG stream, and content id.

## Installation

Expand Down Expand Up @@ -68,7 +68,7 @@ restored = load_game(document)
assert save_game(restored) == document
```

The [documentation site](https://mmacy.github.io/osrlib-python/) walks this example step by step, then builds out from it: [building an adventure](https://mmacy.github.io/osrlib-python/getting-started/building-an-adventure/), the [session and event loop](https://mmacy.github.io/osrlib-python/guides/sessions-commands-events/), and complete [front-end walk-throughs](https://mmacy.github.io/osrlib-python/front-ends/tui-crawler/) for the two example games in `examples/`.
The [documentation site](https://mmacy.github.io/osrlib-python/) walks this example step by step, then builds out from it: [building an adventure](https://mmacy.github.io/osrlib-python/getting-started/building-an-adventure/), the [session and event loop](https://mmacy.github.io/osrlib-python/guides/sessions-commands-events/), [gates, triggers, and quests](https://mmacy.github.io/osrlib-python/guides/gates-triggers-quests/) — the authored layer above — and complete [front-end walk-throughs](https://mmacy.github.io/osrlib-python/front-ends/tui-crawler/) for the two example games in `examples/`.

## Determinism

Expand All @@ -86,7 +86,7 @@ rolls_b = [roll("2d6×10", streams_b.get("treasure")).total for _ in range(3)]
assert rolls_a == rolls_b # same seed + same key → identical sequences
```

Successive rolls on one stream differ, of course; reproducibility across derivations is the contract. Saved games replay from the seed and the command log, so a loaded game is bit-for-bit the game you saved.
Successive rolls on one stream differ, of course; reproducibility across derivations is the contract. A saved game restores from its serialized state alone — no re-execution — while `replay_game` separately rebuilds the identical session by re-executing the seed and the command log from scratch; that the two paths always agree is the determinism guarantee, exercised as a standing test.

## SRD data pipeline

Expand Down
32 changes: 28 additions & 4 deletions docs/front-ends/fastapi-pattern.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 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, 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, 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 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).

Expand All @@ -18,6 +18,12 @@ The session's lock is held across every `execute` and every view read, so one se

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

The handler then branches on which field arrived:

```{.python .no-run}
--8<-- "examples/fastapi_crawler/app.py:create-session"
```
Expand All @@ -27,9 +33,25 @@ Two details carry the trust story:
- **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 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:

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

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

## The command endpoint

One endpoint accepts every command in the engine's registry — all 44 of them, 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` it has never heard of:

```{.python .no-run}
--8<-- "examples/fastapi_crawler/app.py:execute-command"
Expand Down Expand Up @@ -111,7 +133,9 @@ The only game-state read the API offers is the player projection — [`session.v
--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 — 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 — 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.

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

## Saves stay on the server

Expand All @@ -121,7 +145,7 @@ A save document contains everything the wire withholds — the master seed, refe
--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 its listeners (listeners are live game objects, so a restored session needs them attached again), 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 — swapping in a database changes nothing about the pattern.

## Where next

Expand Down
Loading
Loading