diff --git a/AGENTS.md b/AGENTS.md index 00c23e6..d4dcac7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index f0f92cb..5c746b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index c6ac15b..37ba165 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 diff --git a/docs/front-ends/fastapi-pattern.md b/docs/front-ends/fastapi-pattern.md index 4b47bf1..942372d 100644 --- a/docs/front-ends/fastapi-pattern.md +++ b/docs/front-ends/fastapi-pattern.md @@ -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). @@ -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" ``` @@ -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" @@ -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 @@ -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 diff --git a/docs/front-ends/llm-referees.md b/docs/front-ends/llm-referees.md index 5ac5e62..a48e0d2 100644 --- a/docs/front-ends/llm-referees.md +++ b/docs/front-ends/llm-referees.md @@ -1,6 +1,6 @@ # LLM referees -An LLM-driven referee — a model that reads the game and decides what happens next — is a first-class consumer of osrlib, not an afterthought. The engine's shape is already the agent loop's shape: typed commands in, typed events out, a full-knowledge view to observe, and a deterministic core that makes every run reproducible. The pieces such an agent needs all ship today; a complete example agent is on the roadmap. This page assembles the pieces: [the complete program](#the-complete-program) at the end runs as written, and every fragment along the way is an excerpt of it. +An LLM-driven referee — a model that reads the game and decides what happens next — is a first-class consumer of osrlib, not an afterthought. The engine's shape is already the agent loop's shape: typed commands in, typed events out, a full-knowledge view to observe, and a deterministic core that makes every run reproducible. Everything such an agent would consume ships today: the schemas, the referee surface, the authored-content story, and the determinism guarantee. This page assembles the pieces: [the complete program](#the-complete-program) at the end runs as written, and every fragment along the way is an excerpt of it. ## The schemas are the tool definitions @@ -21,7 +21,7 @@ assert len(observations["oneOf"]) == len(ALL_EVENT_CLASSES) assert json.loads(json.dumps(tools)) == tools # plain JSON Schema, ready for a tool registry ``` -Forty-four commands, sixty-eight events, one discriminator field each — an agent framework that accepts JSON Schema tool definitions can load the command union as-is and let the model emit any command in the game, with validation for free. The loop such an agent runs is short (this is a sketch, not a framework): +The whole command surface and the whole event surface, one discriminator field each — an agent framework that accepts JSON Schema tool definitions can load the command union as-is and let the model emit any command in the game, with validation for free. The loop such an agent runs is short (this is a sketch, not a framework): ```{.python .no-run} # Sketch: the agent loop, framework left to the reader. @@ -62,6 +62,8 @@ Player commands let the model drive the party's turn; referee commands let it *r - [`GrantItem`][osrlib.crawl.commands.GrantItem], [`GrantCoins`][osrlib.crawl.commands.GrantCoins], [`AwardXP`][osrlib.crawl.commands.AwardXP] — place rewards directly - [`SetDoorState`][osrlib.crawl.commands.SetDoorState] — rewrite any door's state anywhere: lock it, wedge it, reveal it - [`PlaceParty`][osrlib.crawl.commands.PlaceParty] and [`AdvanceTime`][osrlib.crawl.commands.AdvanceTime] — teleport the party, advance the clock +- [`AddJournalEntry`][osrlib.crawl.commands.AddJournalEntry] and [`RecordNote`][osrlib.crawl.commands.RecordNote] — the agent's durable in-world memory: a journal entry speaks to the players and ships in their view, a note is the referee's own margin and stays behind the screen +- [`ActivateQuest`][osrlib.crawl.commands.ActivateQuest], [`RevealObjective`][osrlib.crawl.commands.RevealObjective], [`CompleteObjective`][osrlib.crawl.commands.CompleteObjective], [`CompleteQuest`][osrlib.crawl.commands.CompleteQuest] — advance authored quest state by hand, with one sharp edge: `CompleteQuest` pays nothing. Whoever completes a quest issues its rewards afterwards — the interpreter does exactly that — so a hand-issued completion that expects the payout to follow on its own will strand the party unpaid ```{.python .no-run} # Referee commands are the authorial surface: record a fact, then spring an ambush. @@ -74,7 +76,7 @@ The rejection contract matters as much here as it does for players: a rejected c ## Narrate from codes, not prose -Events never carry baked prose. Each carries a stable message `code` — a compact fact like `session.monsters.spawned` or `encounter.surprise.rolled` — plus typed fields; [the message code reference](../reference/message-codes.md) lists every shipped code with its emitting event class and default template, and each event's fields are on [its schema page](../reference/events/index.md). That is exactly what a narrator model wants: ground truth it can render freely without parsing English back into facts. When a plain default line is enough, [`format_message`][osrlib.messages.format_message] renders one for any event: +Events never carry *engine-baked* prose. Each carries a stable message `code` — a compact fact like `session.monsters.spawned` or `encounter.surprise.rolled` — plus typed fields; [the message code reference](../reference/message-codes.md) lists every shipped code with its emitting event class and default template, and each event's fields are on [its schema page](../reference/events/index.md). That is exactly what a narrator model wants: ground truth it can render freely without parsing English back into facts. The one kind of English an event does carry is *authored* narrative — a beat the adventure's author wrote, riding a structured field, which the next section teaches the narrator to treat differently from its own words. When a plain default line is enough, [`format_message`][osrlib.messages.format_message] renders one for any event, appending any authored beat verbatim: ```{.python .no-run} # Every event also renders to a default English line the model can lean on. @@ -84,6 +86,24 @@ assert all(lines) A practical narrator prompt sends the structured events (or their codes and fields) as the facts to narrate, and keeps the model's creativity in the telling — the dice already decided what happened. +## Narrating authored content + +An adventure written for the authored layer ([Gates, triggers, and quests](../guides/gates-triggers-quests.md)) arrives with material aimed squarely at a narrating referee, and an agent serving one should use all of it. + +**Register the library's [`Interpreter`][osrlib.crawl.interpreter.Interpreter] and do no bookkeeping.** One `session.register_listener(Interpreter(session))` after the session is built (and again after a load), and the triggers, the quests, the fired-marks, and the rewards all play themselves as ordinary logged commands. The agent referees; the adventure runs its own wiring. + +**`guidance` is steering, never script.** [`NarrativeBlock`][osrlib.crawl.narrative.NarrativeBlock] carries a `guidance` field on any authored object, and [`LevelSpec.guidance`][osrlib.crawl.dungeon.LevelSpec] holds whole-level ambience that hangs on no object at all — the TUI barrow's first level reads: + +```{.python .no-run} +--8<-- "examples/tui_crawler/content.py:level-guidance" +``` + +A referee-side narrator reads these straight off the adventure document it is refereeing and never prints them verbatim — the same trust posture as an area's description prose, which already flows into narration. No event carries guidance and no view ships it; it is the author talking to the narrator. + +**Authored beats are text to weave, not paraphrase.** A quest's offer and completion, an objective's progress, a gate's success and refusal arrive as structured fields on player-visible events and rejections, with `speaker` attribution when the author wrote one ("the temple almoner"). Those are the table's words: deliver them as written, in the speaker's voice, and put the model's creativity around them rather than over them. + +**[`Command.source`][osrlib.crawl.commands.Command] keeps the agent's hands visible.** Every command the interpreter issues is stamped `trigger:{id}` or `quest:{id}`, so an agent that stamps its own referee commands — or simply leaves them unstamped — leaves a log where its choices and the adventure's consequences never blur. That attribution is what completes the eval story below: replay a trajectory and the log itself says which grants were the model's ideas and which were the adventure playing out. + ## Determinism is the eval story Every random draw in osrlib comes from a named stream forked from the master seed, so the same seed plus the same command sequence produces the same game, bit for bit. For agent work this is the property that makes everything else tractable: a trajectory — the seed and the list of commands the model chose — is a complete, reproducible record of a run. Re-execute it offline and you get the same events to score; change a prompt and replay the same seeds to regression-test the change; diff two models on identical dungeons. [Determinism, saves, and replay](../guides/determinism-saves-replay.md) covers the exact guarantee and its boundary (identical replays are promised only under an identical engine version). @@ -192,6 +212,7 @@ assert [e.model_dump(mode="json") for e in rerun.events] == [e.model_dump(mode=" ## Where next +- [Gates, triggers, and quests](../guides/gates-triggers-quests.md) — the authoring side of the guidance and beats this page narrates. - [Views and visibility](../guides/views-and-visibility.md) — the referee/player projection line this page builds on. - [Determinism, saves, and replay](../guides/determinism-saves-replay.md) — the reproducibility guarantee behind the eval story. - [The FastAPI pattern](fastapi-pattern.md) — the other side of the doctrine: serving players who must *not* see what the referee sees. diff --git a/docs/front-ends/tui-crawler.md b/docs/front-ends/tui-crawler.md index 331bb5a..64d002a 100644 --- a/docs/front-ends/tui-crawler.md +++ b/docs/front-ends/tui-crawler.md @@ -3,9 +3,10 @@ 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, and everything a front end -supplies — rendering, input handling, authored content, even a whole quest — is -ordinary application code written against the public surface. The same +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. @@ -27,10 +28,12 @@ is entirely the game's problem — the library has no idea `"move e"` is a sente `_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 — with one addition: the crawler prints the *delta* of -the session's event log rather than just the result's own events, so a quest -listener's reactions (nested commands it executes on the game's behalf) show up in -the transcript too: +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: ```{.python .no-run} --8<-- "examples/tui_crawler/__main__.py:render-events" @@ -54,9 +57,10 @@ examples/tui_crawler/scripts/milestone.txt`) opens like this: The monsters' bearing: uncertain. ``` -The second line is already the delta loop 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`. +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. 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 @@ -65,8 +69,9 @@ message, or nothing at all (see [the message code reference](../reference/messag ## The player's view The event-level `Visibility` check above hides individual referee-only lines. The -crawler's status command takes a coarser approach: it asks the session for a whole -snapshot built for players, rather than reaching into referee-only state itself: +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: ```{.python .no-run} --8<-- "examples/tui_crawler/__main__.py:player-view" @@ -74,7 +79,14 @@ snapshot built for players, rather than reaching into referee-only state itself: [`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. The crawler never touches +valuables, and nothing a referee-only view would add. `_status` also walks +`PlayerView.quests`: the **active** quests only, each with its revealed objectives +and their states, 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. @@ -84,9 +96,10 @@ covers what a `PlayerView` includes and how it differs from the referee's. `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 descriptive text, an encounter, and a feature to a set of cells — -here, the shrine room whose cache holds the quest's MacGuffin, named by id so that -taking it is something the quest can match on: +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" @@ -160,7 +173,9 @@ the ones character creation already drew from: 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 the quest it acted for. Two moments from the end of the same +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: @@ -219,19 +234,32 @@ not fire. That one clause is what gives `scripts/milestone.txt` its shape: `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`. Two beats of -authoring discipline fall out of that ordering and are worth copying: put the town -business before the concluding return, and put the story's thanks in `AwardXP` rather -than in coin, because the last award has already fired by the time the temple pays. +`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. + +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. [Listeners and flags](../guides/listeners-and-flags.md) covers the listener contract -the interpreter follows, and [Building an adventure](../getting-started/building-an-adventure.md) +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, diff --git a/docs/getting-started/building-an-adventure.md b/docs/getting-started/building-an-adventure.md index 144b983..b967452 100644 --- a/docs/getting-started/building-an-adventure.md +++ b/docs/getting-started/building-an-adventure.md @@ -25,11 +25,11 @@ level = LevelSpec( edges={ "1,0:west": Edge(kind=EdgeKind.OPEN), "2,0:west": Edge(kind=EdgeKind.OPEN), - "3,0:west": Edge(kind=EdgeKind.DOOR, door=DoorSpec(requires=sentinel)), + "3,0:west": Edge(kind=EdgeKind.DOOR, door=DoorSpec()), }, ``` -An [`Edge`][osrlib.crawl.dungeon.Edge] is `open`, `wall`, or `door`; a door edge carries a [`DoorSpec`][osrlib.crawl.dungeon.DoorSpec] — normal or secret, optionally stuck or locked, optionally starting open, and optionally gated by an authored condition (the `sentinel` above, built in [Gating a door or a stair](#gating-a-door-or-a-stair)). `entrance` is the cell where [`EnterDungeon`][osrlib.crawl.commands.EnterDungeon] lands the party. +An [`Edge`][osrlib.crawl.dungeon.Edge] is `open`, `wall`, or `door`; a door edge carries a [`DoorSpec`][osrlib.crawl.dungeon.DoorSpec] — normal or secret, optionally stuck or locked, optionally starting open, and optionally gated by an authored condition ([Gates, triggers, and quests](../guides/gates-triggers-quests.md) teaches the gate that would hang on this door's `requires` field). `entrance` is the cell where [`EnterDungeon`][osrlib.crawl.commands.EnterDungeon] lands the party. ## Keyed areas @@ -58,153 +58,6 @@ Beyond encounters, an area (or the level itself) can carry: - [`TransitionSpec`][osrlib.crawl.dungeon.TransitionSpec] — stairs, trapdoors, and chutes between levels (these live on the level, not the area) - [`WanderingSpec`][osrlib.crawl.dungeon.WanderingSpec] — the level's wandering-monster check: 1-in-6 every two turns by default, with an optional custom table -## Gating a door or a stair - -A door or a level transition can carry a [`GateSpec`][osrlib.crawl.gates.GateSpec] on its `requires` field: an authored condition the party must satisfy for the attempt to be legal. The condition is evaluated live, at the moment the party tries the door — never remembered — so a key that gets dropped or sold stops opening it: - -- [`HasItemCondition`][osrlib.crawl.gates.HasItemCondition] — some member's carried inventory holds an item with that catalog id. Any member's pack counts, equipped slots included. The id must resolve against the equipment catalog (bundled items included, see [authoring custom content](../guides/authoring-custom-content.md)) or the magic-item catalog; a gate naming an unknown id fails validation. -- [`FlagEqualsCondition`][osrlib.crawl.gates.FlagEqualsCondition] — a session flag holds a value. Your game sets flags with [`SetFlag`][osrlib.crawl.commands.SetFlag], so this is the lever-opens-the-portcullis wiring. The comparison is strict: an absent key matches nothing, not even `False`, and a stored `True` never satisfies an authored `1`. -- [`EffectActiveCondition`][osrlib.crawl.gates.EffectActiveCondition] — an active effect of that kind is attached to a party member, for a door that wants the talisman invoked rather than merely carried. - -A refused attempt is an ordinary rejection — `exploration.door.gate_refused` or `exploration.transition.gate_refused` — carrying the gate's authored refusal text. It costs nothing: no dice, no game time, no items, and no change to the door. - -```{.python .no-run} -sentinel = GateSpec( - condition=HasItemCondition(item_id="brass_key"), - narrative=NarrativeBlock( - refusal="The bronze sentinel folds its arms. Brass, it says. Brass or nothing.", - success="The brass key turns in the sentinel's palm and the door swings wide.", - ), -) -``` - -Locks and gates are separate layers, and a door that carries both requires both: the lock answers first (`exploration.door.locked`), and once a thief has picked it — [`PickLock`][osrlib.crawl.commands.PickLock] addresses the lock and nothing else — the gate still has its say. A door standing open admits passage unchecked, so setting a gated door open with [`SetDoorState`][osrlib.crawl.commands.SetDoorState] lets the party through until the door closes again, at which point the gate applies once more. Trigger-driven one-time unlocks of that shape arrive in a later release. - -`consumes=True` turns a `has_item` condition into a toll: one instance leaves the first holder in marching order each time the gated command succeeds, reported by [`ItemConsumedEvent`][osrlib.crawl.events.ItemConsumedEvent] just before the door or arrival event. Every success charges again — a consumed key-door that swings shut wants another key. Coins are not items and cannot be tolled; mint a token as a bundled item and gate on that. - -A [`NarrativeBlock`][osrlib.crawl.narrative.NarrativeBlock] holds the authored text for the mechanical object it hangs on. Gates read two of its beats: `refusal`, returned in the rejection, and `success`, which rides the successful command's event — the [`DoorEvent`][osrlib.crawl.events.DoorEvent] for a door, the [`LocationEnteredEvent`][osrlib.crawl.events.LocationEnteredEvent] for a transition that crosses into a new level or dungeon. [`format_message`][osrlib.messages.format_message] appends the beat verbatim, so it shows up in a bare transcript. A transition whose destination is its own level crosses no boundary and emits no arrival event, so a success beat there has nowhere to display. The block's other fields — `journal`, `guidance` for an LLM narrator, `speaker` — are read by the surfaces that consume them; none of them ever reach the player view, which carries no gate wiring at all. - -## Wiring the dungeon with triggers - -A gate asks "may the party do this?" every time it tries. A trigger asks the opposite question, once: "did this just happen?" A [`TriggerSpec`][osrlib.crawl.triggers.TriggerSpec] binds an observable event pattern to referee-command consequences — the lever that opens the portcullis, the idol whose theft wakes the temple, the room whose first crossing writes a line in the party's journal: - -```{.python .no-run} -sentinel_wakes = TriggerSpec( - id="sentinel-wakes", - when=ItemAcquiredPattern(item_id="brass_key"), - consequences=(SetFlag(key="barrow.key_found", value=True),), - narrative=NarrativeBlock( - fired="The sentinel's head turns a few degrees, and stops.", - journal="The brass key is ours. Something in the barrow noticed.", - ), -) -``` - -Triggers are inert content on their own. They play when your game registers the library's [`Interpreter`][osrlib.crawl.interpreter.Interpreter] on the session — once, right after the session is built (and again after loading a save, since listeners are code and a save carries data): - -```{.python .no-run} -session = GameSession.new(Party(members=[hero.character]), adventure, seed=11) -session.register_listener(Interpreter(session)) -``` - -### What a trigger watches - -`when` is one pattern from a small union, each naming an event the engine already emits: - -- [`AreaEnteredPattern`][osrlib.crawl.triggers.AreaEnteredPattern] — the party stepped into a keyed area. Area ids are scoped to their level, so the pattern names the whole triple: `dungeon_id`, `level_number`, `area_id`. -- [`LevelEnteredPattern`][osrlib.crawl.triggers.LevelEnteredPattern] — the party arrived on a level, by stair or by walking in from town. -- [`DungeonEnteredPattern`][osrlib.crawl.triggers.DungeonEnteredPattern] and [`TownEnteredPattern`][osrlib.crawl.triggers.TownEnteredPattern] — the coarser crossings; the town pattern needs no fields, since an adventure has one town. -- [`ItemAcquiredPattern`][osrlib.crawl.triggers.ItemAcquiredPattern] — a member acquired an item with that catalog id, from a cache, a grant, or another member's hands. -- [`MonsterDefeatedPattern`][osrlib.crawl.triggers.MonsterDefeatedPattern] — a monster of that template was defeated: slain, routed, and surrendered all count. Defeats are reported when the battle ends, so "the portcullis opens the instant the boss falls" is not authorable — it opens when the fighting stops. -- [`FlagSetPattern`][osrlib.crawl.triggers.FlagSetPattern] — a flag was written. This is the lever: your game (or another trigger) executes [`SetFlag`][osrlib.crawl.commands.SetFlag], and the trigger watching that key fires. The match is on the value the write carried, and `value=None` matches any value at all. - -`conditions` narrows it further with the same [condition union the gates use](#gating-a-door-or-a-stair) — all of them must hold, evaluated live at the moment of the match, so a trigger can ask "…and only if somebody is still carrying the talisman". One difference from a gate: a trigger's condition may not set `consumes=True`. A trigger reacts to something that has already happened, and there is no attempt of its own to charge a toll against. - -By default a trigger fires once ever, and the fired-mark is session state that survives a save, a load, and a replay. `repeatable=True` opts into firing every time the pattern matches. - -### What a firing does - -The interpreter issues ordinary referee commands, every one of them stamped `source="trigger:{id}"` so the command log answers *why* on its own: - -1. [`MarkTriggerFired`][osrlib.crawl.commands.MarkTriggerFired], first, carrying the `fired` beat. -2. Your `consequences`, in the order you wrote them. -3. [`AddJournalEntry`][osrlib.crawl.commands.AddJournalEntry], last, when the narrative block carries a `journal` form. - -Consequences are drawn from [`ConsequenceCommand`][osrlib.crawl.commands.ConsequenceCommand] — `GrantItem`, `GrantCoins`, `AwardXP`, `SetFlag`, `SpawnMonsters`, `SpawnNpcParty`, `SetDoorState`, `PlaceParty`, `AdvanceTime`. Anything else fails to parse. A consequence that hands something to a character names it with a party selector rather than an id: [`PARTY_SELECTOR`][osrlib.crawl.triggers.PARTY_SELECTOR] (`"@party"`) becomes one command per living member in marching order, and [`FIRST_LIVING_SELECTOR`][osrlib.crawl.triggers.FIRST_LIVING_SELECTOR] (`"@first"`) the lead survivor. Character ids are allocated per session, so a document that named one would be naming something that does not exist when it is read — validation rejects it. - -The two beats have two different audiences, and the rule is worth stating plainly: **`fired` is the referee's line and `journal` is the players'.** The `fired` text rides a referee-visibility event, because content wiring is your game's secret; the journal entry is player-visible and ships verbatim in the [`PlayerView`][osrlib.crawl.views.PlayerView]. If you want the table to read something when a trigger fires, write the journal form. - -### When something doesn't land - -Nothing about a trigger firing is all-or-nothing. A consequence the session rejects — a spawn arriving to find an encounter already open, a grant naming an item the catalog lost — is dropped by itself, the consequences after it still run, and 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: 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, matching stops below depth five, and a firing the bound suppresses is recorded as a note rather than a mark — so a once-only trigger cut short there is still fireable later. Flag-chains are perfectly good wiring; the bound is the guarantee that a loop in them ends. - -## Authoring a quest - -A trigger fires and is done with you. A quest keeps score: it has a state the engine owns, objectives that complete in any order, and an ending. A [`QuestSpec`][osrlib.crawl.quests.QuestSpec] is authored beside the triggers, in the same adventure document, and played by the same [`Interpreter`][osrlib.crawl.interpreter.Interpreter] — you register nothing extra. - -Nothing in the vocabulary is new. Everywhere a quest asks "did this happen?", it asks with a [`TriggerClause`][osrlib.crawl.quests.TriggerClause]: one of the patterns above, plus the conditions that must hold when it matches. The field is `pattern` rather than `when`, so an objective's completion clause reads `objective.when.pattern`. - -Here is a whole quest — the TUI crawler's fetch errand, verbatim from the example: - -```{.python .no-run} ---8<-- "examples/tui_crawler/content.py:fetch-quest" -``` - -### Matching on a thing the party carries - -The idol that quest wants is a bundled item, not a named valuable, and that is deliberate: an acquisition reports mundane items by catalog id, so a bundled id is something [`ItemAcquiredPattern`][osrlib.crawl.triggers.ItemAcquiredPattern] can match and [`HasItemCondition`][osrlib.crawl.gates.HasItemCondition] can test. - -```{.python .no-run} ---8<-- "examples/tui_crawler/content.py:bundled-idol" -``` - -Drop it into a cache by id (`item_ids=("jade-idol",)`) and hand it to `Adventure.items`, and the whole errand becomes matchable: *took it* is a pattern, *still carrying it* is a condition. A `town_entered` clause narrowed by `has_item` is the walked-home-with-it test, and walking home without it simply does not fire. - -### 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 its objectives start watching. Omit it and the quest is active from session start — a standing charge the party carries from round 0. That one has no activation event and no offer entry in the journal, because there is no command channel before the first command; its offer simply stands in the first player view. - -### Hidden objectives and reveals - -`objectives` holds at least one, in the order you write them, and each is an [`ObjectiveSpec`][osrlib.crawl.quests.ObjectiveSpec] with a completion clause of its own. `hidden=True` keeps an objective out of the player view until something surfaces it — either a `reveal_when` clause of its own, or its own completion, because finishing an objective reveals it. A hidden objective with no reveal clause is a normal shape: the party learns about it by doing it. A `reveal_when` on an objective that was never hidden is rejected at parse, being wiring nothing would read. - -### The completion rule and the ending - -`completion` is `"all"` (the default — every objective) or `"any"` (the first one to land, leaving the rest incomplete). The rule is checked the moment an objective completes, and a satisfied rule completes the quest. - -`concludes_adventure=True` marks the quest whose completion ends the adventure: the session clears any open encounter or battle and enters `victory`, a terminal mode where play commands are refused and referee commands still work. That is the one entrance to victory, so author it once, on the quest that is the point of the module. - -### Rewards - -`rewards` are the same [`ConsequenceCommand`][osrlib.crawl.commands.ConsequenceCommand] surface a trigger's consequences use, issued 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 the ordering are worth authoring around. On a concluding quest the session is already in `victory` when the rewards issue, so a reward that would resume play — `SpawnMonsters`, `SpawnNpcParty`, `PlaceParty` — is refused and dropped with a note; grants, awards, and flags land fine. And coin paid on the doorstep earns no treasure XP: the end-of-adventure award has already fired by then, so put the story's thanks in `AwardXP` rather than expecting a purse to convert itself. - -### Which beat goes where - -Quests read four display beats from their narrative blocks, and the mapping is worth keeping straight: - -| Moment | Block | Field | -|---|---|---| -| The quest activates | quest | `offer` | -| A hidden objective is revealed | objective | `offer` | -| An objective completes | objective | `progress` | -| The quest completes | quest | `completion` | - -Each of those beats rides its own player-visible event *and* appends to the journal, as itself — a quest's journal is the transcript of what the table was shown, so quest blocks leave the `journal` field to the carriers whose display beat the players never see (a trigger's `fired`). A quest block's `progress` and an objective block's `completion` are read by nobody; they are silently unread, not rejected. - -### Steering a narrator - -`guidance` on any narrative block is text a narrating front end may steer by and no renderer ever prints. Levels get one of their own for the ambience that hangs on no object at all: - -```{.python .no-run} ---8<-- "examples/tui_crawler/content.py:level-guidance" -``` - -[`LevelSpec.guidance`][osrlib.crawl.dungeon.LevelSpec] is inert authored data: the engine reads it nowhere, no event carries it, and it applies while the party is on the level. Like every other level internal it is referee-side by construction — the player view ships no part of it. - ## The dungeon, the town, and the root The level slots into a [`DungeonSpec`][osrlib.crawl.dungeon.DungeonSpec], and the dungeon into an [`Adventure`][osrlib.crawl.adventure.Adventure] beside the [`TownSpec`][osrlib.crawl.adventure.TownSpec] — the safe base where the party rests, buys equipment, and sells treasure. `travel_turns` maps each dungeon id to the town-to-entrance travel cost in exploration turns: @@ -216,13 +69,10 @@ adventure = Adventure( name="The Barrow of the Knucklebone Goblins", town=town, dungeons=(barrow,), - items=(GearTemplate(id="brass_key", name="Brass key", cost_gp=0),), - triggers=(sentinel_wakes,), - quests=(recover_the_key,), ) ``` -`items` bundles the adventure's own item templates — the brass key the sentinel wants is content, not shipped equipment. See [authoring custom content](../guides/authoring-custom-content.md) for the whole bundling contract. `triggers` is the adventure's wiring and `quests` its errands, and both tuples are document order: when two triggers match the same event they fire in the order you wrote them, and the interpreter walks the triggers of an event before its quests. +The root also carries the adventure's *behavior*: its own item templates, its triggers, and its quests, on the `items`, `triggers`, and `quests` fields — [Gates, triggers, and quests](../guides/gates-triggers-quests.md) teaches all three. ## Validate before play @@ -235,16 +85,15 @@ validate_adventure(adventure, load_monsters(), load_equipment()) ## The complete program -Entering the dungeon and walking east brings the party to the sentinel's door; the brass key opens it, and the cell beyond is the guard post — the goblins spawn, 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; beyond it is the guard post — the goblins spawn, surprise and reaction roll, and the session switches to the encounter: ```python from osrlib.core.alignment import Alignment from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character -from osrlib.core.items import GearTemplate from osrlib.core.rng import RngStreams from osrlib.core.ruleset import Ruleset from osrlib.crawl.adventure import Adventure, TownSpec, validate_adventure -from osrlib.crawl.commands import EnterDungeon, GrantItem, MoveParty, OpenDoor, SessionMode, SetFlag +from osrlib.crawl.commands import EnterDungeon, MoveParty, OpenDoor, SessionMode from osrlib.crawl.dungeon import ( AreaSpec, Direction, @@ -256,23 +105,10 @@ from osrlib.crawl.dungeon import ( KeyedMonster, LevelSpec, ) -from osrlib.crawl.gates import GateSpec, HasItemCondition -from osrlib.crawl.interpreter import Interpreter -from osrlib.crawl.narrative import NarrativeBlock from osrlib.crawl.party import Party -from osrlib.crawl.quests import ObjectiveSpec, QuestSpec, TriggerClause from osrlib.crawl.session import GameSession -from osrlib.crawl.triggers import DungeonEnteredPattern, ItemAcquiredPattern, TriggerSpec from osrlib.data import load_equipment, load_monsters -sentinel = GateSpec( - condition=HasItemCondition(item_id="brass_key"), - narrative=NarrativeBlock( - refusal="The bronze sentinel folds its arms. Brass, it says. Brass or nothing.", - success="The brass key turns in the sentinel's palm and the door swings wide.", - ), -) - # The level: a 4x1 corridor, entered at the west end, with a door at the far end. level = LevelSpec( number=1, @@ -282,7 +118,7 @@ level = LevelSpec( edges={ "1,0:west": Edge(kind=EdgeKind.OPEN), "2,0:west": Edge(kind=EdgeKind.OPEN), - "3,0:west": Edge(kind=EdgeKind.DOOR, door=DoorSpec(requires=sentinel)), + "3,0:west": Edge(kind=EdgeKind.DOOR, door=DoorSpec()), }, areas=( AreaSpec( @@ -295,43 +131,12 @@ level = LevelSpec( ), ) -sentinel_wakes = TriggerSpec( - id="sentinel-wakes", - when=ItemAcquiredPattern(item_id="brass_key"), - consequences=(SetFlag(key="barrow.key_found", value=True),), - narrative=NarrativeBlock( - fired="The sentinel's head turns a few degrees, and stops.", - journal="The brass key is ours. Something in the barrow noticed.", - ), -) - -recover_the_key = QuestSpec( - id="the-key", - name="The Brass Key", - activation=TriggerClause(pattern=DungeonEnteredPattern(dungeon_id="barrow")), - objectives=( - ObjectiveSpec( - id="find-the-key", - when=TriggerClause(pattern=ItemAcquiredPattern(item_id="brass_key")), - narrative=NarrativeBlock(progress="The key came out of the spoil heap, green with age."), - ), - ), - rewards=(SetFlag(key="barrow.errand", value="done"),), - narrative=NarrativeBlock( - offer="Bring the brass key back up, and the sentinel's door is somebody else's problem.", - completion="The key is out of the barrow. The errand is closed.", - ), -) - barrow = DungeonSpec(id="barrow", name="The Barrow", levels=(level,)) town = TownSpec(name="Threshold", travel_turns={"barrow": 2}) adventure = Adventure( name="The Barrow of the Knucklebone Goblins", town=town, dungeons=(barrow,), - items=(GearTemplate(id="brass_key", name="Brass key", cost_gp=0),), - triggers=(sentinel_wakes,), - quests=(recover_the_key,), ) # Validation catches unknown ids and broken geometry before play ever starts. @@ -341,50 +146,13 @@ rules = Ruleset() creation = RngStreams(master_seed=11).get(CHARACTER_CREATION_STREAM) hero = create_character(name="Brakka", class_id="dwarf", alignment=Alignment.LAWFUL, ruleset=rules, stream=creation) session = GameSession.new(Party(members=[hero.character]), adventure, seed=11) -session.register_listener(Interpreter(session)) session.execute(EnterDungeon(dungeon_id="barrow")) -# Crossing the threshold activated the quest, and its offer opened the journal. -assert session.quests["the-key"].status == "active" -assert session.journal[0].text.startswith("Bring the brass key back up") - session.execute(MoveParty(direction=Direction.EAST)) session.execute(MoveParty(direction=Direction.EAST)) -# Keyless, the sentinel's door is an illegal command — and the refusal costs nothing. -refused = session.execute(OpenDoor(direction=Direction.EAST)) -assert not refused.accepted -assert refused.rejections[0].code == "exploration.door.gate_refused" -assert refused.rejections[0].params["refusal"].startswith("The bronze sentinel") - -# The key lands, and everything watching for it reacts inside the same command: -# the trigger first, then the quest, then the quest's reward. -granted = session.execute(GrantItem(character_id="character-0001", item_id="brass_key")) -assert [event.code for event in granted.events] == [ - "exploration.item.acquired", - "session.trigger.fired", - "session.flag.set", - "session.journal.entry_added", - "session.quest.objective_completed", - "session.quest.completed", - "session.flag.set", -] -assert session.fired_triggers == ["sentinel-wakes"] -assert session.flags["barrow.key_found"] is True -assert session.journal[1].text == "The brass key is ours. Something in the barrow noticed." -# One objective, the `all` rule: finishing it finished the quest, and the reward -# landed after the completion. -assert session.quests["the-key"].status == "completed" -assert session.flags["barrow.errand"] == "done" -# Every command a trigger or a quest issued says whose idea it was. -assert {command.source for command in session.command_log if command.source} == { - "trigger:sentinel-wakes", - "quest:the-key", -} - opened = session.execute(OpenDoor(direction=Direction.EAST)) assert opened.accepted -assert opened.events[0].narrative == "The brass key turns in the sentinel's palm and the door swings wide." result = session.execute(MoveParty(direction=Direction.EAST)) assert result.accepted @@ -396,6 +164,7 @@ assert len(session.monsters) == 2 ## Where next +- [Gates, triggers, and quests](../guides/gates-triggers-quests.md) — the authored behavior this dungeon's data can carry: the gated door, the trigger wiring, and the quest that ends the adventure. - The example games ship complete authored adventures worth reading: [the TUI crawler](../front-ends/tui-crawler.md) builds a two-level barrow with a fetch quest, custom wandering tables, and a hand-placed MacGuffin. - [Sessions, commands, and events](../guides/sessions-commands-events.md) — what happens after the encounter starts. - [Authoring custom classes, spells, monsters, and items](../guides/authoring-custom-content.md) — extending the content catalogs themselves. diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index d9be8b0..0b172b4 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -71,7 +71,7 @@ Events carry structured fields and a message code, never baked prose — [`forma ## Save and load -The whole session serializes to a JSON-compatible dict. Loading replays the command log against the same seed, so a loaded game is bit-for-bit the game you saved: +The whole session serializes to a JSON-compatible dict, and loading restores it from that state alone — nothing is re-executed. Replay is the separate [`replay_game`][osrlib.persistence.replay_game] path, which rebuilds the same session by re-running the seed and the command log from scratch; that the two paths always land in the identical state is the determinism guarantee (see [Determinism, saves, and replay](../guides/determinism-saves-replay.md)): ```{.python .no-run} # The whole session round-trips through JSON: same seed, same commands, same game. @@ -130,7 +130,8 @@ assert save_game(restored) == document ## Where next -- [Building an adventure](building-an-adventure.md) — the dungeon geometry and content models, one at a time. +- [Building an adventure](building-an-adventure.md) — the dungeon itself: the grid and its edges, keyed areas, and the content that binds to them. +- [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. - [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 loading works. +- [Determinism, saves, and replay](../guides/determinism-saves-replay.md) — what the seed guarantees and how saves and replay meet in the middle. - [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 d8d7f83..0f1255d 100644 --- a/docs/guides/authoring-custom-content.md +++ b/docs/guides/authoring-custom-content.md @@ -11,7 +11,10 @@ ids for exactly this reason — nothing in the kernel restricts them to the valu happen to use. This page builds one small custom class and one custom spell for it ([the complete program](#the-complete-program) runs every step shown along the way), then [a custom monster bundled into an adventure](#bundling-custom-monsters-with-an-adventure) and [the items an adventure carries -with it](#bundling-custom-items-with-an-adventure) for the crawl layer. +with it](#bundling-custom-items-with-an-adventure) for the crawl layer. Its scope is the content +catalogs — the *things* a game and its adventures can contain. Authored *behavior* — the gated door, +the trigger, the quest — is a different surface, taught in +[Gates, triggers, and quests](gates-triggers-quests.md). ## The shape of a class definition @@ -715,8 +718,10 @@ this page named. ## Where next -- [Building an adventure](../getting-started/building-an-adventure.md) — validating monster and - equipment ids the same catalog-driven way, for the crawl layer instead of a character sheet. +- [Building an adventure](../getting-started/building-an-adventure.md) — the dungeon geometry and + keyed content the bundled monsters and items above bind into. +- [Gates, triggers, and quests](gates-triggers-quests.md) — authored behavior: the gate that wants + a bundled key, the trigger that matches a bundled id, the quest that ends the adventure. - [Sessions, commands, and events](sessions-commands-events.md) — running a character, custom class or not, through an actual session once it exists. - [The API reference](../reference/api/index.md) — the full model and function reference for everything diff --git a/docs/guides/determinism-saves-replay.md b/docs/guides/determinism-saves-replay.md index 5dd0a92..23e51e0 100644 --- a/docs/guides/determinism-saves-replay.md +++ b/docs/guides/determinism-saves-replay.md @@ -1,5 +1,7 @@ # Determinism, saves, and replay +You want a bug report you can reproduce, a golden test that never flakes, and a save +you can trust. osrlib's central promise is that a game is a pure function of its seed and its command sequence: every random draw comes from a named [`RngStream`][osrlib.core.rng.RngStream] forked from a session's master seed, so **the same seed, the same sequence of commands, and the same @@ -31,8 +33,13 @@ assert save_game(session_a) == save_game(session_b) [`save_game`][osrlib.persistence.save_game] serializes a running [`GameSession`][osrlib.crawl.session.GameSession] to a JSON-compatible dict: the party, the embedded adventure content, dungeon state, the clock, every exported RNG stream position, the -master seed, the accepted-command log, and — unless called with `include_event_log=False` — the -event log. [`load_game`][osrlib.persistence.load_game] reconstructs a session from that dict by +master seed, the session-state blocks the extension and authored layers write — the flag store, +each registered listener's state slot, the trigger fired-marks, the journal, and quest state +(see [Listeners and flags](listeners-and-flags.md) and +[Gates, triggers, and quests](gates-triggers-quests.md)) — the accepted-command log, and — +unless called with `include_event_log=False` — the event log. So the answer to "does my +authored progress survive?" is yes, all of it, by construction. +[`load_game`][osrlib.persistence.load_game] reconstructs a session from that dict by restoring each piece exactly, RNG stream positions included, so a loaded game continues drawing from precisely where the saved game left off: @@ -53,7 +60,8 @@ is safe to compact a save with — state reconstructs exactly whether the log ri adventure, and the accepted-command log, and re-executes every command from scratch through a fresh session — no saved state at all. It raises [`ReplayVersionError`][osrlib.errors.ReplayVersionError] when the log's recorded engine version -doesn't match the running engine, and +doesn't match the running engine — a check that runs only when the caller passes the version a +save recorded, through the `recorded_engine_version` argument — and [`ContentValidationError`][osrlib.errors.ContentValidationError] if a logged command is rejected on replay — a divergence, since the log holds only commands that were accepted the first time. @@ -83,6 +91,23 @@ assert save_game(replayed, include_event_log=False) == save_game(session_a, incl ever joined a session — because [`GameSession.new`][osrlib.crawl.session.GameSession.new] assigns member ids itself, in party order, the same way both times. +### Replay runs with no listeners + +`replay_game` builds its session with **no listeners registered**, and that is sufficient: +every reaction a listener issued live — the interpreter's trigger consequences, a game +listener's awards — was an ordinary command, accepted and logged, so re-executing the log +rebuilds its every effect. The commands are already there; nothing needs to react again. + +The corollary is worth scoping precisely, because the two halves point in opposite +directions. After [`load_game`][osrlib.persistence.load_game], re-register your listeners +before executing *new* commands — a restored session that will keep playing needs its code +attached again. During a *replay*, the rule splits by what the listener does: one that only +observes — accumulating `listener_state`, returning annotation events — may be registered and +reproduces its state exactly, but one that reacts by **issuing commands** — the +[`Interpreter`][osrlib.crawl.interpreter.Interpreter] above all — must not be, because the log +already carries every command it issued live, and a second issuer would issue them again and +diverge from the recorded game. + ## Schema versions and migrations This page is the documented home of [`osrlib.versioning`][osrlib.versioning]. Every serialized @@ -90,7 +115,7 @@ document — saves, commands, events — is wrapped in an envelope carrying a `k `schema_version`, and an `engine_version`, produced by [`stamp_document`][osrlib.versioning.stamp_document] and read back by [`check_document`][osrlib.versioning.check_document]. -[`SCHEMA_VERSION`][osrlib.versioning.SCHEMA_VERSION] is currently `2`, and it's one integer +[`SCHEMA_VERSION`][osrlib.versioning.SCHEMA_VERSION] is currently `3`, and it's one integer shared by every document kind, independent of the package's own release version. The promise a schema version makes is additive-only: within one version, only new event types @@ -98,10 +123,12 @@ and new optional fields are allowed to appear. Anything else — a rename, a rem 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 touching it, so a document stamped at an -older schema version still loads. Version 1's single migration is concrete: it drops a +older schema version still loads. Both shipped migrations are concrete. The 1 → 2 step drops a `recovered_treasure` field the version-2 payload no longer carries, and adds the empty `npcs` -list that arrived with version 2. A document saved back at the current floor, schema version 1, -loads the same way a fresh one does: +list that arrived with version 2. The 2 → 3 step 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. 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 -- @@ -207,8 +234,8 @@ assert migrated.npcs == {} ## Where next -- [The kernel à la carte](kernel-a-la-carte.md) — the streams and kernel functions this - determinism contract is built from. +- [Using the rules without a session](rules-without-a-session.md) — the streams and kernel + functions this determinism contract is built from. - [RNG streams](../reference/rng-streams.md) — every named stream and what it governs. - [Sessions, commands, and events](sessions-commands-events.md) — the command loop that produces the command log this page replays. diff --git a/docs/guides/gates-triggers-quests.md b/docs/guides/gates-triggers-quests.md new file mode 100644 index 0000000..e8153a8 --- /dev/null +++ b/docs/guides/gates-triggers-quests.md @@ -0,0 +1,324 @@ +# 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. osrlib authors all three as data: a **gate** guards an attempt, a **trigger** reacts to an event, a **quest** keeps score toward an ending, and 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 exactly like the ones [Listeners and flags](listeners-and-flags.md) taught, shipped because every authored adventure wants one. This page teaches all three surfaces and the interpreter that runs them. [The complete program](#the-complete-program) at the end runs as written; every fragment along the way is an excerpt of it, except where a fragment excerpts [the TUI crawler's](../front-ends/tui-crawler.md) authored adventure and says so. + +The door itself — the edge, the [`DoorSpec`][osrlib.crawl.dungeon.DoorSpec] — is dungeon geometry, taught in [Building an adventure](../getting-started/building-an-adventure.md#the-grid-and-its-edges) along with the keyed areas and transitions this page hangs conditions on. + +## Gating a door or a stair + +A door or a level transition can carry a [`GateSpec`][osrlib.crawl.gates.GateSpec] on its `requires` field: an authored condition the party must satisfy for the attempt to be legal. The condition is evaluated live, at the moment the party tries the door — never remembered — so a key that gets dropped or sold stops opening it: + +- [`HasItemCondition`][osrlib.crawl.gates.HasItemCondition] — some member's carried inventory holds an item with that catalog id. Any member's pack counts, equipped slots included. The id must resolve against the equipment catalog (bundled items included, see [authoring custom content](authoring-custom-content.md)) or the magic-item catalog; a gate naming an unknown id fails validation. +- [`FlagEqualsCondition`][osrlib.crawl.gates.FlagEqualsCondition] — a session flag holds a value. Your game sets flags with [`SetFlag`][osrlib.crawl.commands.SetFlag], so this is the lever-opens-the-portcullis wiring. The comparison is strict: an absent key matches nothing, not even `False`, and a stored `True` never satisfies an authored `1`. +- [`EffectActiveCondition`][osrlib.crawl.gates.EffectActiveCondition] — an active effect of that kind is attached to a party member, for a door that wants the talisman invoked rather than merely carried. + +A refused attempt is an ordinary rejection — `exploration.door.gate_refused` or `exploration.transition.gate_refused` — carrying the gate's authored refusal text. It costs nothing: no dice, no game time, no items, and no change to the door. + +```{.python .no-run} +sentinel = GateSpec( + condition=HasItemCondition(item_id="brass_key"), + narrative=NarrativeBlock( + refusal="The bronze sentinel folds its arms. Brass, it says. Brass or nothing.", + success="The brass key turns in the sentinel's palm and the door swings wide.", + ), +) +``` + +Locks and gates are separate layers, and a door that carries both requires both: the lock answers first (`exploration.door.locked`), and once a thief has picked it — [`PickLock`][osrlib.crawl.commands.PickLock] addresses the lock and nothing else — the gate still has its say. A door standing open admits passage unchecked, so setting a gated door open with [`SetDoorState`][osrlib.crawl.commands.SetDoorState] lets the party through until the door closes again, at which point the gate applies once more. A one-time unlock that flips a door's state for good — the lever thrown once, the portcullis that stays up — is exactly a [trigger's](#wiring-the-dungeon-with-triggers) job: a `SetDoorState` consequence fired on the lever's flag. + +`consumes=True` turns a `has_item` condition into a toll: one instance leaves the first holder in marching order each time the gated command succeeds, reported by [`ItemConsumedEvent`][osrlib.crawl.events.ItemConsumedEvent] just before the door or arrival event. Every success charges again — a consumed key-door that swings shut wants another key. Coins are not items and cannot be tolled; mint a token as a bundled item and gate on that. + +A [`NarrativeBlock`][osrlib.crawl.narrative.NarrativeBlock] holds the authored text for the mechanical object it hangs on. Gates read two of its beats: `refusal`, returned in the rejection, and `success`, which rides the successful command's event — the [`DoorEvent`][osrlib.crawl.events.DoorEvent] for a door, the [`LocationEnteredEvent`][osrlib.crawl.events.LocationEnteredEvent] for a transition that crosses into a new level or dungeon. [`format_message`][osrlib.messages.format_message] appends the beat verbatim, so it shows up in a bare transcript. A transition whose destination is its own level crosses no boundary and emits no arrival event, so a success beat there has nowhere to display. The block's other fields — `journal`, `guidance` for an LLM narrator, `speaker` — are read by the surfaces that consume them, and a gate's `journal` beat has no consumer at all: journaling a door is [a trigger's](#wiring-the-dungeon-with-triggers) job. None of them ever reach the player view, which carries no gate wiring at all. + +## Wiring the dungeon with triggers + +A gate asks "may the party do this?" every time it tries. A trigger asks the opposite question, once: "did this just happen?" A [`TriggerSpec`][osrlib.crawl.triggers.TriggerSpec] binds an observable event pattern to referee-command consequences — the lever that opens the portcullis, the idol whose theft wakes the temple, the room whose first crossing writes a line in the party's journal: + +```{.python .no-run} +sentinel_wakes = TriggerSpec( + id="sentinel-wakes", + when=ItemAcquiredPattern(item_id="brass_key"), + consequences=(SetFlag(key="barrow.key_found", value=True),), + narrative=NarrativeBlock( + fired="The sentinel's head turns a few degrees, and stops.", + journal="The brass key is ours. Something in the barrow noticed.", + ), +) +``` + +Triggers ride the adventure document alongside the content they wire. `Adventure.items` bundles the adventure's own item templates — the brass key the sentinel wants is content, not shipped equipment (see [authoring custom content](authoring-custom-content.md) for the whole bundling contract). `Adventure.triggers` is the adventure's wiring, and the tuple is document order: when two triggers match the same event, they fire in the order you wrote them. + +Triggers are inert content on their own. They play when your game registers the library's [`Interpreter`][osrlib.crawl.interpreter.Interpreter] on the session — once, right after the session is built (and again after loading a save, since listeners are code and a save carries data): + +```{.python .no-run} +session = GameSession.new(Party(members=[hero.character]), adventure, seed=11) +session.register_listener(Interpreter(session)) +``` + +### What a trigger watches + +`when` is one pattern from a small union, each naming an event the engine already emits: + +- [`AreaEnteredPattern`][osrlib.crawl.triggers.AreaEnteredPattern] — the party stepped into a keyed area. Area ids are scoped to their level, so the pattern names the whole triple: `dungeon_id`, `level_number`, `area_id`. +- [`LevelEnteredPattern`][osrlib.crawl.triggers.LevelEnteredPattern] — the party arrived on a level, by stair or by walking in from town. +- [`DungeonEnteredPattern`][osrlib.crawl.triggers.DungeonEnteredPattern] and [`TownEnteredPattern`][osrlib.crawl.triggers.TownEnteredPattern] — the coarser crossings; the town pattern needs no fields, since an adventure has one town. +- [`ItemAcquiredPattern`][osrlib.crawl.triggers.ItemAcquiredPattern] — a member acquired an item with that catalog id, from a cache, a grant, or another member's hands. +- [`MonsterDefeatedPattern`][osrlib.crawl.triggers.MonsterDefeatedPattern] — a monster of that template was defeated: slain, routed, and surrendered all count. Defeats are reported when the battle ends, so "the portcullis opens the instant the boss falls" is not authorable — it opens when the fighting stops. +- [`FlagSetPattern`][osrlib.crawl.triggers.FlagSetPattern] — a flag was written. This is the lever: your game (or another trigger) executes [`SetFlag`][osrlib.crawl.commands.SetFlag], and the trigger watching that key fires. The match is on the value the write carried, and `value=None` matches any value at all. + +`conditions` narrows it further with the same [condition union the gates use](#gating-a-door-or-a-stair) — all of them must hold, evaluated live at the moment of the match, so a trigger can ask "…and only if somebody is still carrying the talisman". One difference from a gate: a trigger's condition may not set `consumes=True`. A trigger reacts to something that has already happened, and there is no attempt of its own to charge a toll against. + +By default a trigger fires once ever, and the fired-mark is session state that survives a save, a load, and a replay. `repeatable=True` opts into firing every time the pattern matches. + +### What a firing does + +The interpreter issues ordinary referee commands, every one of them stamped `source="trigger:{id}"` so the command log answers *why* on its own: + +1. [`MarkTriggerFired`][osrlib.crawl.commands.MarkTriggerFired], first, carrying the `fired` beat. +2. Your `consequences`, in the order you wrote them. +3. [`AddJournalEntry`][osrlib.crawl.commands.AddJournalEntry], last, when the narrative block carries a `journal` form. + +Consequences are drawn from [`ConsequenceCommand`][osrlib.crawl.commands.ConsequenceCommand] — `GrantItem`, `GrantCoins`, `AwardXP`, `SetFlag`, `SpawnMonsters`, `SpawnNpcParty`, `SetDoorState`, `PlaceParty`, `AdvanceTime`. Anything else fails to parse. A consequence that hands something to a character names it with a party selector rather than an id: [`PARTY_SELECTOR`][osrlib.crawl.triggers.PARTY_SELECTOR] (`"@party"`) becomes one command per living member in marching order, and [`FIRST_LIVING_SELECTOR`][osrlib.crawl.triggers.FIRST_LIVING_SELECTOR] (`"@first"`) the lead survivor. Character ids are allocated per session, so a document that named one would be naming something that does not exist when it is read — validation rejects it. + +The two beats have two different audiences, and the rule is worth stating plainly: **`fired` is the referee's line and `journal` is the players'.** The `fired` text rides a referee-visibility event, because content wiring is your game's secret; the journal entry is player-visible and ships verbatim in the [`PlayerView`][osrlib.crawl.views.PlayerView]. If you want the table to read something when a trigger fires, write the journal form. + +### When something doesn't land + +Nothing about a trigger firing is all-or-nothing. A consequence the session rejects — a spawn arriving to find an encounter already open, a grant naming an item the catalog lost — is dropped by itself, the consequences after it still run, and 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: 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, matching stops below depth five, and a firing the bound suppresses is recorded as a note rather than a mark — so a once-only trigger cut short there is still fireable later. Flag-chains are perfectly good wiring; the bound is the guarantee that a loop in them ends. + +## Authoring a quest + +A trigger fires and is done with you. A quest keeps score: it has a state the engine owns, objectives that complete in any order, and an ending. A [`QuestSpec`][osrlib.crawl.quests.QuestSpec] is authored beside the triggers, in the same adventure document, and played by the same [`Interpreter`][osrlib.crawl.interpreter.Interpreter] — you register nothing extra. `Adventure.quests` is document order too, and the interpreter walks an event's triggers before its quests, so a trigger's consequences have already landed by the time a quest's clauses are asked about the same event. + +Nothing in the vocabulary is new. Everywhere a quest asks "did this happen?", it asks with a [`TriggerClause`][osrlib.crawl.quests.TriggerClause]: one of the patterns above, plus the conditions that must hold when it matches. The field is `pattern` rather than `when`, so an objective's completion clause reads `objective.when.pattern`. + +Here is a whole quest — the TUI crawler's fetch errand, verbatim from the example: + +```{.python .no-run} +--8<-- "examples/tui_crawler/content.py:fetch-quest" +``` + +### Matching on a thing the party carries + +The idol that quest wants is a bundled item, not a named valuable, and that is deliberate: an acquisition reports mundane items by catalog id, so a bundled id is something [`ItemAcquiredPattern`][osrlib.crawl.triggers.ItemAcquiredPattern] can match and [`HasItemCondition`][osrlib.crawl.gates.HasItemCondition] can test. + +```{.python .no-run} +--8<-- "examples/tui_crawler/content.py:bundled-idol" +``` + +The `cost_gp=0` on the idol — and on the brass key in [the complete program](#the-complete-program) — is not a bargain: a bundled item's price is moot, because the town shop stocks the shipped lists only and refuses a bundled id with `items.purchase.not_stocked` (see [the bundling contract](authoring-custom-content.md#bundling-custom-items-with-an-adventure)). + +Drop it into a cache by id (`item_ids=("jade-idol",)`) and hand it to `Adventure.items`, and the whole errand becomes matchable: *took it* is a pattern, *still carrying it* is a condition. A `town_entered` clause narrowed by `has_item` is the walked-home-with-it test, and walking home without it simply does not fire. + +### 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 its objectives start watching. Omit it and the quest is active from session start — a standing charge the party carries from round 0. That one has no activation event and no offer entry in the journal, because there is no command channel before the first command; its offer simply stands in the first player view. + +### Hidden objectives and reveals + +`objectives` holds at least one, in the order you write them, and each is an [`ObjectiveSpec`][osrlib.crawl.quests.ObjectiveSpec] with a completion clause of its own. `hidden=True` keeps an objective out of the player view until something surfaces it — either a `reveal_when` clause of its own, or its own completion, because finishing an objective reveals it. A hidden objective with no reveal clause is a normal shape: the party learns about it by doing it. A `reveal_when` on an objective that was never hidden is rejected at parse, being wiring nothing would read. + +### The completion rule and the ending + +`completion` is `"all"` (the default — every objective) or `"any"` (the first one to land, leaving the rest incomplete). The rule is checked the moment an objective completes, and a satisfied rule completes the quest. + +`concludes_adventure=True` marks the quest whose completion ends the adventure: the session clears any open encounter or battle and enters `victory`, a terminal mode where play commands are refused and referee commands still work. That is the one entrance to victory, so author it once, on the quest that is the point of the module. The transition is reported by [`AdventureCompletedEvent`][osrlib.crawl.events.AdventureCompletedEvent] (`session.adventure.completed`) — the event a front end watches for its victory screen, carrying the concluding quest's completion beat. + +### Rewards + +`rewards` are the same [`ConsequenceCommand`][osrlib.crawl.commands.ConsequenceCommand] surface a trigger's consequences use, issued 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 the ordering are worth authoring around. On a concluding quest the session is already in `victory` when the rewards issue, so a reward that would resume play — `SpawnMonsters`, `SpawnNpcParty`, `PlaceParty` — is refused and dropped with a note; grants, awards, and flags land fine. And coin paid on the doorstep earns no treasure XP: the end-of-adventure award has already fired by then, so put the story's thanks in `AwardXP` rather than expecting a purse to convert itself. + +### Which beat goes where + +Quests read four display beats from their narrative blocks, and the mapping is worth keeping straight: + +| Moment | Block | Field | +|---|---|---| +| The quest activates | quest | `offer` | +| A hidden objective is revealed | objective | `offer` | +| An objective completes | objective | `progress` | +| The quest completes | quest | `completion` | + +Each of those beats rides its own player-visible event *and* appends to the journal, as itself — a quest's journal is the transcript of what the table was shown, so quest blocks leave the `journal` field to the carriers whose display beat the players never see (a trigger's `fired`). A quest block's `progress` and an objective block's `completion` are read by nobody; they are silently unread, not rejected. + +### Steering a narrator + +`guidance` on any narrative block is text a narrating front end may steer by and no renderer ever prints. Levels get one of their own for the ambience that hangs on no object at all: + +```{.python .no-run} +--8<-- "examples/tui_crawler/content.py:level-guidance" +``` + +[`LevelSpec.guidance`][osrlib.crawl.dungeon.LevelSpec] is inert authored data: the engine reads it nowhere, no event carries it, and it applies while the party is on the level. Like every other level internal it is referee-side by construction — the player view ships no part of it. + +## The complete program + +One corridor carries all three mechanisms. Entering the dungeon activates the quest; the brass key's arrival fires the trigger and completes the quest; the gated door refuses the keyless party and opens for the key: + +```python +from osrlib.core.alignment import Alignment +from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character +from osrlib.core.items import GearTemplate +from osrlib.core.rng import RngStreams +from osrlib.core.ruleset import Ruleset +from osrlib.crawl.adventure import Adventure, TownSpec, validate_adventure +from osrlib.crawl.commands import EnterDungeon, GrantItem, MoveParty, OpenDoor, SessionMode, SetFlag +from osrlib.crawl.dungeon import ( + AreaSpec, + Direction, + DoorSpec, + DungeonSpec, + Edge, + EdgeKind, + KeyedEncounter, + KeyedMonster, + LevelSpec, +) +from osrlib.crawl.gates import GateSpec, HasItemCondition +from osrlib.crawl.interpreter import Interpreter +from osrlib.crawl.narrative import NarrativeBlock +from osrlib.crawl.party import Party +from osrlib.crawl.quests import ObjectiveSpec, QuestSpec, TriggerClause +from osrlib.crawl.session import GameSession +from osrlib.crawl.triggers import DungeonEnteredPattern, ItemAcquiredPattern, TriggerSpec +from osrlib.data import load_equipment, load_monsters + +sentinel = GateSpec( + condition=HasItemCondition(item_id="brass_key"), + narrative=NarrativeBlock( + refusal="The bronze sentinel folds its arms. Brass, it says. Brass or nothing.", + success="The brass key turns in the sentinel's palm and the door swings wide.", + ), +) + +# The level: a 4x1 corridor, entered at the west end, with the gated door at the far end. +level = LevelSpec( + number=1, + width=4, + height=1, + entrance=(0, 0), + edges={ + "1,0:west": Edge(kind=EdgeKind.OPEN), + "2,0:west": Edge(kind=EdgeKind.OPEN), + "3,0:west": Edge(kind=EdgeKind.DOOR, door=DoorSpec(requires=sentinel)), + }, + areas=( + AreaSpec( + id="guard_post", + name="Guard post", + description="Two goblins crouch over a game of knucklebones.", + cells=((3, 0),), + encounter=KeyedEncounter(monsters=(KeyedMonster(template_id="goblin", count_fixed=2),)), + ), + ), +) + +sentinel_wakes = TriggerSpec( + id="sentinel-wakes", + when=ItemAcquiredPattern(item_id="brass_key"), + consequences=(SetFlag(key="barrow.key_found", value=True),), + narrative=NarrativeBlock( + fired="The sentinel's head turns a few degrees, and stops.", + journal="The brass key is ours. Something in the barrow noticed.", + ), +) + +recover_the_key = QuestSpec( + id="the-key", + name="The Brass Key", + activation=TriggerClause(pattern=DungeonEnteredPattern(dungeon_id="barrow")), + objectives=( + ObjectiveSpec( + id="find-the-key", + when=TriggerClause(pattern=ItemAcquiredPattern(item_id="brass_key")), + narrative=NarrativeBlock(progress="The key came out of the spoil heap, green with age."), + ), + ), + rewards=(SetFlag(key="barrow.errand", value="done"),), + narrative=NarrativeBlock( + offer="Bring the brass key back up, and the sentinel's door is somebody else's problem.", + completion="The key is out of the barrow. The errand is closed.", + ), +) + +barrow = DungeonSpec(id="barrow", name="The Barrow", levels=(level,)) +town = TownSpec(name="Threshold", travel_turns={"barrow": 2}) +adventure = Adventure( + name="The Barrow of the Knucklebone Goblins", + town=town, + dungeons=(barrow,), + items=(GearTemplate(id="brass_key", name="Brass key", cost_gp=0),), + triggers=(sentinel_wakes,), + quests=(recover_the_key,), +) + +# Validation catches unknown ids and broken geometry before play ever starts. +validate_adventure(adventure, load_monsters(), load_equipment()) + +rules = Ruleset() +creation = RngStreams(master_seed=11).get(CHARACTER_CREATION_STREAM) +hero = create_character(name="Brakka", class_id="dwarf", alignment=Alignment.LAWFUL, ruleset=rules, stream=creation) +session = GameSession.new(Party(members=[hero.character]), adventure, seed=11) +session.register_listener(Interpreter(session)) + +session.execute(EnterDungeon(dungeon_id="barrow")) +# Crossing the threshold activated the quest, and its offer opened the journal. +assert session.quests["the-key"].status == "active" +assert session.journal[0].text.startswith("Bring the brass key back up") + +session.execute(MoveParty(direction=Direction.EAST)) +session.execute(MoveParty(direction=Direction.EAST)) + +# Keyless, the sentinel's door is an illegal command — and the refusal costs nothing. +refused = session.execute(OpenDoor(direction=Direction.EAST)) +assert not refused.accepted +assert refused.rejections[0].code == "exploration.door.gate_refused" +assert refused.rejections[0].params["refusal"].startswith("The bronze sentinel") + +# The key lands, and everything watching for it reacts inside the same command: +# the trigger first, then the quest, then the quest's reward. +granted = session.execute(GrantItem(character_id="character-0001", item_id="brass_key")) +assert [event.code for event in granted.events] == [ + "exploration.item.acquired", + "session.trigger.fired", + "session.flag.set", + "session.journal.entry_added", + "session.quest.objective_completed", + "session.quest.completed", + "session.flag.set", +] +assert session.fired_triggers == ["sentinel-wakes"] +assert session.flags["barrow.key_found"] is True +assert session.journal[1].text == "The brass key is ours. Something in the barrow noticed." +# One objective, the `all` rule: finishing it finished the quest, and the reward +# landed after the completion. +assert session.quests["the-key"].status == "completed" +assert session.flags["barrow.errand"] == "done" +# Every command a trigger or a quest issued says whose idea it was. +assert {command.source for command in session.command_log if command.source} == { + "trigger:sentinel-wakes", + "quest:the-key", +} + +opened = session.execute(OpenDoor(direction=Direction.EAST)) +assert opened.accepted +assert opened.events[0].narrative == "The brass key turns in the sentinel's palm and the door swings wide." + +result = session.execute(MoveParty(direction=Direction.EAST)) +assert result.accepted + +# Stepping into the keyed area spawns the goblins and starts an encounter. +assert session.mode is SessionMode.ENCOUNTER +assert len(session.monsters) == 2 +``` + +## Where next + +- [The TUI crawler](../front-ends/tui-crawler.md) — the fetch quest this page excerpts, in its full adventure context: a two-level barrow, a concluding quest, and the victory ending. +- [Sessions, commands, and events](sessions-commands-events.md) — the lifecycle commands the interpreter issues, and the victory mode a concluding quest enters. +- [Determinism, saves, and replay](determinism-saves-replay.md) — how fired-marks, the journal, and quest state survive a save and rebuild under replay. +- [Views and visibility](views-and-visibility.md) — what a quest projects into the player view, and what stays the game's secret. diff --git a/docs/guides/listeners-and-flags.md b/docs/guides/listeners-and-flags.md index a443997..cd03440 100644 --- a/docs/guides/listeners-and-flags.md +++ b/docs/guides/listeners-and-flags.md @@ -141,7 +141,7 @@ session.register_listener(Interpreter(session)) ``` From then on it watches every command's events, matches them against the adventure's authored -[triggers](../getting-started/building-an-adventure.md#wiring-the-dungeon-with-triggers) and its +[triggers](gates-triggers-quests.md#wiring-the-dungeon-with-triggers) and its [`QuestSpec`][osrlib.crawl.quests.QuestSpec]s, and reacts the only way a listener may: by executing referee commands, each stamped `source="trigger:{id}"` or `source="quest:{id}"`. Three properties are worth copying into your own listeners: @@ -157,16 +157,19 @@ properties are worth copying into your own listeners: condition can look unsatisfied from inside its own reaction. The interpreter instead records the fired-mark *before* running a trigger's consequences, so a consequence that re-matches its own trigger finds it already fired; re-entrant self-invocation is how one trigger's consequences fire - the next, and a depth bound rather than a latch is what stops a cascade. + the next, and a depth bound rather than a latch is what stops a cascade (see + [When something doesn't land](gates-triggers-quests.md#when-something-doesnt-land)). ## A fetch quest, worked Most fetch quests belong in the adventure document, where [`QuestSpec`][osrlib.crawl.quests.QuestSpec] says what to fetch and the interpreter above plays -it — the TUI crawler's Jade Idol is authored exactly that way (see +it — [Gates, triggers, and quests](gates-triggers-quests.md#authoring-a-quest) teaches that +surface, and the TUI crawler's Jade Idol is authored exactly that way (see [the complete front end](../front-ends/tui-crawler.md)). But the same errand is a fair worked example of the game-owned pattern, because everything a quest needs is on this page's surface: a -listener that watches events, keeps its own objective state, and acts through commands. +listener that watches events, keeps its own objective state, and acts through commands. The +[complete program](#the-complete-program) below carries this listener whole and runs it. ```{.python .no-run} class FetchQuestListener: @@ -191,9 +194,7 @@ class FetchQuestListener: acquired = any(isinstance(event, ItemAcquiredEvent) for event in events) if acquired and not state.get("recovered") and self._carrier() is not None: state["recovered"] = True - home = any( - isinstance(event, LocationEnteredEvent) and event.location_kind == "town" for event in events - ) + home = any(isinstance(event, LocationEnteredEvent) and event.location_kind == "town" for event in events) if home and state.get("recovered") and not state.get("completed"): state["completed"] = True self._reacting = True @@ -227,8 +228,11 @@ the objective, and for [`QuestSpec`][osrlib.crawl.quests.QuestSpec] when the adv ## The complete program -A minimal listener that counts party moves, exercised against a couple of commands (one of them -rejected), plus a flag set and read back two ways: +Three listeners on one small session: the move counter from the top of the page, the fetch +quest worked above (exercised end to end — the idol acquired, the walk home, the flag and the +XP landing as commands), and the library's interpreter, registered beside them — legal and +inert here, since this adventure authors no triggers or quests. Plus a flag set and read back +two ways, and the lifecycle vocabulary: ```python from collections.abc import Sequence @@ -236,19 +240,24 @@ from collections.abc import Sequence from osrlib.core.alignment import Alignment from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character from osrlib.core.events import Event, Visibility +from osrlib.core.items import GearTemplate from osrlib.core.rng import RngStreams from osrlib.core.ruleset import Ruleset from osrlib.crawl.adventure import Adventure, TownSpec from osrlib.crawl.commands import ( AddJournalEntry, + AwardXP, EnterDungeon, + GrantItem, MarkTriggerFired, MoveParty, RecordNote, SetFlag, + TravelToTown, ) from osrlib.crawl.dungeon import Direction, DungeonSpec, Edge, EdgeKind, LevelSpec -from osrlib.crawl.events import PartyMovedEvent +from osrlib.crawl.events import ItemAcquiredEvent, LocationEnteredEvent, PartyMovedEvent +from osrlib.crawl.interpreter import Interpreter from osrlib.crawl.party import Party from osrlib.crawl.session import GameSession @@ -265,14 +274,55 @@ class MoveCounter: return [], state -# The quickstart's one-corridor crypt: two cells joined west-east. +class FetchQuestListener: + """Recover an item and bring it home — a quest tracker as a listener.""" + + key = "fetch_quest" + + def __init__(self, session) -> None: + self._session = session + self._reacting = False + + def _carrier(self): + for member in self._session.party.members: + if member.inventory.carried_item("jade-idol") is not None: + return member + return None + + def handle(self, events: Sequence[Event], state: dict) -> tuple[list[Event], dict]: + if self._reacting: + return [], state + state = dict(state) + acquired = any(isinstance(event, ItemAcquiredEvent) for event in events) + if acquired and not state.get("recovered") and self._carrier() is not None: + state["recovered"] = True + home = any(isinstance(event, LocationEnteredEvent) and event.location_kind == "town" for event in events) + if home and state.get("recovered") and not state.get("completed"): + state["completed"] = True + self._reacting = True + try: + self._session.execute(SetFlag(key="quest.idol", value="recovered")) + for member in self._session.party.living_members(): + self._session.execute(AwardXP(character_id=member.id, amount=1200)) + finally: + self._reacting = False + return [], state + + +# The quickstart's one-corridor crypt, plus the idol the fetch quest wants: a +# bundled item, so acquiring it reports a catalog id the listener can look for. crypt = DungeonSpec( id="crypt", name="The Old Crypt", levels=(LevelSpec(number=1, width=2, height=1, entrance=(0, 0), edges={"1,0:west": Edge(kind=EdgeKind.OPEN)}),), ) town = TownSpec(name="Threshold", travel_turns={"crypt": 1}) -adventure = Adventure(name="A First Delve", town=town, dungeons=(crypt,)) +adventure = Adventure( + name="A First Delve", + town=town, + dungeons=(crypt,), + items=(GearTemplate(id="jade-idol", name="Jade idol", cost_gp=0),), +) rules = Ruleset() creation = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM) @@ -281,6 +331,8 @@ party = Party(members=[fighter.character]) session = GameSession.new(party, adventure, seed=7) session.register_listener(MoveCounter()) +session.register_listener(FetchQuestListener(session)) +session.register_listener(Interpreter(session)) session.execute(EnterDungeon(dungeon_id="crypt")) session.execute(MoveParty(direction=Direction.EAST)) @@ -312,6 +364,19 @@ assert session.fired_triggers == ["crypt.lever"] assert [entry.text for entry in session.journal] == ["The lever grinds."] assert session.view(Visibility.PLAYER).journal == tuple(session.journal) assert session.command_log[-1].source is None # the note was the referee's own + +# The fetch quest, end to end: the idol lands in a pack, and the walk home +# completes the errand — the flag and the XP both landing as ordinary commands. +hero = session.party.members[0] +granted = session.execute(GrantItem(character_id=hero.id, item_id="jade-idol")) +assert granted.accepted +assert session.listener_state["fetch_quest"] == {"recovered": True} + +home = session.execute(TravelToTown()) +assert home.accepted +assert session.listener_state["fetch_quest"] == {"recovered": True, "completed": True} +assert session.flags["quest.idol"] == "recovered" +assert hero.xp > 0 # the award applied, prime-requisite modifier and all ``` ## Where next diff --git a/docs/guides/kernel-a-la-carte.md b/docs/guides/rules-without-a-session.md similarity index 96% rename from docs/guides/kernel-a-la-carte.md rename to docs/guides/rules-without-a-session.md index 8125cee..8c83031 100644 --- a/docs/guides/kernel-a-la-carte.md +++ b/docs/guides/rules-without-a-session.md @@ -1,6 +1,8 @@ -# The kernel à la carte +# Using the rules without a session -`osrlib.core` is the rules kernel: dice, combat, treasure, spells, and the printed +You want to roll dice, resolve an attack, or generate a hoard from a script — no +session, no adventure, no game loop. That is what `osrlib.core`, the rules **kernel**, +is for: dice, combat, treasure, spells, and the printed tables, as pure functions over frozen models. None of it depends on a running game — the dungeon-crawl framework in `osrlib.crawl` is one consumer of the kernel, built entirely on top of it, and a mass-combat simulator, a balance harness, or a content-validation script is @@ -10,7 +12,7 @@ anything built against `osrlib.core` keeps working no matter what the crawl laye Away from a session you bring your own [`RngStreams`][osrlib.core.rng.RngStreams] and pass the stream each function asks for 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 à la carte code isn't +[`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. This page tours four corners of the kernel: rolling dice, resolving an attack, generating treasure, and looking up a reaction. @@ -93,7 +95,7 @@ straight off the SRD's tables (see [the treasure type index][treasure-types-inde its full contents — coins, gems, jewellery, and magic items — end to end from one stream, in printed order, with an `IdAllocator` minting ids for whatever it generates. `tier` picks the Basic or Expert magic-item columns; a session derives it from the party's highest living level, -but à la carte code just states it outright: +but standalone code just states it outright: ```{.python .no-run} # Roll a treasure type letter's contents directly -- no dungeon, no keyed area. diff --git a/docs/guides/ruleset-options.md b/docs/guides/ruleset-options.md index e556e24..59a7d87 100644 --- a/docs/guides/ruleset-options.md +++ b/docs/guides/ruleset-options.md @@ -170,7 +170,9 @@ else: - [The adaptations register](../adaptations.md) — the reasoning and rule text behind every documented adaptation, plus the settled readings of ambiguous SRD text that apply regardless of any flag. -- [Listeners and flags](listeners-and-flags.md) — the game-defined state a `Ruleset` doesn't cover: - quests, triggers, and other content-specific logic. +- [Listeners and flags](listeners-and-flags.md) — session flags and game-owned listeners, the + game-defined state a `Ruleset` doesn't cover. +- [Gates, triggers, and quests](gates-triggers-quests.md) — authored triggers and quests, the + behavior an adventure document carries with it. - [Sessions, commands, and events](sessions-commands-events.md) — how a `Ruleset` reaches a running session and stays fixed for its lifetime. diff --git a/docs/guides/sessions-commands-events.md b/docs/guides/sessions-commands-events.md index 3bad671..1297982 100644 --- a/docs/guides/sessions-commands-events.md +++ b/docs/guides/sessions-commands-events.md @@ -1,13 +1,17 @@ # Sessions, commands, and events +You want the player's action to change the world exactly once, legally, and to hear +about everything it caused — that loop is this page. A [`GameSession`][osrlib.crawl.session.GameSession] is a running game. It owns every piece of mutable state — the party, the dungeon map as explored so far, the RNG streams, the clock, the live monster registry, the mode — and it exposes exactly one way to change any of it: [`execute`][osrlib.crawl.session.GameSession.execute]. Hand it a command, get back a [`CommandResult`][osrlib.crawl.commands.CommandResult]. Nothing -else in the public API mutates a session. This page walks the loop in depth: the modes -that gate which commands are legal, the difference between a rejected command and a -raised exception, and the event log those accepted commands leave behind. The complete +else in the public API mutates a session. This page walks the loop in depth: the shape +of one command's execution and the `source` stamp that records on whose behalf it ran, +the modes that gate which commands are legal, the lifecycle commands the authored layer +keeps its books with, the difference between a rejected command and a raised exception, +and the event log those accepted commands leave behind. The complete program appears [at the end of the page](#the-complete-program); every fragment along the way is an excerpt of it. @@ -58,48 +62,12 @@ 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 — so a log left behind by authored content reads as a transcript with -attributions: this grant came from `trigger:idol-lifted`, that door opened for +a quest causes (see [Gates, triggers, and quests](gates-triggers-quests.md) for how +those are authored) — so a log left behind by authored content 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 lifecycle commands - -Seven referee commands exist for the authored layer to keep its own books with. 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. -- [`CompleteObjective`][osrlib.crawl.commands.CompleteObjective] marks one done — and - reveals it on the way, since an objective the party finished is one it 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`. - -They are ordinary commands: legal in every mode, logged, replayed, and stamped like any -other. What they write — per-quest status and per-objective flags in `session.quests` — -is engine-owned session state, so a replay with no listeners registered rebuilds it by -re-executing the log. - -Their ids are a **closed domain**, and this is the one place the lifecycle family is not -uniform. `MarkTriggerFired.trigger_id` is open: a mark records that something fired, needs -no authored trigger behind it, and a game drives it with ids from its own systems. The -four quest commands invert that — they resolve `quest_id` and `objective_id` against the -adventure's own [`QuestSpec`][osrlib.crawl.quests.QuestSpec]s and reject an id no spec -holds (`session.command.unknown_quest`, `session.command.unknown_objective`), because the -state they advance is projected into the player view, and an id with no spec behind it has -no name, no offer, and no objective list to show. A command that contradicts the state it -finds — activating a quest already active, completing an objective already complete — -rejects with `session.command.quest_state` naming the quest and the state that refused it. - -One asymmetry is deliberate: `CompleteQuest` requires the quest to be active and does -*not* check its completion rule. Ruling a quest done is the referee's call; the -interpreter is simply a disciplined issuer that checks the rule before it issues. For the -same reason, rewards are not the command's doing — whoever completes a quest issues its -rewards afterwards, which is why a hand-driven completion grants nothing. - ## Session modes and mode gating [`SessionMode`][osrlib.crawl.commands.SessionMode] is a small, closed set: `town`, @@ -152,12 +120,50 @@ the party with nobody standing routes to `game_over` and reports it with the sam [`GameOverEvent`][osrlib.crawl.events.GameOverEvent] — a save-or-die trap sprung by a step into the wrong room, a fall, starvation on a long delve, a poison that resolves under a referee's `AdvanceTime`. `victory` is the other terminal mode: -the session that ended by finishing what it set out to do. Nothing in the library -enters it yet — the transition arrives with the authored quest layer, when -completing an adventure's concluding quest is what puts a session there — but its -contract is already in force, and -[`SessionMode.terminal`][osrlib.crawl.commands.SessionMode] answers "has this -session ended?" for either one. +the session that ended by finishing what it set out to do. It has exactly one +entrance — [`CompleteQuest`](#the-lifecycle-commands) on a quest authored +`concludes_adventure=True`, which is how the interpreter ends an adventure whose +concluding quest completes (see +[the completion rule and the ending](gates-triggers-quests.md#the-completion-rule-and-the-ending)) +— and [`SessionMode.terminal`][osrlib.crawl.commands.SessionMode] answers "has +this session ended?" for either one. + +## The lifecycle commands + +Seven referee commands exist for the authored layer to keep its own books with. 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. +- [`CompleteObjective`][osrlib.crawl.commands.CompleteObjective] marks one done — and + reveals it on the way, since an objective the party finished is one it 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`. + +They are ordinary commands: legal in every mode, logged, replayed, and stamped like any +other. What they write — per-quest status and per-objective flags in `session.quests` — +is engine-owned session state, so a replay with no listeners registered rebuilds it by +re-executing the log. + +Their ids are a **closed domain**, and this is the one place the lifecycle family is not +uniform. `MarkTriggerFired.trigger_id` is open: a mark records that something fired, needs +no authored trigger behind it, and a game drives it with ids from its own systems. The +four quest commands invert that — they resolve `quest_id` and `objective_id` against the +adventure's own [`QuestSpec`][osrlib.crawl.quests.QuestSpec]s and reject an id no spec +holds (`session.command.unknown_quest`, `session.command.unknown_objective`), because the +state they advance is projected into the player view, and an id with no spec behind it has +no name, no offer, and no objective list to show. A command that contradicts the state it +finds — activating a quest already active, completing an objective already complete — +rejects with `session.command.quest_state` naming the quest and the state that refused it. + +One asymmetry is deliberate: `CompleteQuest` requires the quest to be active and does +*not* check its completion rule. Ruling a quest done is the referee's call; the +interpreter is simply a disciplined issuer that checks the rule before it issues. For the +same reason, rewards are not the command's doing — whoever completes a quest issues its +rewards afterwards, which is why a hand-driven completion grants nothing. ## Rejections versus exceptions @@ -170,6 +176,13 @@ Moving into a wall, trying to pick a lock without thieves' tools, casting a spel the wrong mode: these are all rejections, and `CommandResult.rejections` is where they land. +One rejection family carries authored player-facing text on top of its code: a gate +refusal (`exploration.door.gate_refused`, `exploration.transition.gate_refused`) +ships the author's `refusal` beat in its `params` — content data in a structured +field, not engine-baked English — so a front end that renders rejections should show +that line to the player. [Gates, triggers, and quests](gates-triggers-quests.md) +teaches the gate that authors it. + ```{.python .no-run} # The party starts in town: MoveParty is out of mode and comes back rejected, not raised. result = session.execute(MoveParty(direction=Direction.EAST)) diff --git a/docs/guides/views-and-visibility.md b/docs/guides/views-and-visibility.md index 1362455..1faef83 100644 --- a/docs/guides/views-and-visibility.md +++ b/docs/guides/views-and-visibility.md @@ -31,7 +31,11 @@ field, alongside the event's message code, never engine-baked English. The wirin produced the beat is not: a fired trigger ([`TriggerFiredEvent`][osrlib.crawl.events.TriggerFiredEvent]) and a referee note ([`NoteRecordedEvent`][osrlib.crawl.events.NoteRecordedEvent]) are referee-visibility, -exactly as a flag write is, because content wiring is the game's secret. +exactly as a flag write is, because content wiring is the game's secret. Player-visible +events and the player view are two of the three channels authored words reach a player +by; the third is a gate's `refusal` beat riding an ordinary rejection, which a front +end should render like any other refusal (see +[Gates, triggers, and quests](gates-triggers-quests.md)). Most front ends never need to do that filtering by hand, though, because osrlib also ships two ready-made projections of the *whole session*, one per audience, and either @@ -129,7 +133,7 @@ assert [entry.id for entry in quest_view.objectives] == ["find-the-lever"] assert "name-the-dead" not in player_view.model_dump_json() ``` -### What tells a client the journal grew +## What tells a client the journal grew [`JournalEntryAddedEvent`][osrlib.crawl.events.JournalEntryAddedEvent] is not the only event a growing journal emits. A quest beat's entry *is* the line the quest displayed, so @@ -280,6 +284,10 @@ assert session.quests["the-lamps"].status == "active" # the hidden objective is - [Sessions, commands, and events](sessions-commands-events.md) — the command loop that produces the state these views project. +- [Listeners and flags](listeners-and-flags.md) — the flag store and listener state + this page keeps out of the player view, and where each one lives. +- [Gates, triggers, and quests](gates-triggers-quests.md) — the authored layer behind + the journal, the quest projections, and the refusal beat. - [The FastAPI pattern](../front-ends/fastapi-pattern.md) — the player view as the wire contract, end to end. - [LLM referees](../front-ends/llm-referees.md) — a narrator built on the referee diff --git a/docs/index.md b/docs/index.md index 48144ad..54a2cf8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,22 +2,39 @@ osrlib is 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. +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. 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** — a FastAPI service serving a crawler over HTTP, with JSON Schema for every command and event - **A terminal game** — a local TUI crawler driving the engine through synchronous calls - **An LLM referee or narrator** — an agent that consumes structured events and drives the engine with typed commands -- **Scripts and simulations** — balance testing, mass-combat statistics, and content validation using the kernel à la carte +- **Scripts and simulations** — balance testing, mass-combat statistics, and content validation, calling the rules kernel with no session at all ## Where to start - The [quickstart](getting-started/quickstart.md) runs the whole loop — characters, party, adventure, session, commands, events, save, and load — in one sitting. -- [Building an adventure](getting-started/building-an-adventure.md) assembles a small dungeon model by model. -- The [guides](guides/sessions-commands-events.md) teach the contracts: sessions and the command/event loop, visibility, determinism, the kernel, listeners, authoring, and ruleset options. +- [Building an adventure](getting-started/building-an-adventure.md) teaches the dungeon itself: the grid and its edges, keyed areas, and the content that binds to them. +- [Gates, triggers, and quests](guides/gates-triggers-quests.md) adds the authored behavior: the door that needs a key, the lever that opens a portcullis, and the 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. +- The [changelog on GitHub](https://github.com/mmacy/osrlib-python/blob/main/CHANGELOG.md) records what each release changed. + +## What things are called + +The project's vocabulary maps one-to-one onto API names, so it pays to learn it early. The common name locates the concept; the linked page teaches the term: + +| You may know it as | osrlib calls it | Taught in | +| --- | --- | --- | +| A quest log | the journal | [Listeners and flags](guides/listeners-and-flags.md#lifecycle-commands-fired-marks-the-journal-and-notes) | +| A scripted event | a trigger | [Gates, triggers, and quests](guides/gates-triggers-quests.md#wiring-the-dungeon-with-triggers) | +| A locked door that needs an item | a gate | [Gates, triggers, and quests](guides/gates-triggers-quests.md#gating-a-door-or-a-stair) | +| The text an event shows | beats on a narrative block | [Gates, triggers, and quests](guides/gates-triggers-quests.md#which-beat-goes-where) | +| What the player is allowed to see | the player view | [Views and visibility](guides/views-and-visibility.md) | +| Seedable randomness | named streams and draws | [Determinism, saves, and replay](guides/determinism-saves-replay.md) | +| A save file | a stamped document | [Determinism, saves, and replay](guides/determinism-saves-replay.md#saves) | +| A win condition | a concluding quest and `victory` | [Gates, triggers, and quests](guides/gates-triggers-quests.md#the-completion-rule-and-the-ending) | ## Installation diff --git a/docs/reference/rng-streams.md b/docs/reference/rng-streams.md index a1eceb4..d31c10f 100644 --- a/docs/reference/rng-streams.md +++ b/docs/reference/rng-streams.md @@ -10,7 +10,7 @@ seed replay identically, and adding new draws to one subsystem never shifts anot subsystem's rolls. Each stream is identified by a plain string key, such as `"combat"` or `"treasure"`. -Code that uses the kernel functions directly — à la carte, outside of a running game — +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 diff --git a/docs/spec.md b/docs/spec.md index 4a906e1..88fa055 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -232,7 +232,7 @@ A save file contains the full game state (including registered listener state an - A saved game restores from state alone; the logs are records, not dependencies. The event log may therefore be compacted (save = state + optional log tail) without affecting correctness. - A replay is seed + accepted-command log, and is valid only under the same engine version — any rules change may legitimately alter outcomes, and replaying under a different engine version is an explicit, detectable error rather than silent divergence. -- Replays reproduce engine state exactly with or without listeners, because listeners never mutate game state: their reactions were issued as ordinary commands and are already in the log. Re-registering the same listeners during a replay reproduces their state too. +- Replays reproduce engine state exactly with or without listeners, because listeners never mutate game state: their reactions were issued as ordinary commands and are already in the log. Re-registering an observe-only listener during a replay reproduces its state too; a listener that reacts by issuing commands — the interpreter above all — must not be registered during a replay, because the log already carries every command it issued live and a second issuer would issue them again. - A standing test guarantees that `load(save)` and `replay(seed, commands)` produce identical state for every golden scenario. Schema versioning: saves, commands, and events share a single monotonically increasing integer `schema_version`, independent of the package version. diff --git a/examples/fastapi_crawler/app.py b/examples/fastapi_crawler/app.py index cf30284..bb9230b 100644 --- a/examples/fastapi_crawler/app.py +++ b/examples/fastapi_crawler/app.py @@ -56,6 +56,7 @@ # --8<-- [end:session-store] +# --8<-- [start:create-session-model] class CreateSession(BaseModel): """The `POST /sessions` body: a stamped party document, or a save id to restore. @@ -74,6 +75,9 @@ def _party_or_save(self) -> CreateSession: return self +# --8<-- [end:create-session-model] + + # --8<-- [start:error-mapping] @app.exception_handler(ContentValidationError) def _content_validation_error(request: Request, error: ContentValidationError) -> JSONResponse: diff --git a/examples/fastapi_crawler/content.py b/examples/fastapi_crawler/content.py index fdb2781..cc8fc0b 100644 --- a/examples/fastapi_crawler/content.py +++ b/examples/fastapi_crawler/content.py @@ -21,6 +21,7 @@ __all__ = ["new_session", "restore_session"] +# --8<-- [start:new-session] def new_session(party: Party, *, seed: int) -> GameSession: """Create a session serving the barrow, with the adventure's quest in play. @@ -36,6 +37,10 @@ def new_session(party: Party, *, seed: int) -> GameSession: return session +# --8<-- [end:new-session] + + +# --8<-- [start:restore-session] def restore_session(document: Mapping[str, object]) -> GameSession: """Restore a session from a save document, re-registering the interpreter. @@ -52,3 +57,6 @@ def restore_session(document: Mapping[str, object]) -> GameSession: session = load_game(document) session.register_listener(Interpreter(session)) return session + + +# --8<-- [end:restore-session] diff --git a/examples/tui_crawler/README.md b/examples/tui_crawler/README.md index bb94f54..dfce233 100644 --- a/examples/tui_crawler/README.md +++ b/examples/tui_crawler/README.md @@ -1,8 +1,8 @@ # The barrow crawler A minimal terminal dungeon crawl on osrlib and the plain standard library — no -curses, no Textual, no dependencies. It is the Phase 5 milestone: character -creation to leveling up, entirely through `GameSession.execute`. +curses, no Textual, no dependencies. It plays a complete adventure end to end — +character creation to leveling up — entirely through `GameSession.execute`. ## Running it @@ -25,7 +25,8 @@ town # return to town from the entrance sell all # sell carried valuables at full value give character-0001 character-0002 550 # hand coin to a companion (coin weighs!) heal character-0002 cure_light_wounds # buy a temple service -status # party summary +status # party summary, active quests included +journal # the party's journal, in order of discovery quit ``` diff --git a/examples/tui_crawler/__main__.py b/examples/tui_crawler/__main__.py index 1d57127..4e6ae60 100644 --- a/examples/tui_crawler/__main__.py +++ b/examples/tui_crawler/__main__.py @@ -44,18 +44,23 @@ # --8<-- [start:render-events] def _run(session, command): - """Execute one command and print every player-visible event it logged. + """Execute one command and print every player-visible event it came back with. - Printing the event-log delta (rather than the result's events) shows the - interpreter's reactions too: the commands it issues for the adventure's - triggers and quests append to the same log. + The result envelope already carries the whole chain — everything a nested + listener-issued command logged, the interpreter's reactions included, folds + into `result.events` in log order — so rendering is a plain iteration. + A rejection prints its code, and the authored refusal text when a gate wrote + one. """ - mark = len(session.event_log) result = session.execute(command) if not result.accepted: - print(" (refused: " + ", ".join(rejection.code for rejection in result.rejections) + ")") + reasons = [] + for rejection in result.rejections: + refusal = rejection.params.get("refusal") + reasons.append(f"{rejection.code} — {refusal}" if refusal else rejection.code) + print(" (refused: " + ", ".join(reasons) + ")") return result - for event in session.event_log[mark:]: + for event in result.events: if event.visibility is Visibility.PLAYER: print(" " + format_message(event)) return result @@ -117,6 +122,20 @@ def _status(session) -> None: purse = member.inventory["purse"] valuables = ", ".join(v["name"] or v["kind"] for v in member.inventory["valuables"]) print(f" gold {purse['gp']} gp" + (f"; carrying {valuables}" if valuables else "")) + # Active quests only: a completed quest leaves the projection, its record kept + # by the journal. + for quest in view.quests: + objectives = ", ".join(f"{objective.id} {objective.state}" for objective in quest.objectives) + print(f" Quest: {quest.name}" + (f" — {objectives}" if objectives else "")) + + +def _journal(session) -> None: + view = session.view(Visibility.PLAYER) + if not view.journal: + print(" (the journal is empty)") + return + for entry in view.journal: + print(f" [round {entry.rounds}] {entry.text}") # --8<-- [end:player-view] @@ -133,6 +152,9 @@ def _dispatch(session, line: str) -> bool: if verb == "status": _status(session) return True + if verb == "journal": + _journal(session) + return True if verb == "fight": _fight(session) return True diff --git a/examples/tui_crawler/create.py b/examples/tui_crawler/create.py index b8ef00d..df62804 100644 --- a/examples/tui_crawler/create.py +++ b/examples/tui_crawler/create.py @@ -1,6 +1,6 @@ """Party creation for the crawler: interactive prompts, or the fixed script party. -Both paths drive the Phase 1 creation kernel; the game owns prompting and choice, +Both paths drive the library's creation kernel; the game owns prompting and choice, the kernel owns the dice and the rules. """ diff --git a/examples/tui_crawler/scripts/milestone.txt b/examples/tui_crawler/scripts/milestone.txt index 7d32525..bf9cb8c 100644 --- a/examples/tui_crawler/scripts/milestone.txt +++ b/examples/tui_crawler/scripts/milestone.txt @@ -47,4 +47,5 @@ move w move w move w town +journal status diff --git a/mkdocs.yml b/mkdocs.yml index 41f52a4..944c3a3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -41,6 +41,9 @@ markdown_extensions: - pymdownx.snippets: base_path: [.] check_paths: true + # Sections excerpted from inside functions render at the page margin, not at + # their source indentation. + dedent_subsections: true # The design documents live in docs/ beside the site pages but are not part of the # published site; the adaptations register (adaptations.md) is a site page and stays. @@ -77,8 +80,9 @@ nav: - Sessions, commands, and events: guides/sessions-commands-events.md - Views and visibility: guides/views-and-visibility.md - Determinism, saves, and replay: guides/determinism-saves-replay.md - - The kernel à la carte: guides/kernel-a-la-carte.md + - Using the rules without a session: guides/rules-without-a-session.md - Listeners and flags: guides/listeners-and-flags.md + - Gates, triggers, and quests: guides/gates-triggers-quests.md - Authoring custom classes, spells, monsters, and items: guides/authoring-custom-content.md - Ruleset options: guides/ruleset-options.md - Front ends: diff --git a/src/osrlib/crawl/adventure.py b/src/osrlib/crawl/adventure.py index b505bc3..6a9e4d1 100644 --- a/src/osrlib/crawl/adventure.py +++ b/src/osrlib/crawl/adventure.py @@ -6,9 +6,18 @@ kernel, not a simulated town. Content prose lives in these models — events carry ids and front ends resolve prose against the adventure. +Beyond the dungeons, the document carries the adventure's own content and +behavior: `monsters` and `items` bundle templates that resolve beside the shipped +catalogs for that session, `triggers` is the authored wiring +([`TriggerSpec`][osrlib.crawl.triggers.TriggerSpec]), and `quests` the authored +errands ([`QuestSpec`][osrlib.crawl.quests.QuestSpec]) — with gates +([`GateSpec`][osrlib.crawl.gates.GateSpec]) riding the dungeon geometry's doors +and transitions. + [`validate_adventure`][osrlib.crawl.adventure.validate_adventure] is the fail-fast content gate: dangling references (transition targets, monster template ids, -item ids, area cells out of bounds) raise +item ids, area cells out of bounds, gate item ids, trigger and quest references — +patterns, conditions, consequence targets, selectors) raise [`ContentValidationError`][osrlib.errors.ContentValidationError] before a session ever runs the content. """ diff --git a/src/osrlib/crawl/commands.py b/src/osrlib/crawl/commands.py index b4021c8..6d97e58 100644 --- a/src/osrlib/crawl/commands.py +++ b/src/osrlib/crawl/commands.py @@ -171,6 +171,11 @@ class CommandResult(BaseModel): A rejected command consumes no RNG draws, no clock time, mutates nothing, and is excluded from the command log — its result carries the rejections and no events. + + An accepted command's `events` carries the complete chain: the handler's own + events, plus everything the nested commands a listener issued logged while it + ran — each event exactly once, in log order — so a front end renders the whole + reaction from one envelope without reading `session.event_log`. """ model_config = ConfigDict(frozen=True) @@ -1548,6 +1553,10 @@ class GrantItem(Command): command_type: Literal["grant_item"] = "grant_item" character_id: str + """In an authored consequence or reward, this field takes the party selectors + (`"@party"`, `"@first"`), expanded to literal member ids by the interpreter + before issue; issued directly, it must be a literal member id or the command + rejects.""" item_id: str quantity: int = Field(default=1, ge=1) @@ -1570,6 +1579,10 @@ class GrantCoins(Command): command_type: Literal["grant_coins"] = "grant_coins" character_id: str + """In an authored consequence or reward, this field takes the party selectors + (`"@party"`, `"@first"`), expanded to literal member ids by the interpreter + before issue; issued directly, it must be a literal member id or the command + rejects.""" coins: Coins @@ -1596,6 +1609,10 @@ class AwardXP(Command): command_type: Literal["award_xp"] = "award_xp" character_id: str + """In an authored consequence or reward, this field takes the party selectors + (`"@party"`, `"@first"`), expanded to literal member ids by the interpreter + before issue; issued directly, it must be a literal member id or the command + rejects.""" amount: int = Field(ge=0) diff --git a/src/osrlib/crawl/events.py b/src/osrlib/crawl/events.py index 7d4f6b7..4300787 100644 --- a/src/osrlib/crawl/events.py +++ b/src/osrlib/crawl/events.py @@ -127,13 +127,7 @@ class LocationEnteredEvent(Event): class DoorEvent(Event): - """A door changed state; the edge is named by its cell and direction. - - `narrative` carries the authored success text of the door's gate when the - opening satisfied one — content data in a structured field, not engine-baked - English: the event still carries its message code and its facts, and the - default formatter appends the line verbatim after the templated one. - """ + """A door changed state; the edge is named by its cell and direction.""" allowed_codes: ClassVar[frozenset[str]] = frozenset( { @@ -154,6 +148,10 @@ class DoorEvent(Event): direction: str character_id: str | None = None narrative: str | None = None + """The authored success text of the door's gate, when the opening satisfied one. + Authored text on an event is content data in a structured field, not engine-baked + English: the event still carries its message code and its facts, and the default + formatter appends this line verbatim after the templated one.""" class ListenedEvent(Event): diff --git a/src/osrlib/crawl/gates.py b/src/osrlib/crawl/gates.py index 65dbdb7..98a9545 100644 --- a/src/osrlib/crawl/gates.py +++ b/src/osrlib/crawl/gates.py @@ -1,7 +1,10 @@ """Authored gates: the condition vocabulary, the gate model, and pure evaluation. A gate is an authored predicate the engine checks when the party *attempts* -something — opening a door, taking a stair. It is a stateless content object: +something — opening a door, taking a stair. Its two carriers are the `requires` +fields of [`DoorSpec`][osrlib.crawl.dungeon.DoorSpec] and +[`TransitionSpec`][osrlib.crawl.dungeon.TransitionSpec] — a gate hangs nowhere +else. It is a stateless content object: [`condition_holds`][osrlib.crawl.gates.condition_holds] reads live session state at the moment of the attempt and stores nothing, so a key dropped or sold stops opening its door and evaluation never drifts from the truth. diff --git a/src/osrlib/crawl/narrative.py b/src/osrlib/crawl/narrative.py index c572a21..71fde5c 100644 --- a/src/osrlib/crawl/narrative.py +++ b/src/osrlib/crawl/narrative.py @@ -1,9 +1,12 @@ """Authored narrative attached to mechanical objects: the three-audience block. A [`NarrativeBlock`][osrlib.crawl.narrative.NarrativeBlock] is content a game's -author hangs on a mechanical object — a gate today, a trigger or a quest as those -land — and it is inert data: it decides nothing and is evaluated by nobody. Its -three audiences are: +author hangs on a mechanical object — a gate +([`GateSpec`][osrlib.crawl.gates.GateSpec]), a trigger +([`TriggerSpec`][osrlib.crawl.triggers.TriggerSpec]), a quest or one of its +objectives ([`QuestSpec`][osrlib.crawl.quests.QuestSpec], +[`ObjectiveSpec`][osrlib.crawl.quests.ObjectiveSpec]) — and it is inert data: it +decides nothing and is evaluated by nobody. Its three audiences are: - **Display beats**, shown verbatim by a deterministic renderer. The default English formatter ([`format_message`][osrlib.messages.format_message]) appends @@ -79,11 +82,21 @@ class NarrativeBlock(BaseModel): model_config = ConfigDict(frozen=True) refusal: str = "" + """A gate's refusal line, returned in the rejection when the attempt is refused.""" success: str = "" + """A gate's success line, riding the successful command's event.""" fired: str = "" + """A trigger's firing line — the referee's beat, on a referee-visibility event.""" offer: str = "" + """A quest's activation line, or an objective's reveal line; shown and journaled.""" progress: str = "" + """An objective's completion line; shown and journaled. Unread on a quest block.""" completion: str = "" + """A quest's completion line; shown and journaled. Unread on an objective block.""" journal: str = "" + """The written-record form, for carriers whose display beat the players never see + (a trigger's `fired`). Unread by quests, which journal the display text they showed.""" guidance: str = "" + """Steering for an LLM narrator while the carrier is in play; never displayed.""" speaker: str = "" + """An attribution ("the bronze sentinel") a renderer may put in front of a beat.""" diff --git a/src/osrlib/crawl/quests.py b/src/osrlib/crawl/quests.py index ac248ec..2574b9a 100644 --- a/src/osrlib/crawl/quests.py +++ b/src/osrlib/crawl/quests.py @@ -22,6 +22,14 @@ [`QuestSpec.objectives`][osrlib.crawl.quests.QuestSpec] — the order a session's quest state ([`QuestState`][osrlib.crawl.session.QuestState]) keys its objectives in, so every walk over either is deterministic. + +A spec is inert data; the [`Interpreter`][osrlib.crawl.interpreter.Interpreter] is +the shipped listener that plays it, advancing quest state through its only four +writers — the lifecycle commands +[`ActivateQuest`][osrlib.crawl.commands.ActivateQuest], +[`RevealObjective`][osrlib.crawl.commands.RevealObjective], +[`CompleteObjective`][osrlib.crawl.commands.CompleteObjective], and +[`CompleteQuest`][osrlib.crawl.commands.CompleteQuest]. """ from typing import Literal diff --git a/src/osrlib/crawl/session.py b/src/osrlib/crawl/session.py index 7f1345b..a16a7ac 100644 --- a/src/osrlib/crawl/session.py +++ b/src/osrlib/crawl/session.py @@ -918,8 +918,7 @@ def _record_deaths(self, events: Sequence[Event]) -> bool: events: The just-executed command's events, in order. Returns: - True when a party member died in them — the edge - [`_end_on_party_wipe`][osrlib.crawl.session.GameSession._end_on_party_wipe] + True when a party member died in them — the edge the party-wipe check triggers on, identified by this same walk. Monsters and NPC adventurers carry non-member ids and never count. """ diff --git a/src/osrlib/crawl/triggers.py b/src/osrlib/crawl/triggers.py index c17ad97..5b01379 100644 --- a/src/osrlib/crawl/triggers.py +++ b/src/osrlib/crawl/triggers.py @@ -29,6 +29,11 @@ Document order is the order of the [`Adventure.triggers`][osrlib.crawl.adventure.Adventure] tuple: triggers matching one event fire in that order, and a trigger's consequences execute in authored order. + +Triggers are inert content on their own. The +[`Interpreter`][osrlib.crawl.interpreter.Interpreter] is the shipped listener that +plays them: registered on a session, it matches every command's events against the +adventure's triggers and issues each firing's commands. """ from typing import Annotated, Literal @@ -186,6 +191,9 @@ class FlagSetPattern(BaseModel): class TriggerSpec(BaseModel): """One authored trigger: when it fires, what must hold, and what happens. + A spec is inert data; the [`Interpreter`][osrlib.crawl.interpreter.Interpreter] + is the shipped listener that plays it. + Once-only by default — the fired-mark that [`MarkTriggerFired`][osrlib.crawl.commands.MarkTriggerFired] writes is session state, so once-only survives a save, a load, and a replay. `repeatable=True` is diff --git a/src/osrlib/crawl/views.py b/src/osrlib/crawl/views.py index 593c9ce..65a1d59 100644 --- a/src/osrlib/crawl/views.py +++ b/src/osrlib/crawl/views.py @@ -149,6 +149,7 @@ class ObjectiveView(BaseModel): model_config = ConfigDict(frozen=True) id: str + """The objective's authored id, scoped to its quest.""" state: str """`"incomplete"` or `"complete"`.""" @@ -166,7 +167,9 @@ class QuestView(BaseModel): model_config = ConfigDict(frozen=True) id: str + """The quest's authored id.""" name: str + """The quest's authored display name.""" narrative: str speaker: str objectives: tuple[ObjectiveView, ...] diff --git a/tests/test_example_crawler.py b/tests/test_example_crawler.py index 6a1a200..adc6bbb 100644 --- a/tests/test_example_crawler.py +++ b/tests/test_example_crawler.py @@ -53,6 +53,10 @@ def test_scripted_run_reaches_the_milestone(self): # The level-up and the quest flag. assert "Highest level reached: 2" in out assert "quest.idol = 'recovered'" in out + # The status view renders the active quest, and the journal verb prints + # the authored record with its clock stamps. + assert "Quest: The Jade Idol — recover-idol incomplete, return-home incomplete" in out + assert "[round 120] The temple wants the Jade Idol off the barrow king's altar" in out # The adventure is over, and the closing status says so. assert "[victory]" in out