From 5a254da61e2f5e4d586b8a6e110738831ca5a808 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Sun, 13 Sep 2026 22:11:55 -0700 Subject: [PATCH 1/2] Docstrings: crawl authoring Rewrite the reference prose for the models an author builds before a session starts: the crawl subpackage front page, the party, the adventure with its town, the dungeon geometry and its state overlay, content packs, and stocking. Every public member and every pydantic field now says what it is for, what you need first, what to call next, and what its limits are, and Adventure, DungeonSpec, and LevelSpec carry runnable examples. Four claims corrected against the code: Party.ranks no longer says the width is 3 in a room and 2 in a corridor, DropPile no longer says pursuit bait lands in a pile, KeyedMonster.count_dice now names the wandering stream, and DungeonSpec.levels now says the tuple's order decides which entrance EnterDungeon uses. Claude-Session: https://claude.ai/code/session_01GL26QnA6dCrvUc3WmhzFSa --- src/osrlib/crawl/__init__.py | 86 +- src/osrlib/crawl/adventure.py | 417 ++++++---- src/osrlib/crawl/content_pack.py | 323 +++++--- src/osrlib/crawl/dungeon.py | 1283 +++++++++++++++++++++++++----- src/osrlib/crawl/party.py | 132 ++- src/osrlib/crawl/stocking.py | 237 ++++-- 6 files changed, 1922 insertions(+), 556 deletions(-) diff --git a/src/osrlib/crawl/__init__.py b/src/osrlib/crawl/__init__.py index d5bd285..62f2a1a 100644 --- a/src/osrlib/crawl/__init__.py +++ b/src/osrlib/crawl/__init__.py @@ -1,9 +1,79 @@ -"""The crawl framework layer: the dungeon crawl game loop. - -`osrlib.crawl` implements the dungeon adventuring procedures on top of the -`osrlib.core` kernel: the adventure container with its base town, the multi-level -dungeon grid with keyed areas, the crawl party with marching order, the exploration -turn loop, the encounter procedure, the range-track battle state machine, and the -`GameSession` command/event API. The kernel never imports from this package — -layering is a spec invariant. +"""The crawl framework: adventure content, the game session, and the dungeon crawl loop. + +`osrlib.crawl` is the layer you build a dungeon crawler on. It holds the content models you author an +adventure with, the [`GameSession`][osrlib.crawl.session.GameSession] that runs one, and the +procedures the session dispatches to while the party explores, talks, and fights. It sits on top of +the `osrlib.core` kernel, which has the characters, items, dice, the clock, and the rules that need +no dungeon around them. The dependency runs one way: the kernel never imports from here, so you can +use the core rules on their own and reach for this package when you want a running game. + +The modules, in the order you meet them: + +- [`osrlib.crawl.dungeon`][osrlib.crawl.dungeon] takes cell coordinates and gives you the geometry: + levels, the edges that make walls and doors, keyed areas, features, traps, and transitions, plus + [`DungeonState`][osrlib.crawl.dungeon.DungeonState], the overlay play writes over that frozen content. +- [`osrlib.crawl.adventure`][osrlib.crawl.adventure] takes dungeons and a town and gives you an + [`Adventure`][osrlib.crawl.adventure.Adventure], the root document a session runs, with + [`validate_adventure`][osrlib.crawl.adventure.validate_adventure] to check every id in it resolves. +- [`osrlib.crawl.party`][osrlib.crawl.party] takes characters and gives you a + [`Party`][osrlib.crawl.party.Party] in marching order, which the session moves as one body. +- [`osrlib.crawl.gates`][osrlib.crawl.gates], [`osrlib.crawl.triggers`][osrlib.crawl.triggers], and + [`osrlib.crawl.quests`][osrlib.crawl.quests] take event patterns and conditions and give you the + authored behavior an adventure contains: what a door requires before it opens, what fires when the + party walks in, and what the party is trying to accomplish. + [`osrlib.crawl.narrative`][osrlib.crawl.narrative] is the prose block you attach to any of them. +- [`osrlib.crawl.stocking`][osrlib.crawl.stocking] takes a level number and an RNG stream and gives + you one keyed area's rolled contents, so you can fill rooms from the B/X tables instead of by hand. +- [`osrlib.crawl.content_pack`][osrlib.crawl.content_pack] takes finished room content and gives you a + portable document with the geometry left out, for moving stocked rooms between adventures. +- [`osrlib.crawl.session`][osrlib.crawl.session] takes a party and an adventure and gives you the + running game: one [`execute`][osrlib.crawl.session.GameSession.execute] call per player action. +- [`osrlib.crawl.commands`][osrlib.crawl.commands] is what you hand `execute`, and + [`osrlib.crawl.events`][osrlib.crawl.events] is what comes back. + [`osrlib.crawl.views`][osrlib.crawl.views] turns the session into the state you draw, in either the + player's or the referee's visibility. +- [`osrlib.crawl.interpreter`][osrlib.crawl.interpreter] is the listener that plays an adventure's + triggers and quests, and + [`osrlib.crawl.exploration`][osrlib.crawl.exploration], + [`osrlib.crawl.encounter`][osrlib.crawl.encounter], and + [`osrlib.crawl.battle`][osrlib.crawl.battle] are the procedures the session runs for you. You read + them to learn what a command does. You rarely call into them yourself. + +Typical usage: + +```python +from osrlib.core.abilities import AbilityScore +from osrlib.core.alignment import Alignment +from osrlib.core.character import Character +from osrlib.crawl.adventure import Adventure, TownSpec +from osrlib.crawl.commands import EnterDungeon, MoveParty +from osrlib.crawl.dungeon import Direction, DungeonSpec, Edge, EdgeKind, LevelSpec +from osrlib.crawl.party import Party +from osrlib.crawl.session import GameSession + +hero = Character( + name="Hild", + class_id="fighter", + race="human", + level=1, + xp=0, + scores={ability: 12 for ability in AbilityScore}, + alignment=Alignment.LAWFUL, + max_hp=8, + current_hp=8, +) +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)}),), +) +adventure = Adventure(name="A First Delve", town=TownSpec(name="Threshold"), dungeons=(crypt,)) + +session = GameSession.new(Party(members=[hero]), adventure, seed=7) +session.execute(EnterDungeon(dungeon_id="crypt")) +result = session.execute(MoveParty(direction=Direction.EAST)) + +print([event.code for event in result.events]) +# ['exploration.party.moved'] +``` """ diff --git a/src/osrlib/crawl/adventure.py b/src/osrlib/crawl/adventure.py index 6a9e4d1..8eb6e95 100644 --- a/src/osrlib/crawl/adventure.py +++ b/src/osrlib/crawl/adventure.py @@ -1,25 +1,32 @@ -"""The adventure container: dungeons, the base town, and scenario metadata. - -An adventure is frozen game content — the session runs it, never mutates it. The -base town anchors the XP rule's "survive and return to safety" and safe day-level -rest. It is a marker offering safe rest and equipment purchase through the -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, gate item ids, trigger and quest references — -patterns, conditions, consequence targets, selectors) raise -[`ContentValidationError`][osrlib.errors.ContentValidationError] before a session -ever runs the content. +"""The adventure: the root document a session plays. + +An [`Adventure`][osrlib.crawl.adventure.Adventure] is everything a game needs to run except the party +and the dice. You build the geometry in [`osrlib.crawl.dungeon`][osrlib.crawl.dungeon], wrap the +levels in dungeons, add a [`TownSpec`][osrlib.crawl.adventure.TownSpec] for the party to come home +to, and assemble the two here. Then you hand the result to +[`GameSession.new`][osrlib.crawl.session.GameSession.new] beside a +[`Party`][osrlib.crawl.party.Party], and the session runs it. + +The adventure is frozen. The session reads it and never writes back: everything play changes goes +into [`DungeonState`][osrlib.crawl.dungeon.DungeonState] instead. That split is what lets a save file +carry the overlay alone, and it is why loading a save against the same adventure gives you the same +game. Prose lives in these models rather than in events, because events carry ids and your front end +resolves them against the adventure it already has. + +Besides its dungeons, an adventure contains its own content and behavior. `monsters` and `items` bundle +templates that resolve beside the shipped catalogs for the sessions that run this adventure. +`triggers` ([`TriggerSpec`][osrlib.crawl.triggers.TriggerSpec]) are what fire when something happens, +`quests` ([`QuestSpec`][osrlib.crawl.quests.QuestSpec]) are what the party is trying to accomplish, and +gates ([`GateSpec`][osrlib.crawl.gates.GateSpec]) sit on the doors and transitions of the geometry. + +[`validate_adventure`][osrlib.crawl.adventure.validate_adventure] is what tells you the document +hangs together before anybody plays it. It follows every id in the tree to the thing it names and +raises [`ContentValidationError`][osrlib.errors.ContentValidationError] listing everything that +dangles at once. `GameSession.new` runs it for you, so a session can never start on broken content. +Call it yourself while you author and you find out sooner. + +The long form, with a complete program you can run, is the guide +[Building an adventure](https://mmacy.github.io/osrlib-python/getting-started/building-an-adventure/). """ from pydantic import BaseModel, ConfigDict, Field, model_validator @@ -68,68 +75,152 @@ class TownSpec(BaseModel): - """The base town: safe rest, equipment purchase, and travel costs. - - `services` is prose for front ends. `travel_turns` maps dungeon ids to the - town-to-entrance travel cost in exploration turns — content-authored, consumed - by `EnterDungeon` and `TravelToTown`. + """The base town: where the party is safe, buys gear, and comes home to. + + Every adventure has exactly one town, and a session starts there. It is a marker rather than a + place you can walk around: there is no grid, no rooms, and nothing to explore. What it does is + anchor the rules that need somewhere safe. The party rests a full day here, buys and sells + through the equipment catalog, pays a temple for healing, and, under the default XP timing, + earns the treasure it carried out only once it has come back. + + Attributes: + name: The town's name. + description: Prose for your front end. + services: The services the town offers. + travel_turns: The travel cost from town to each dungeon. + + Examples: + ```python + from osrlib.crawl.adventure import TownSpec + + threshold = TownSpec(name="Threshold", services=("temple", "smith"), travel_turns={"crypt": 1}) + print(threshold.travel_turns["crypt"]) + # 1 + ``` """ model_config = ConfigDict(frozen=True) name: str + """The town's name, for your front end to show: `"Threshold"`.""" description: str = "" + """Prose your front end shows while the party is in town.""" services: tuple[str, ...] = () + """The services the town offers, as free-form strings like `("temple", "smith")`. Nothing in the + engine reads them: the shop and the temple commands work in town regardless. They reach your + front end on the player view's `town_services`, which is what a town screen lists.""" travel_turns: dict[str, int] = {} + """How long it takes to get from town to each dungeon's entrance, in exploration turns, keyed by + dungeon id. [`EnterDungeon`][osrlib.crawl.commands.EnterDungeon] and + [`TravelToTown`][osrlib.crawl.commands.TravelToTown] each advance the clock by this much, so a + far-off dungeon costs light and rations to reach and to leave. A dungeon with no entry here + travels free. Every id named here has to be a dungeon of this adventure, and + [`validate_adventure`][osrlib.crawl.adventure.validate_adventure] refuses one that is not.""" class Adventure(BaseModel): - """An adventure: one or more dungeons plus the base town and metadata. - - `monsters` are the adventure's bundled custom - [`MonsterTemplate`][osrlib.core.monsters.MonsterTemplate]s: they join the - shipped catalog for this adventure's sessions everywhere the engine resolves - template ids (keyed encounters, `SpawnMonsters`, inline wandering tables, - listen checks). Bundled ids must not collide with the shipped catalog or each - other — a collision is a validation error, never an override. The empty tuple - is the universal default: an adventure that bundles nothing plays exactly as - before. - - `items` are the adventure's bundled custom - [`ItemTemplate`][osrlib.core.items.ItemTemplate]s — weapons, armour, gear, and - ammunition — under the same contract: they join the shipped equipment catalog - for this adventure's sessions everywhere the engine resolves authored item ids - (treasure caches, `GrantItem`, drop-pile recovery), and they ride every carry - surface (gives, equips, drops) through the templates their instances embed. - Bundled item ids must not collide with the equipment catalog, the magic-item - catalog, or each other — one item id names one thing per session. The town - shop is the one place they do not reach: it stocks the shipped equipment - lists. - - `triggers` are the adventure's authored - [`TriggerSpec`][osrlib.crawl.triggers.TriggerSpec]s, and the tuple's order *is* - document order: triggers matching one event fire in it. A game plays them by - registering an [`Interpreter`][osrlib.crawl.interpreter.Interpreter] on its - session; an adventure that authors none plays exactly as one that never could. - - `quests` are the adventure's authored - [`QuestSpec`][osrlib.crawl.quests.QuestSpec]s, in document order too: a session - seeds one state block per quest at construction, in this order, and every walk - over them follows it. Quest ids and trigger ids are separate namespaces — they - live in separate state blocks — so a quest and a trigger may share an id. + """An adventure: one or more dungeons, the base town, and everything they need. + + This is the root of the content tree and the document a session plays. Build the levels first, + wrap them in [`DungeonSpec`][osrlib.crawl.dungeon.DungeonSpec]s, write a + [`TownSpec`][osrlib.crawl.adventure.TownSpec], and assemble them here. Then call + [`GameSession.new`][osrlib.crawl.session.GameSession.new] with a + [`Party`][osrlib.crawl.party.Party] and this adventure: it validates the whole tree, assigns the + party's members their entity ids, composes the shipped catalogs with whatever this adventure + bundles, seeds a state block for each quest, and hands you a session standing in town. The + adventure itself is frozen and stays as you wrote it for the life of the session. + + Everything nested here is a pydantic model with a keyword constructor, so an adventure is a tree + of calls you can write in Python, generate from your own file format, or round-trip through JSON. + Nothing reads files for you. + + Attributes: + name: The adventure's title. + description: Prose for your front end. + hooks: Why a party might take this on. + town: The base town. + dungeons: The dungeons, at least one. + monsters: Monster templates this adventure brings with it. + items: Item templates this adventure brings with it. + triggers: What fires when something happens. + quests: What the party is trying to accomplish. + + Raises: + ValueError: If two dungeons carry the same `id`. + + Examples: + ```python + from osrlib.crawl.adventure import Adventure, TownSpec, validate_adventure + from osrlib.crawl.dungeon import DungeonSpec, Edge, EdgeKind, LevelSpec + from osrlib.data import load_equipment, load_monsters + + # Two cells joined west to east, entered at the west end. + corridor = LevelSpec(number=1, width=2, height=1, entrance=(0, 0), edges={"1,0:west": Edge(kind=EdgeKind.OPEN)}) + crypt = DungeonSpec(id="crypt", name="The Old Crypt", levels=(corridor,)) + adventure = Adventure( + name="A First Delve", + town=TownSpec(name="Threshold", travel_turns={"crypt": 1}), + dungeons=(crypt,), + ) + validate_adventure(adventure, load_monsters(), load_equipment()) + + print(adventure.dungeon("crypt").level(1).entrance) + # (0, 0) + ``` """ model_config = ConfigDict(frozen=True) name: str + """The adventure's title, for your front end to show.""" description: str = "" + """Prose describing the adventure, for your front end.""" hooks: tuple[str, ...] = () + """The reasons a party might take this on, as free-form strings: the rumours in the tavern, the + patron's offer. Nothing in the engine reads them. They are here so an adventure document contains + its own pitch.""" town: TownSpec + """The base town. Exactly one, and the session starts there. See + [`TownSpec`][osrlib.crawl.adventure.TownSpec].""" dungeons: tuple[DungeonSpec, ...] = Field(min_length=1) + """The dungeons, at least one, with unique ids. Some level of each needs an `entrance`, since + that is where the party arrives from town.""" monsters: tuple[MonsterTemplate, ...] = () + """[`MonsterTemplate`][osrlib.core.monsters.MonsterTemplate]s this adventure brings with it, + beyond the shipped catalog. They join that catalog for the sessions that run this adventure, + everywhere the engine resolves a template id: keyed encounters, + [`SpawnMonsters`][osrlib.crawl.commands.SpawnMonsters], inline wandering tables, listen checks. + + A bundled id may not collide with a shipped one or with another bundled one. A collision is a + validation error rather than an override, because one id has to name one monster for the session + to be able to say what it spawned. Writing a monster template is covered in the guide + [Authoring custom classes, spells, monsters, and items](https://mmacy.github.io/osrlib-python/guides/authoring-custom-content/).""" items: tuple[ItemTemplate, ...] = () + """[`ItemTemplate`][osrlib.core.items.ItemTemplate]s this adventure brings with it: weapons, + armour, gear, and ammunition. They join the shipped equipment catalog for the sessions that run + this adventure, everywhere the engine resolves an authored item id, which is treasure caches, + [`GrantItem`][osrlib.crawl.commands.GrantItem], and drop-pile recovery. Once an instance exists it + carries its template with it, so a bundled item gives, equips, and drops like any other. + + A bundled id may not collide with the equipment catalog, the magic-item catalog, or another + bundled item: one item id names one thing per session. The town shop is the one place these do + not reach, because it stocks the shipped equipment lists.""" triggers: tuple[TriggerSpec, ...] = () + """The adventure's [`TriggerSpec`][osrlib.crawl.triggers.TriggerSpec]s: what happens when + something happens. The tuple's order is the firing order, so triggers matching one event fire in + the order you wrote them. + + Triggers do nothing on their own. A game plays them by registering an + [`Interpreter`][osrlib.crawl.interpreter.Interpreter] on its session, and an adventure with no + triggers plays the same whether or not one is registered. See the guide + [Gates, triggers, and quests](https://mmacy.github.io/osrlib-python/guides/gates-triggers-quests/).""" quests: tuple[QuestSpec, ...] = () + """The adventure's [`QuestSpec`][osrlib.crawl.quests.QuestSpec]s: what the party is trying to + accomplish. The session seeds one state block per quest when it is constructed, in this order, + and every walk over them follows it. + + Quest ids and trigger ids live in separate state blocks, so they are separate namespaces and a + quest may share an id with a trigger.""" @model_validator(mode="after") def _dungeon_ids_unique(self) -> Adventure: @@ -141,6 +232,10 @@ def _dungeon_ids_unique(self) -> Adventure: def dungeon(self, dungeon_id: str) -> DungeonSpec: """Return the dungeon with `dungeon_id`. + Use this to turn a dungeon id out of a command, an event, or a party location back into the + dungeon it names, rather than searching `dungeons` yourself. From there, + [`DungeonSpec.level`][osrlib.crawl.dungeon.DungeonSpec.level] gets you the level. + Args: dungeon_id: The dungeon id. @@ -148,7 +243,7 @@ def dungeon(self, dungeon_id: str) -> DungeonSpec: The dungeon spec. Raises: - ValueError: If no dungeon has that id. + ValueError: If no dungeon has that id. The message names the id. """ for dungeon in self.dungeons: if dungeon.id == dungeon_id: @@ -158,8 +253,10 @@ def dungeon(self, dungeon_id: str) -> DungeonSpec: def quest(self, quest_id: str) -> QuestSpec: """Return the quest with `quest_id`. - The resolution behind the quest lifecycle commands' closed id domain: an id - this cannot answer names no quest of this adventure. + This is what the quest lifecycle commands resolve against, which is what makes their id + domain closed: an id this cannot answer names no quest of this adventure, and the command is + refused. Call it to show a quest's objectives and rewards beside the state the session keeps + for it. Args: quest_id: The quest id. @@ -168,7 +265,7 @@ def quest(self, quest_id: str) -> QuestSpec: The quest spec. Raises: - ValueError: If no quest has that id. + ValueError: If no quest has that id. The message names the id. """ for quest in self.quests: if quest.id == quest_id: @@ -177,12 +274,11 @@ def quest(self, quest_id: str) -> QuestSpec: def _effective_monsters(adventure: Adventure, base: MonsterCatalog) -> tuple[MonsterCatalog, tuple[str, ...]]: - """Build the adventure's effective monster catalog: base ∪ bundled, first occurrence wins. + """Compose the base monster catalog with the adventure's bundled templates, first occurrence winning. - Always returns a usable catalog plus every skipped colliding id (empty means - clean) — both callers get a total answer, and each turns a non-empty collision - list into its own typed failure. An empty bundle returns the base catalog - object itself: no copy, no behavior change for adventures that bundle nothing. + Always returns a usable catalog plus the ids it skipped, so both callers get a whole answer and + each turns a non-empty collision list into the failure shape it needs. An empty bundle returns the + base catalog object itself, so an adventure that bundles nothing copies nothing. """ if not adventure.monsters: return base, () @@ -199,17 +295,15 @@ def _effective_monsters(adventure: Adventure, base: MonsterCatalog) -> tuple[Mon def _effective_equipment(adventure: Adventure, base: EquipmentCatalog) -> tuple[EquipmentCatalog, tuple[str, ...]]: - """Build the adventure's effective equipment catalog: base ∪ bundled, first occurrence wins. - - The monster helper's sibling, with one wider rule: an item id names one thing - per session, so a bundled id collides with the shipped magic-item ids as well - as the four equipment lists and the rest of the bundle. Collisions are skipped - before the catalog is built rather than after — `EquipmentCatalog`'s own - uniqueness validator raises a bare `ValueError`, the wrong failure shape for a - content problem. `treasure_weights` is an encumbrance table, not item - identity, so it passes through untouched and its ids are outside the rule. An - empty bundle returns the base catalog object itself: no copy, no behavior - change for adventures that bundle nothing. + """Compose the base equipment catalog with the adventure's bundled templates, first occurrence winning. + + The monster helper's sibling, with one wider rule: an item id names one thing per session, so a + bundled id collides with the shipped magic-item ids as well as with the four equipment lists and + the rest of the bundle. Collisions are skipped before the catalog is built rather than after, + because `EquipmentCatalog`'s own uniqueness validator raises a bare `ValueError` and a content + problem needs a typed one. `treasure_weights` is an encumbrance table rather than item identity, + so it passes through untouched and its ids fall outside the rule. An empty bundle returns the base + catalog object itself, so an adventure that bundles nothing copies nothing. """ if not adventure.items: return base, () @@ -268,14 +362,14 @@ def _validate_feature( def _dangling_condition_item( condition: ConditionSpec, equipment: EquipmentCatalog, magic: MagicItemCatalog ) -> str | None: - """The condition's item id when it names nothing that can ever be carried, else `None`. - - Equipment (base ∪ bundled) or magic item: exactly the union `has_item` - evaluates against, so an id neither catalog holds can never be satisfied and is - a dangling reference. Every other condition kind answers `None` — flag keys and - effect kinds are open domains and get no check, because a flag nobody writes is - authoring-tool territory, not a broken document. Gates and bare trigger - conditions both ask here, so the two can never disagree about the domain. + """Return the condition's item id when it names nothing the party could ever carry, else `None`. + + The domain is the composed equipment catalog and the magic-item catalog, which is exactly what + `has_item` evaluates against, so an id neither holds can never be satisfied and is a dangling + reference. Every other condition kind answers `None`: flag keys and effect kinds are open domains + and get no check, because a flag nobody writes is something an authoring tool warns about rather + than a broken document. Validation resolves gates and bare trigger conditions through this one + helper, so the two can never disagree about the domain. """ if not isinstance(condition, HasItemCondition): return None @@ -297,7 +391,7 @@ def _validate_gate( magic: MagicItemCatalog, errors: list[str], ) -> None: - """Resolve a gate's `has_item` id against the item domain the condition matches.""" + """Resolve a gate's `has_item` id against the item domain that condition matches.""" if gate is None: return dangling = _dangling_condition_item(gate.condition, equipment, magic) @@ -306,7 +400,7 @@ def _validate_gate( def _resolve_level(adventure: Adventure, dungeon_id: str, level_number: int) -> LevelSpec | None: - """The level a dungeon id and level number name, or `None` when either dangles.""" + """Return the level a dungeon id and level number name, or `None` when either dangles.""" try: return adventure.dungeon(dungeon_id).level(level_number) except ValueError: @@ -325,14 +419,13 @@ def _validate_clause( ) -> None: """Resolve one matching clause's pattern and condition references. - The one body behind every clause in a document — a trigger's `when` and - `conditions`, a quest's activation, an objective's completion, a hidden - objective's reveal — so the two authoring surfaces can never drift apart on what - resolves. `owner` is the subject the error lines name (`trigger 'lever-east'`, - `quest 'the-idol' objective 'return-home'`). + This is the one body behind every clause in a document: a trigger's `when` and `conditions`, a + quest's activation, an objective's completion, and a hidden objective's reveal. Sharing it keeps + the two authoring paths from drifting apart on what resolves. `owner` is the subject the error + lines name, like `trigger 'lever-east'` or `quest 'the-idol' objective 'return-home'`. - Flag keys stay unchecked at every site — the flag namespace is open by design, - and a key nobody writes is an authoring lint rather than a broken document. + Flag keys stay unchecked at every site. The flag namespace is open by design, so a key nobody + writes is something an authoring tool warns about rather than a broken document. """ if isinstance(pattern, AreaEnteredPattern | LevelEnteredPattern): level = _resolve_level(adventure, pattern.dungeon_id, pattern.level_number) @@ -372,13 +465,13 @@ def _validate_consequence( ) -> None: """Resolve one authored consequence's references and its character addressing. - The one body behind every consequence in a document — a trigger's consequences - and a quest's rewards alike. `site` is the subject the error lines name - (`trigger 'reward': consequence 0`, `quest 'the-idol': reward 0`). + This is the one body behind every consequence in a document, a trigger's consequences and a + quest's rewards alike. `site` is the subject the error lines name, like + `trigger 'reward': consequence 0` or `quest 'the-idol': reward 0`. """ if isinstance(consequence, GrantItem | GrantCoins | AwardXP): - # Character ids are allocated per session, so a document can never name - # one: authored consequences address the party through the selectors. + # Character ids are allocated per session, so a document can never name one: + # authored consequences address the party through the selectors. if consequence.character_id not in (PARTY_SELECTOR, FIRST_LIVING_SELECTOR): errors.append( f"{site} names character {consequence.character_id!r}; an authored consequence " @@ -401,7 +494,7 @@ def _validate_consequence( elif level.edge((consequence.x, consequence.y), consequence.direction).kind is not EdgeKind.DOOR: errors.append(f"{site} names no door at ({consequence.x}, {consequence.y}) {consequence.direction.value}") elif isinstance(consequence, PlaceParty): - # A town placement names the adventure's one town and needs no check; the + # A town placement names the adventure's one town and needs no check, and the # location model guarantees a dungeon location's fields travel together. location = consequence.location if location.dungeon_id is None or location.level_number is None: @@ -438,11 +531,11 @@ def _validate_quest( ) -> None: """Resolve one quest's clause and reward references, clause by clause. - Every clause a quest carries walks the shared clause check: the activation, each - objective's completion, and each hidden objective's reveal — the reveal named - apart from the completion so an error line says which of the two dangles. Rewards - walk the shared consequence check, so a quest's reward and a trigger's consequence - are held to the same references and the same party-selector rule. + Every clause a quest carries walks the shared clause check: the activation, each objective's + completion, and each hidden objective's reveal. The reveal is named apart from the completion so + an error line says which of the two dangles. Rewards walk the shared consequence check, so a + quest's reward and a trigger's consequence are held to the same references and the same + party-selector rule. """ owner = f"quest {quest.id!r}" if quest.activation is not None: @@ -470,49 +563,79 @@ def _validate_quest( def validate_adventure(adventure: Adventure, monsters: MonsterCatalog, equipment: EquipmentCatalog) -> None: - """Validate an adventure's cross-references — the fail-fast content gate. - - Checks: bundled monster ids colliding with the shipped catalog or each other, - and bundled item ids colliding with the equipment catalog, the shipped - magic-item catalog, or each other; then, per level: area cells and features in - bounds, feature ids unique, cache item ids resolving against the effective - equipment catalog, cache magic item ids resolving against the shipped - magic-item catalog ([`load_magic_items`][osrlib.data.load_magic_items] — - adventures bundle no magic items, so validation loads it itself), - keyed-encounter template ids (and any fixed spawn alignment) and inline - wandering-table monster ids resolving against the effective catalog, item ids - named by `has_item` gates on doors and transitions resolving against the - effective equipment catalog or the magic-item catalog, transition destinations - resolving to real cells, town travel entries naming real dungeons, and an - entrance existing somewhere in every dungeon. - - Then, per trigger: ids unique across the adventure; the pattern's area, level, - dungeon, item, and monster references resolving; the same item domain for - `has_item` conditions; and per consequence, granted item ids, spawned template - ids, a door edge at the cell a door write names, a placement landing on the - grid, and the rule that a consequence addressing a character does so through a - party selector — a session allocates character ids, so a document naming one is - naming something that cannot exist when it is read. - - Then, per quest: ids unique across the adventure (quest ids and trigger ids are - separate namespaces, and nothing here cross-checks them); per clause — the - activation, each objective's completion, each hidden objective's reveal — the - same pattern and condition references a trigger's clause resolves; and per - reward, the same references and the same party-selector rule a consequence gets. - The two surfaces share one clause check and one consequence check, so neither can - grow a reference the other fails to resolve. + """Check that every id in an adventure names something that exists. + + Call this while you author, as soon as you have an adventure to check. It follows every reference + in the tree and raises once, listing everything wrong, so you fix the whole document in one pass + instead of finding the next broken id on the next run. + [`GameSession.new`][osrlib.crawl.session.GameSession.new] runs it too, which is why a session can + never start on content that would fail partway through a delve. It changes nothing and returns + nothing, so a clean adventure comes back unchanged. + + It checks the following, roughly in the order the message lists them. Bundled monster ids, + against the shipped catalog and each other. Bundled item ids, against the equipment catalog, the shipped + magic-item catalog, and each other. Then, for each level: area cells and feature cells on the + grid, feature ids unique across the level, cache item ids and magic item ids resolving, keyed + encounter template ids resolving and any fixed alignment being one the template allows, inline + wandering-table monster ids resolving, the item ids named by `has_item` gates on doors and + transitions resolving, transitions landing on real cells of real levels, the town's travel entries + naming real dungeons, and every dungeon having an entrance on some level. Then, for each trigger: + its id unique, the area, level, dungeon, item, and monster its pattern names resolving, and for + each consequence the item it grants, the monster it spawns, a door actually standing at the cell a + door-state consequence names, and a placement landing on the grid. Then, for each quest: its id unique, and the + same checks over every clause it has (its activation, each objective's completion, each hidden + objective's reveal) and every reward it pays. + + One rule is about authoring rather than about a dangling id. A consequence that addresses a + character has to do so through a party selector, because character ids are allocated when a + session starts: a document naming one is naming something that cannot exist at the time it is + read. + + Magic items are the one catalog you do not pass. Adventures bundle no magic items, so validation + loads the shipped one itself with [`load_magic_items`][osrlib.data.load_magic_items]. + + Quest ids and trigger ids are separate namespaces, so nothing here compares them and a quest may + share an id with a trigger. Args: - adventure: The adventure to validate. - monsters: The *base* monster catalog — validation unions it internally - with the adventure's bundled templates, and every monster reference - resolves against that union. - equipment: The *base* equipment catalog — validation unions it internally - with the adventure's bundled templates, and every cache item reference - resolves against that union. + adventure: The adventure to check. + monsters: The base monster catalog, usually [`load_monsters`][osrlib.data.load_monsters]. The + check composes it with the adventure's bundled templates itself, and every monster + reference resolves against that union, so pass the shipped catalog rather than one you + have already merged. + equipment: The base equipment catalog, usually + [`load_equipment`][osrlib.data.load_equipment]. Composed with the adventure's bundled + items the same way. Raises: - ContentValidationError: Listing every dangling reference found. + ContentValidationError: If anything is wrong. The message lists every problem found, one per + line, each naming the dungeon, level, and object it sits on. + + Examples: + ```python + from osrlib.crawl.adventure import Adventure, TownSpec, validate_adventure + from osrlib.crawl.dungeon import AreaSpec, DungeonSpec, KeyedEncounter, KeyedMonster, LevelSpec + from osrlib.data import load_equipment, load_monsters + from osrlib.errors import ContentValidationError + + hall = AreaSpec( + id="hall", + cells=((0, 0),), + encounter=KeyedEncounter(monsters=(KeyedMonster(template_id="grue", count_fixed=1),)), + ) + level = LevelSpec(number=1, width=1, height=1, entrance=(0, 0), areas=(hall,)) + broken = Adventure( + name="A First Delve", + town=TownSpec(name="Threshold"), + dungeons=(DungeonSpec(id="crypt", levels=(level,)),), + ) + try: + validate_adventure(broken, load_monsters(), load_equipment()) + except ContentValidationError as error: + print(error) + # adventure validation failed: + # crypt level 1: area 'hall' references unknown monster 'grue' + ``` """ errors: list[str] = [] magic = load_magic_items() diff --git a/src/osrlib/crawl/content_pack.py b/src/osrlib/crawl/content_pack.py index 55cdcfb..aa31361 100644 --- a/src/osrlib/crawl/content_pack.py +++ b/src/osrlib/crawl/content_pack.py @@ -1,37 +1,40 @@ -"""Content packs: portable, geometry-free keyed content an editor can carry between adventures. - -A [`ContentPack`][osrlib.crawl.content_pack.ContentPack] is finished room content -with the geometry left behind: sections of entries that mirror -[`AreaSpec`][osrlib.crawl.dungeon.AreaSpec] minus its cells, each section -optionally carrying a level's [`WanderingSpec`][osrlib.crawl.dungeon.WanderingSpec], -plus the bundled [`MonsterTemplate`][osrlib.core.monsters.MonsterTemplate]s the -entries' encounters and the sections' wandering tables reference — the pack's -closure. Item templates are deliberately not part of that closure: a pack's -features reference the shipped equipment catalog only, since a bundled item id -belongs to the one adventure that carries it and would arrive dangling in any -other. Packs are how an authoring -tool moves stocked rooms from one adventure to another: the consumer writes an -entry's content into a target area it already has, so a pack never places cells, -transitions, or any other geometry. - -Identity is the contract consumers stand on: section ids, entry ids (pack-wide), -and bundled monster ids are each unique, enforced at construction — a pack that -breaks them refuses to load rather than lingering as a diagnostic. Dangling -references are legal by the same posture as -[`Adventure`][osrlib.crawl.adventure.Adventure]: an encounter may name a template -neither the pack nor the shipped catalog carries, and -[`validate_content_pack`][osrlib.crawl.content_pack.validate_content_pack] -reports such gaps as structured -[`PackFinding`][osrlib.crawl.content_pack.PackFinding]s instead of raising — the -first consumer is a panel that lists findings, not a gate. +"""Content packs: finished rooms you can carry from one adventure to another. + +A [`ContentPack`][osrlib.crawl.content_pack.ContentPack] is room content with the geometry left out. +Where an [`AreaSpec`][osrlib.crawl.dungeon.AreaSpec] binds an encounter, a trap, treasure, and +features to particular cells of a particular level, a pack entry carries the same content slots and +no cells at all. That is what makes it portable: an authoring tool reads an entry and writes its +content into whatever area the target adventure already has, so a pack never places geometry and +never has to agree with a map it has not seen. + +Sections group the entries by dungeon level, and each section can carry that level's +[`WanderingSpec`][osrlib.crawl.dungeon.WanderingSpec]. Alongside them, the pack bundles the +[`MonsterTemplate`][osrlib.core.monsters.MonsterTemplate]s its encounters and wandering tables +reference beyond the shipped catalog, which is the pack's closure: what it needs that the engine does +not already ship. Item templates are deliberately outside that closure, because a bundled item id +belongs to the one adventure that carries it and would arrive dangling anywhere else, so a pack's +features reference the shipped equipment catalog only. + +Ids are what an authoring tool addresses a pack by, so all three sets are checked when the pack is +constructed: section ids, entry ids (unique across the whole pack, not only within a section), and +bundled monster ids. A pack that breaks any of the three fails to construct rather than surviving as +a warning. + +A reference that resolves to nothing is legal here, the way it is in an +[`Adventure`][osrlib.crawl.adventure.Adventure] you have not finished writing. An encounter may name a +template neither the pack nor the shipped catalog holds, and +[`validate_content_pack`][osrlib.crawl.content_pack.validate_content_pack] hands those back as +[`PackFinding`][osrlib.crawl.content_pack.PackFinding] models instead of raising, so the tool that +reads a pack can show you the gaps and let you decide. Packs serialize as stamped `"content_pack"` documents -([`CONTENT_PACK_KIND`][osrlib.crawl.content_pack.CONTENT_PACK_KIND]), the -longest-lived artifacts in the document family, and own their acceptance rules: -a document stamped by an older schema version is accepted on load, one stamped -by a newer version fails with [`SaveVersionError`][osrlib.errors.SaveVersionError], -and any write re-stamps at the current schema and engine versions — a loaded -older pack saves as a current one. +([`CONTENT_PACK_KIND`][osrlib.crawl.content_pack.CONTENT_PACK_KIND]) and have their own acceptance +rules, because a pack is meant to be kept and passed around longer than a save file is. A document stamped by an older +schema version loads, one stamped by a newer version fails with +[`SaveVersionError`][osrlib.errors.SaveVersionError], and every write re-stamps at the current schema +and engine versions, so an older pack you load and save comes back current. + +Typical usage: ```python from osrlib.crawl.content_pack import ContentPack, ContentPackEntry, PackSection, validate_content_pack @@ -83,17 +86,22 @@ ] CONTENT_PACK_KIND = "content_pack" -"""The stamped-document kind for serialized content packs.""" +"""The `kind` stamped on a serialized content pack. + +Every document osrlib writes has a kind, and this is the pack's. +[`ContentPack.to_document`][osrlib.crawl.content_pack.ContentPack.to_document] stamps it and +[`from_document`][osrlib.crawl.content_pack.ContentPack.from_document] refuses anything else, so +handing a save file to a pack loader fails with a message naming both kinds rather than producing a +nonsense pack. Read it to route a document you have just parsed to the right loader.""" _CODE_PATTERN = re.compile(r"[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+") def _rewrite_dead_treasure_triggers(payload: dict) -> None: - """Rewrite a pre-3 payload's `treasure`+`enter` traps to `"open"`, in place. + """Rewrite a pre-3 payload's `treasure` traps with `trigger="enter"` to `"open"`, in place. - The pack-side twin of the save chain's `_migrate_2_to_3`: a treasure trap - can sit only on an entry's features, and the engine never read the dead - trigger, so the rewrite is lossless. + The pack-side twin of the save chain's `_migrate_2_to_3`. A treasure trap can sit only on an + entry's features, and the engine never read the dead trigger value, so the rewrite loses nothing. """ for section in payload.get("sections", ()): for entry in section.get("entries", ()): @@ -104,25 +112,53 @@ def _rewrite_dead_treasure_triggers(payload: dict) -> None: class ContentPackEntry(BaseModel): - """One portable room: [`AreaSpec`][osrlib.crawl.dungeon.AreaSpec] minus its geometry. - - The entry carries the content slots an area has — prose, encounter, trap, - treasure, features — and nothing that binds to a grid: there are no cells, - and a consumer writes the carried slots into a target area it already has. - An entry trap must be a room trap, the same rule `AreaSpec` enforces; the - treasure-trap coupling needs no restatement because it lives on - [`FeatureSpec`][osrlib.crawl.dungeon.FeatureSpec] itself. + """One portable room: an [`AreaSpec`][osrlib.crawl.dungeon.AreaSpec] with its geometry left out. + + An entry carries the content slots an area has, which is prose, an encounter, a trap, treasure, + and features, and nothing that binds to a grid. There are no cells. A tool applying a pack picks + a target area the adventure already has and writes these slots onto it, so the same entry works on + a corridor dead end in one adventure and a vaulted hall in another. + + An entry's trap has to be a room trap, the same rule `AreaSpec` enforces. Treasure traps need no + rule here, because they belong to a [`FeatureSpec`][osrlib.crawl.dungeon.FeatureSpec] and that + model already enforces it. + + Attributes: + id: The entry's id, unique across the pack. + name: The room's name. + description: Prose for your front end. + encounter: The monsters waiting in the room. + trap: A room trap over the whole room. + treasure: Generated treasure with nothing guarding it. + features: The keyed things in the room. + + Raises: + ValueError: If `trap` is not a room trap. """ model_config = ConfigDict(frozen=True) id: str = Field(min_length=1) + """The entry's id, which has to be unique across the whole pack rather than only within its + section. An authoring tool addresses an entry by this alone, so a pack with two entries of the + same id fails to construct. It cannot be empty.""" name: str = "" + """The room's name, for a panel to list and for the target area to take.""" description: str = "" + """Prose describing the room, for the target area to take.""" encounter: KeyedEncounter | None = None + """The monsters waiting in the room, or `None`. Any template id it names that neither the shipped + catalog nor the pack's own `monsters` holds is what + [`validate_content_pack`][osrlib.crawl.content_pack.validate_content_pack] reports as a gap.""" trap: TrapSpec | None = None + """A trap over the whole room, or `None`. It has to be a room trap.""" treasure: AreaTreasureSpec | None = None + """Treasure the engine rolls on first entry, or `None`. It names treasure type letters or the + unguarded band, so it carries across adventures without needing anything else.""" features: tuple[FeatureSpec, ...] = () + """The keyed things in the room: caches, tricks, and custom content. Their `item_ids` and + `magic_item_ids` resolve against the shipped catalogs only, since a pack bundles no items of its + own.""" @model_validator(mode="after") def _trap_kind_matches(self) -> ContentPackEntry: @@ -132,39 +168,83 @@ def _trap_kind_matches(self) -> ContentPackEntry: class PackSection(BaseModel): - """A pack's level grouping: entries plus the level's optional wandering table. - - Sections are structural because three consumers operate on a level grouping — - a panel's level rows, wandering's level scope, and a level-scoped capture. - Entry ids are unique pack-wide, so consumers address entries by id alone; - the section contributes presentation grouping and the wandering slot. + """A pack's level grouping: the entries for one dungeon level, plus that level's wandering table. + + Sections exist because three things work on a level at a time: a panel showing the pack level by + level, the wandering-monster check, whose scope is a level, and a capture that pulls one level's + rooms out of an adventure. Entry ids are unique across the pack, so an authoring tool never needs + the section to address an entry. What the section adds is the grouping and the wandering slot. + + Attributes: + id: The section's id, unique in the pack. + label: The section's display name. + entries: The rooms in this section. + wandering: The level's wandering-monster check. """ model_config = ConfigDict(frozen=True) id: str = Field(min_length=1) + """The section's id, unique within the pack. It cannot be empty.""" label: str = "" + """What a panel calls this section: `"Level 1"`, `"The lower caves"`.""" entries: tuple[ContentPackEntry, ...] = () + """The rooms in this section. Their ids are unique across the whole pack, not only here.""" wandering: WanderingSpec | None = None + """The level's wandering-monster check, or `None` for a section that has none. An authoring tool + writes it onto the target level's `wandering`. Monster ids in its table are part of what the + pack's closure has to cover.""" class ContentPack(BaseModel): - """A content pack: sections of geometry-free entries plus their monster closure. - - `monsters` bundles the [`MonsterTemplate`][osrlib.core.monsters.MonsterTemplate]s - the pack's encounters and wandering tables reference beyond the shipped - catalog. `id` defaults empty: a pack derived on the fly takes its identity - from its source, and only a persisted pack mints an id of its own. + """A content pack: sections of geometry-free rooms, plus the monsters they need. + + Build one by hand, or have an authoring tool capture it out of an adventure you have already + written. Check it with + [`validate_content_pack`][osrlib.crawl.content_pack.validate_content_pack], write it out with + [`to_document`][osrlib.crawl.content_pack.ContentPack.to_document], and read it back with + [`from_document`][osrlib.crawl.content_pack.ContentPack.from_document]. What you do with the + entries is yours: nothing in osrlib applies a pack to an adventure, because only your tool has the + mapping from an entry to the target area it belongs on. + + A pack is frozen, and its three id rules are checked when you construct it rather than when you + validate it, so a pack you have in hand is one whose ids are already sound. + + Attributes: + id: The pack's own id, when it has one. + name: The pack's name. + description: Prose about the pack. + author: Who wrote it. + sections: The level groupings holding the entries. + monsters: The monster templates the entries need. + + Raises: + ValueError: If two sections share an id, if two entries share an id anywhere in the pack, or + if two bundled monsters share an id. """ model_config = ConfigDict(frozen=True) id: str = "" + """The pack's own id, empty by default. A pack derived on the fly takes its identity from + whatever it was derived from, so only a pack somebody saved needs to mint one.""" name: str = "" + """The pack's name, for a panel to show.""" description: str = "" + """Prose about what the pack contains and what it is for.""" author: str = "" + """Who wrote the pack. Nothing reads it, and it travels with the document as credit.""" sections: tuple[PackSection, ...] = () + """The pack's level groupings. Section ids are unique, and entry ids are unique across all of + them together.""" monsters: tuple[MonsterTemplate, ...] = () + """The [`MonsterTemplate`][osrlib.core.monsters.MonsterTemplate]s the pack's encounters and + wandering tables need beyond the shipped catalog. This is the pack's closure: bundle what your + rooms reference and the pack arrives self-contained. + + There is no matching item bundle, because a bundled item id belongs to the adventure that carries + it. A pack's features reference the shipped equipment and magic-item catalogs, and an entry naming + an adventure's bundled item is reported as a gap rather than carried along.""" @model_validator(mode="after") def _identities_are_unique(self) -> ContentPack: @@ -180,13 +260,27 @@ def _identities_are_unique(self) -> ContentPack: return self def to_document(self) -> dict[str, object]: - """Serialize to a stamped document with schema and engine versions. + """Serialize the pack to a stamped document you can write to a file. + + The result is plain JSON-compatible data: an envelope containing the kind, the schema version, + the engine version, and the pack itself as the payload. Hand it to `json.dump`, put it in a + database, or send it over a wire. Read it back with + [`from_document`][osrlib.crawl.content_pack.ContentPack.from_document]. - A write always stamps the current versions: re-serializing a pack loaded - from an older document re-stamps it as a current one. + A write always stamps the current versions, so a pack you loaded from an older document + saves as a current one. Returns: The stamped document envelope wrapping the serialized pack. + + Examples: + ```python + from osrlib.crawl.content_pack import ContentPack + + document = ContentPack(name="The gnawing dark").to_document() + print(document["kind"]) + # content_pack + ``` """ return stamp_document(CONTENT_PACK_KIND, self.model_dump(mode="json")) @@ -194,12 +288,16 @@ def to_document(self) -> dict[str, object]: def from_document(cls, document: Mapping[str, object]) -> ContentPack: """Load a content pack from a stamped document. - A `schema_version` older than the current one is accepted: pack payload - changes are additive within a version, and the one narrowing — schema 3 - made [`TrapSpec`][osrlib.crawl.dungeon.TrapSpec] reject `trigger="enter"` - on a treasure trap — is repaired in place, rewriting the dead value to - `"open"` exactly as the save migration does. Unknown payload fields are - ignored, per the additive-schema contract. + This is the other half of [`to_document`][osrlib.crawl.content_pack.ContentPack.to_document]: + parse your file however you like, then hand the resulting mapping here. The pack's identity + rules are checked on the way in, so a document with duplicate ids fails here rather than + later. + + An older `schema_version` is accepted, because pack payloads only grow within a version. + There has been one narrowing: schema 3 made [`TrapSpec`][osrlib.crawl.dungeon.TrapSpec] + refuse `trigger="enter"` on a treasure trap, and a pre-3 document carrying that combination is + repaired in place on the way in, rewritten to `"open"` exactly as the save migration does. + Payload fields this version doesn't recognize are ignored. Args: document: A document produced by @@ -209,10 +307,11 @@ def from_document(cls, document: Mapping[str, object]) -> ContentPack: The reconstructed pack. Raises: - ContentValidationError: If the envelope or payload is malformed or of - the wrong kind. - SaveVersionError: If the document's schema version is newer than this - library understands. + ContentValidationError: If the envelope is malformed or is not a `content_pack` + document, or if the payload fails validation. + SaveVersionError: If the document's schema version is newer than this library + understands, which means the pack was written by a later osrlib than the one reading + it. """ payload = check_document(document, CONTENT_PACK_KIND) schema_version = document["schema_version"] # an int: check_document vetted the envelope @@ -225,19 +324,34 @@ def from_document(cls, document: Mapping[str, object]) -> ContentPack: class PackFinding(BaseModel): - """A structured self-containment gap in a content pack. + """One reference a content pack does not cover: what is missing, and where. + + [`validate_content_pack`][osrlib.crawl.content_pack.validate_content_pack] returns these. They are + data rather than errors, so a panel can list them beside the pack and let the author decide + whether a gap matters. To refuse a pack that has any gap, check for a non-empty result. + + Attributes: + code: What kind of gap this is. + message: The specifics, in English. + entry_id: The entry the gap sits on, or `None` for a section-level one. - `code` is dotted snake_case namespaced by subsystem, the - [`Rejection`][osrlib.core.validation.Rejection] discipline. `entry_id` names - the entry the gap sits on, or `None` for a section-scoped gap (a wandering - table's); `message` carries the specifics either way. + Raises: + ValueError: If `code` is not two or more dot-separated snake_case segments. """ model_config = ConfigDict(frozen=True) code: str + """What kind of gap this is, as a dotted snake_case code namespaced by subsystem, like + `"pack.encounter.unknown_monster"`. It is the part a program reads, and it follows the same rule + [`Rejection`][osrlib.core.validation.Rejection] codes do, so a front end can switch on it instead + of parsing the message.""" message: str + """The specifics in English: which entry, which feature, which id. Written for a person reading a + list of findings.""" entry_id: str | None = None + """The entry the gap sits on, or `None` when the gap belongs to a section rather than an entry, + which today means a wandering table's monster. `message` names the section in that case.""" @field_validator("code") @classmethod @@ -253,30 +367,51 @@ def _code_must_be_dotted_snake_case(cls, value: str) -> str: def validate_content_pack( pack: ContentPack, monsters: MonsterCatalog, equipment: EquipmentCatalog ) -> tuple[PackFinding, ...]: - """Report a pack's self-containment gaps — references its closure does not cover. - - Checks every monster reference (keyed-encounter lines and wandering-table - rows) against the union of the shipped catalog and the pack's bundled - monsters, every feature's `item_ids` against the shipped equipment catalog, - and every feature's `magic_item_ids` against the shipped magic-item catalog - ([`load_magic_items`][osrlib.data.load_magic_items] — packs bundle no magic - items, so the check loads it itself). - Findings are data, not errors: a dangling reference is legal in a pack - exactly as it is while editing an adventure, and a caller wanting a gate - checks for a non-empty result. + """Report the references a content pack does not cover on its own. + + Call this before you share a pack, or whenever a panel needs to show what is still missing. It + follows every id in the pack and reports the ones that resolve nowhere, so you can bundle the + monster, change the id, or decide the gap is acceptable for the adventures you mean to apply the + pack to. + + It checks every monster reference, which is the keyed-encounter lines and the wandering-table + rows, against the shipped catalog composed with the pack's own `monsters`. It checks every + feature's `item_ids` against the shipped equipment catalog and every feature's `magic_item_ids` + against the shipped magic-item catalog, which it loads itself with + [`load_magic_items`][osrlib.data.load_magic_items] because packs bundle no magic items. + + It never raises. A dangling reference in a pack is as legal as one in an adventure you are still + writing, so the gaps come back as data. Compare it with + [`validate_adventure`][osrlib.crawl.adventure.validate_adventure], which does raise, because by + then the content is about to be played. Args: pack: The pack to check. - monsters: The *base* monster catalog — the check unions it with - `pack.monsters` internally. - equipment: The shipped equipment catalog feature contents resolve - against. Packs carry no item bundle of their own, so there is nothing - to union here: an entry naming an item some adventure bundles reports - as a gap, which is the honest answer for a portable pack. + monsters: The base monster catalog, usually [`load_monsters`][osrlib.data.load_monsters]. The + check composes it with `pack.monsters` itself. + equipment: The shipped equipment catalog, usually + [`load_equipment`][osrlib.data.load_equipment]. Nothing is composed with it, because a + pack carries no items of its own. An entry naming an item that some adventure bundles is + reported as a gap, which is the right answer for content meant to travel. Returns: - One finding per gap, in section and entry order; empty means the pack is - self-contained. + One finding per gap, in section then entry order. Empty means the pack is self-contained. + + Examples: + ```python + from osrlib.crawl.content_pack import ContentPack, ContentPackEntry, PackSection, validate_content_pack + from osrlib.crawl.dungeon import KeyedEncounter, KeyedMonster + from osrlib.data import load_equipment, load_monsters + + entry = ContentPackEntry( + id="guard-post", + encounter=KeyedEncounter(monsters=(KeyedMonster(template_id="grue", count_fixed=1),)), + ) + pack = ContentPack(name="The gnawing dark", sections=(PackSection(id="level-1", entries=(entry,)),)) + for finding in validate_content_pack(pack, load_monsters(), load_equipment()): + print(finding.code, finding.entry_id, finding.message) + # pack.encounter.unknown_monster guard-post entry 'guard-post' references unknown monster 'grue' + ``` """ known_monsters = {template.id for template in monsters.monsters} known_monsters.update(template.id for template in pack.monsters) diff --git a/src/osrlib/crawl/dungeon.py b/src/osrlib/crawl/dungeon.py index 19a6cd2..80df3f1 100644 --- a/src/osrlib/crawl/dungeon.py +++ b/src/osrlib/crawl/dungeon.py @@ -1,18 +1,48 @@ -"""The multi-level dungeon grid: cells, edges, doors, areas, traps, and state. - -Authored content is frozen; play mutates a state overlay — the template/instance -split applied to space. [`DungeonSpec`][osrlib.crawl.dungeon.DungeonSpec] and its -levels, areas, features, and traps are game content, never mutated; -[`DungeonState`][osrlib.crawl.dungeon.DungeonState] carries everything play changes -(explored cells, door state, sprung traps, dropped piles, the party's location) and -serializes into saves. - -Geometry, as API convention: cells are 10' squares addressed `(x, y)` with -`x` increasing east and `y` increasing south from the level's northwest corner. -Edges are the single spatial truth for walls and doors: an `edges` map keyed by the -canonical edge key (a cell plus `north` or `west`, so each physical edge has exactly -one entry). An edge absent from the map is a wall — authored content declares its -passages (`open`) and doors explicitly — and the level boundary is implicitly wall. +"""The dungeon: the grid you author, and the overlay play writes over it. + +This module holds both halves of a dungeon. [`DungeonSpec`][osrlib.crawl.dungeon.DungeonSpec] and +everything under it (levels, edges, areas, features, traps, transitions) is authored content: frozen +models you build, hand to an [`Adventure`][osrlib.crawl.adventure.Adventure], and never change again. +[`DungeonState`][osrlib.crawl.dungeon.DungeonState] is the mutable overlay the running session writes +alongside it: which cells the party has walked, which doors stand open, which traps have gone off, +what has been dropped on the floor, and where the party is standing. The overlay is what a save file +carries, and it refers to the content by string references rather than by object, so it serializes +flat. + +You build the geometry here and assemble it into an adventure in +[`osrlib.crawl.adventure`][osrlib.crawl.adventure]. You never construct `DungeonState` yourself: +[`GameSession.new`][osrlib.crawl.session.GameSession.new] makes one, and you read it through the +session's views. The long form, with a complete program you can run, is the guide +[Building an adventure](https://mmacy.github.io/osrlib-python/getting-started/building-an-adventure/). + +The geometry, which every member here assumes: a level is a grid of 10-foot cells addressed `(x, y)`, +with `x` increasing east and `y` increasing south from `(0, 0)` in the northwest corner. Walls are +the default, and you declare the exceptions. An `edges` map holds one entry per physical edge that is +something other than wall, keyed by [`edge_key`][osrlib.crawl.dungeon.edge_key] so the boundary +between two cells has exactly one entry no matter which side you name it from. An edge with no entry +is wall, and so is the level boundary. + +Typical usage: + +```python +from osrlib.crawl.dungeon import Direction, DungeonSpec, Edge, EdgeKind, LevelSpec, edge_key + +# A two-cell corridor running west to east, entered at the west end. +corridor = LevelSpec( + number=1, + width=2, + height=1, + entrance=(0, 0), + edges={edge_key((0, 0), Direction.EAST): Edge(kind=EdgeKind.OPEN)}, +) +crypt = DungeonSpec(id="crypt", name="The Old Crypt", levels=(corridor,)) + +print(edge_key((0, 0), Direction.EAST)) +# 1,0:west + +print(crypt.level(1).edge((0, 0), Direction.NORTH).kind) +# EdgeKind.WALL +``` """ from collections.abc import Iterable @@ -62,29 +92,54 @@ ] Position = tuple[int, int] -"""A cell address: `(x, y)`, x increasing east, y increasing south, from (0, 0).""" +"""A cell address on a level's grid: `(x, y)`. + +`x` increases east and `y` increases south from `(0, 0)` in the level's northwest corner, and one +cell is 10 feet on a side. Write one as a plain tuple, `(3, 0)`. A position is only meaningful +against a particular level, and one that names a cell off the grid is out of bounds rather than +invalid: [`LevelSpec.in_bounds`][osrlib.crawl.dungeon.LevelSpec.in_bounds] is how you ask, and +[`validate_adventure`][osrlib.crawl.adventure.validate_adventure] is what catches an authored one +that lands outside.""" class Direction(StrEnum): - """The four grid directions. + """The four grid directions the party faces and moves in. + + This is the direction vocabulary the whole crawl uses: which way + [`MoveParty`][osrlib.crawl.commands.MoveParty] steps, which side of a cell + [`OpenDoor`][osrlib.crawl.commands.OpenDoor] works on, which way a transition faces the party on + arrival. There is no up or down here. Between levels is a + [`TransitionSpec`][osrlib.crawl.dungeon.TransitionSpec]. - The wire values are lowercase — they serialize into commands, events, and - saves; changing them is a `schema_version` bump. + The wire values are lowercase and they serialize into commands, events, and saves, so changing + one is a `schema_version` bump. """ NORTH = "north" + """Decreasing `y`: toward the top of the map.""" EAST = "east" + """Increasing `x`: toward the right of the map.""" SOUTH = "south" + """Increasing `y`: toward the bottom of the map.""" WEST = "west" + """Decreasing `x`: toward the left of the map.""" @property def vector(self) -> tuple[int, int]: - """The `(dx, dy)` step for one cell in this direction.""" + """The `(dx, dy)` step for one cell in this direction. + + Add it to a position to get the neighbour, or call + [`step`][osrlib.crawl.dungeon.step], which does the addition for you. + """ return _VECTORS[self] @property def opposite(self) -> Direction: - """The reverse direction.""" + """The reverse direction. + + The direction you came from is the opposite of the one you went. A transition's `to_facing` + and a door seen from the far side are both read this way. + """ return _OPPOSITES[self] @@ -106,12 +161,26 @@ def opposite(self) -> Direction: def step(position: Position, direction: Direction) -> Position: """Return the cell one step from `position` in `direction`. + This is grid arithmetic, and it takes no account of walls, levels, or the party. Ask + [`LevelSpec.in_bounds`][osrlib.crawl.dungeon.LevelSpec.in_bounds] whether the answer is on the + grid and [`LevelSpec.edge`][osrlib.crawl.dungeon.LevelSpec.edge] whether the party could get + there. Use it while authoring to walk a corridor cell by cell, or in a front end to work out + which cell a click landed on. + Args: position: The starting cell. direction: The direction to step. Returns: - The adjacent cell address (which may lie outside the level). + The adjacent cell address, which may lie outside the level. + + Examples: + ```python + from osrlib.crawl.dungeon import Direction, step + + print(step((1, 1), Direction.NORTH)) + # (1, 0) + ``` """ dx, dy = direction.vector return (position[0] + dx, position[1] + dy) @@ -120,16 +189,33 @@ def step(position: Position, direction: Direction) -> Position: def edge_key(position: Position, direction: Direction) -> str: """Return the canonical key for the edge on `direction`'s side of `position`. - Each physical edge has exactly one entry: the key is a cell plus `north` or - `west`, so a cell's south edge is its southern neighbour's north edge and its - east edge the eastern neighbour's west. The format is `"{x},{y}:{side}"`. + Use this whenever you write a [`LevelSpec.edges`][osrlib.crawl.dungeon.LevelSpec] map, so you + don't have to work out which of the two neighbouring cells owns the boundary between them. Every + physical edge has exactly one key: a cell plus `north` or `west`. A cell's south edge is its + southern neighbour's north edge, and its east edge is its eastern neighbour's west edge, so + `edge_key((0, 0), Direction.EAST)` and `edge_key((1, 0), Direction.WEST)` are the same string. + + The format is `"{x},{y}:{side}"`, which is what you see in a serialized adventure and what + [`LevelSpec.edge`][osrlib.crawl.dungeon.LevelSpec.edge] looks up for you at read time. Args: position: The cell. direction: Which of the cell's four edges. Returns: - The canonical edge key. + The canonical edge key. It is not checked against any level, so a key for a cell off the grid + comes back the same way. + + Examples: + ```python + from osrlib.crawl.dungeon import Direction, edge_key + + print(edge_key((0, 0), Direction.EAST)) + # 1,0:west + + print(edge_key((1, 0), Direction.WEST)) + # 1,0:west + ``` """ x, y = position if direction is Direction.SOUTH: @@ -140,25 +226,46 @@ def edge_key(position: Position, direction: Direction) -> str: def cell_ref(dungeon_id: str, level_number: int, position: Position) -> str: - """Return the structured cell reference used by location-bound effects. + """Return the reference string that names one cell across a whole adventure. - The format is `cell:{dungeon}:{level}:{x},{y}` — an - [`ActiveEffect.target_ref`][osrlib.core.effects.ActiveEffect] in this form - anchors the effect to a dungeon cell. + An `edges` key locates a cell inside one level. A cell reference locates it inside the game: it + carries the dungeon and the level too, which is what the state overlay and the effects system + need. Drop piles key on this in [`DungeonState.piles`][osrlib.crawl.dungeon.DungeonState], and an + [`ActiveEffect.target_ref`][osrlib.core.effects.ActiveEffect] in this form anchors an effect to a + dungeon cell rather than to a creature. + + The format is `"cell:{dungeon}:{level}:{x},{y}"`. Args: - dungeon_id: The dungeon id. + dungeon_id: The dungeon id, as it appears on its + [`DungeonSpec`][osrlib.crawl.dungeon.DungeonSpec]. level_number: The 1-based level number. position: The cell. Returns: The cell reference string. + + Examples: + ```python + from osrlib.crawl.dungeon import cell_ref + + print(cell_ref("crypt", 1, (2, 3))) + # cell:crypt:1:2,3 + ``` """ return f"cell:{dungeon_id}:{level_number}:{position[0]},{position[1]}" def edge_ref(dungeon_id: str, level_number: int, position: Position, direction: Direction) -> str: - """Return the state-overlay reference for one physical edge (door bookkeeping). + """Return the reference string that names one physical edge across a whole adventure. + + This is what [`DungeonState.doors`][osrlib.crawl.dungeon.DungeonState] keys on, so it is how you + look a door's live open, wedged, discovered, and unlocked flags up from a cell and a direction. + It canonicalizes the same way [`edge_key`][osrlib.crawl.dungeon.edge_key] does, so both sides of + a door produce one reference and the two sides can never disagree about its state. Pass the + result to [`DungeonState.door`][osrlib.crawl.dungeon.DungeonState.door]. + + The format is `"{dungeon}:{level}:{x},{y}:{side}"`. Args: dungeon_id: The dungeon id. @@ -167,55 +274,131 @@ def edge_ref(dungeon_id: str, level_number: int, position: Position, direction: direction: Which of the cell's four edges. Returns: - The edge reference string, canonicalized like - [`edge_key`][osrlib.crawl.dungeon.edge_key]. + The edge reference string, canonicalized like [`edge_key`][osrlib.crawl.dungeon.edge_key]. + + Examples: + ```python + from osrlib.crawl.dungeon import Direction, edge_ref + + print(edge_ref("crypt", 1, (0, 0), Direction.EAST)) + # crypt:1:1,0:west + ``` """ return f"{dungeon_id}:{level_number}:{edge_key(position, direction)}" class EdgeKind(StrEnum): - """What occupies an edge between two cells.""" + """What occupies an edge between two cells. + + This is the `kind` of an [`Edge`][osrlib.crawl.dungeon.Edge] entry, and it is what makes the + boundary between two cells passable or not. + """ OPEN = "open" + """Nothing in the way: the party walks across.""" WALL = "wall" + """Solid: the party cannot cross. This is also what a level reports for an edge with no entry at + all, so you only write it when you want the wall stated outright.""" DOOR = "door" + """A door stands here, and the entry contains the [`DoorSpec`][osrlib.crawl.dungeon.DoorSpec] that + describes it. An edge of this kind must include one, and no other kind may.""" class DoorSpec(BaseModel): - """A door on an edge, exactly as authored. - - `kind="secret"` doors are invisible until discovered (a successful secret-door - search marks them in the state overlay). `stuck` and `locked` are the authored - starting conditions; play mutates the overlay, never this spec. - - `requires` is an optional authored gate — a stateless predicate - ([`GateSpec`][osrlib.crawl.gates.GateSpec]) evaluated whenever the party - attempts to open or force the door, after every mundane refusal. It is - orthogonal to `locked`: a door carrying both requires both, - [`PickLock`][osrlib.crawl.commands.PickLock] addresses only the lock, and - [`SetDoorState`][osrlib.crawl.commands.SetDoorState] rewrites the overlay - without ever touching the gate. A door standing open admits passage - unchecked — the gate guards the opening, not the doorway — so a door set open - lets the party through until it closes again, after which the gate applies - once more. + """A door on an edge, as you authored it. + + Put one on an [`Edge`][osrlib.crawl.dungeon.Edge] whose `kind` is `door`. Everything here is the + door's starting condition, and play never writes back to it. What the party does to the door goes + into [`DoorState`][osrlib.crawl.dungeon.DoorState] in the overlay instead, which is where you + read whether a door is open right now. + + Attributes: + kind: Whether the door is visible from the start or has to be found. + stuck: Whether the door needs forcing before it opens. + locked: Whether the door needs a key or a thief before it opens. + starts_open: Whether the door stands open when the party first arrives. + requires: An authored condition the party must satisfy to open the door. + + Examples: + ```python + from osrlib.crawl.dungeon import DoorSpec, Edge, EdgeKind + + vault = Edge(kind=EdgeKind.DOOR, door=DoorSpec(locked=True)) + print(vault.door.locked, vault.door.kind) + # True normal + ``` """ model_config = ConfigDict(frozen=True) kind: Literal["normal", "secret"] = "normal" + """`"normal"` for a door the party can see, `"secret"` for one it cannot. A secret door is + invisible until a successful secret-door search finds it, which marks `discovered` on the door's + overlay entry. Until then the party cannot open it, listen at it, or walk through it, and the + edge reads to the player as wall.""" stuck: bool = False + """Whether the door is stuck shut. The engine refuses + [`OpenDoor`][osrlib.crawl.commands.OpenDoor] on a stuck door, so the party has to force it with + [`ForceDoor`][osrlib.crawl.commands.ForceDoor], a strength check that costs a turn whether or not + it works.""" locked: bool = False + """Whether the door is locked. The engine refuses to open a locked door until something unlocks it: + [`PickLock`][osrlib.crawl.commands.PickLock] by a thief, or a referee + [`SetDoorState`][osrlib.crawl.commands.SetDoorState]. Forcing still works, since a locked door can + be broken open.""" starts_open: bool = False + """Whether the door already stands open when the party first reaches it. An authored-open door + stays open: the rule that a door swings shut behind the party applies only to doors the party + itself opened.""" requires: GateSpec | None = None + """An authored gate on opening the door, or `None` for a door anyone may open. A gate + ([`GateSpec`][osrlib.crawl.gates.GateSpec]) is a stateless condition the engine checks whenever + the party tries to open or force the door, after every ordinary refusal has passed. It is + independent of `locked`: a door with both needs both, [`PickLock`][osrlib.crawl.commands.PickLock] + addresses only the lock, and [`SetDoorState`][osrlib.crawl.commands.SetDoorState] writes the + overlay without consulting the gate at all. A door standing open lets the party through + unchecked, because the gate guards the opening rather than the doorway, and applies again once + the door closes.""" class Edge(BaseModel): - """One authored edge entry: its kind, plus the door when `kind="door"`.""" + """One entry in a level's `edges` map: what stands on the boundary between two cells. + + You write these into [`LevelSpec.edges`][osrlib.crawl.dungeon.LevelSpec] keyed by + [`edge_key`][osrlib.crawl.dungeon.edge_key]. Only the exceptions need entries, because an edge + with no entry is wall. Read one back with + [`LevelSpec.edge`][osrlib.crawl.dungeon.LevelSpec.edge], which supplies a wall for anything + absent and for the level boundary. + + Attributes: + kind: What occupies the edge. + door: The door, when `kind` is `door`. + + Raises: + ValueError: If `kind` is `door` and `door` is `None`, or `door` is set on any other kind. + + Examples: + ```python + from osrlib.crawl.dungeon import DoorSpec, Edge, EdgeKind + + print(Edge(kind=EdgeKind.OPEN).door) + # None + + try: + Edge(kind=EdgeKind.OPEN, door=DoorSpec()) + except ValueError as error: + print("an edge carries a door spec exactly when its kind is 'door'" in str(error)) + # True + ``` + """ model_config = ConfigDict(frozen=True) kind: EdgeKind + """Whether the boundary is open, wall, or a door.""" door: DoorSpec | None = None + """The door standing on this edge, and `None` on every other kind. The two fields travel + together: a `door` edge must carry one and no other kind may.""" @model_validator(mode="after") def _door_exactly_on_door_edges(self) -> Edge: @@ -225,62 +408,148 @@ def _door_exactly_on_door_edges(self) -> Edge: class TransitionSpec(BaseModel): - """A level transition on a cell: stairs, trapdoor, or chute. - - The destination is `(dungeon_id, level_number, position, facing)`. Chutes are - one-way — `UseStairs` rejects on arrival cells that have no transition back. - - `requires` is an optional authored gate ([`GateSpec`][osrlib.crawl.gates.GateSpec]) - on a transition the party *takes*: [`UseStairs`][osrlib.crawl.commands.UseStairs] - evaluates it after the no-transition-here refusal and before the party moves, - which is what makes a toll payable at the threshold. A transition inside a - trap effect is a forced relocation rather than an attempt and may carry no - gate at all — [`TrapEffect`][osrlib.crawl.dungeon.TrapEffect] rejects one that - does. The gate's `success` beat rides the arrival's - [`LocationEnteredEvent`][osrlib.crawl.events.LocationEnteredEvent], so a - transition whose destination is its own level (which crosses no boundary and - emits no such event) has nowhere to display one. + """A way between levels standing on one cell: stairs, a trapdoor, or a chute. + + Transitions live on the level rather than on an area, in + [`LevelSpec.transitions`][osrlib.crawl.dungeon.LevelSpec]. They are how a multi-level dungeon + joins up, and how two dungeons join if you point one at the other's id. The party takes one with + [`UseStairs`][osrlib.crawl.commands.UseStairs], which lands it at `to_position` facing + `to_facing`. [`validate_adventure`][osrlib.crawl.adventure.validate_adventure] checks that the + cell you leave from and the cell you arrive at are both on their grids. + + Nothing pairs transitions up for you. A staircase the party can walk back up is two transitions, + one on each level, pointing at each other. Leave the return one out and you have a chute: a + one-way drop, which `UseStairs` refuses to climb back because the arrival cell holds no + transition. + + Attributes: + kind: Which kind of connection this is. + position: The cell it stands on. + to_dungeon_id: The dungeon the party arrives in. + to_level_number: The level the party arrives on. + to_position: The cell the party arrives at. + to_facing: The direction the party faces on arrival. + requires: An authored condition the party must satisfy to take it. """ model_config = ConfigDict(frozen=True) kind: Literal["stairs_up", "stairs_down", "trapdoor", "chute"] + """What the party sees and uses: `"stairs_up"`, `"stairs_down"`, `"trapdoor"`, or `"chute"`. The + value is descriptive. The destination fields set where the party goes, and the + presence or absence of a return transition decides whether it can come back.""" position: Position + """The cell on this level where the transition stands. The party has to be standing here for + [`UseStairs`][osrlib.crawl.commands.UseStairs] to do anything.""" to_dungeon_id: str + """The id of the dungeon the party arrives in. Naming this level's own dungeon is the ordinary + case. Naming another dungeon of the same adventure joins the two.""" to_level_number: int = Field(ge=1) + """The 1-based number of the level the party arrives on.""" to_position: Position + """The cell the party arrives at, on the destination level's grid.""" to_facing: Direction + """The direction the party faces on arrival, so a front end knows which way the view points and + the party's first step is not a surprise.""" requires: GateSpec | None = None + """An authored gate on taking the transition, or `None` for one anyone may take. + [`UseStairs`][osrlib.crawl.commands.UseStairs] evaluates it after the there-is-no-transition-here + refusal and before the party moves, so a gate that costs the party something is paid at the + threshold. The gate's `success` narration is attached to the arrival's + [`LocationEnteredEvent`][osrlib.crawl.events.LocationEnteredEvent], so a transition whose + destination is its own level crosses no boundary, emits no such event, and has nowhere to show + one. A transition inside a [`TrapEffect`][osrlib.crawl.dungeon.TrapEffect] may carry no gate at + all: that is a forced relocation rather than an attempt, and the trap effect rejects one that + does.""" class TrapEffect(BaseModel): - """What a sprung trap does: damage, a save, a condition, a fall, or a transition. - - `damage_dice` rolls once; `volley_dice` is the darts form (1d6 projectiles, - each rolling `damage_dice` — a count-times-damage form the dice grammar alone - can't say). `save` gates the effect: `negates` spares a passer outright, and - a passed save always spares the victim from `kills` and `condition`, whatever - `on_save` says — `on_save` scales damage only, because half of a kill or of a - blindness isn't a thing B/X expresses. `half` halves damage, rolled and - falling alike. `kills` marks save-or-die forms (poison gas). `condition` with - its duration is the blindness form; `fall_feet` is the pit's falling damage; - `transition` drops the victim elsewhere (slides). `manual` keeps prose for - the rest. + """What a sprung trap does to its victim. + + You attach one to a [`TrapSpec`][osrlib.crawl.dungeon.TrapSpec], which is what says when it + springs. The fields compose: a dart trap rolls damage, a pit trap rolls falling damage and may add + a condition, a gas trap calls for a save and kills on a failure. Leave everything unset and put + your description in `manual` for a trap your front end narrates and resolves itself. + + A passed save always spares the victim from `kills` and from `condition`, whatever `on_save` says, + because half a death and half a blindness are not things B/X expresses. `on_save` scales damage + only. + + Attributes: + damage_dice: The damage the trap deals. + volley_dice: The number of projectiles, for a trap that fires several. + save: The saving throw the victim gets. + kills: Whether a failed save kills outright. + condition: A condition a failed save inflicts. + condition_duration_dice: The condition's duration, rolled. + condition_duration_amount: The condition's duration, fixed. + condition_duration_unit: The unit the duration counts in. + fall_feet: How far the victim falls. + transition: Where the trap drops the victim. + manual: Prose for a trap the rules do not resolve. + + Raises: + ValueError: If a duration is given with no `condition`, if `volley_dice` is given with no + `damage_dice`, or if `transition` carries a gate. + + Examples: + ```python + from osrlib.core.combat import SaveCategory + from osrlib.core.spells import SaveSpec + from osrlib.crawl.dungeon import TrapEffect + + # A dart trap: 1d6 darts, each for 1d4. + darts = TrapEffect(damage_dice="1d4", volley_dice="1d6", save=SaveSpec(category=SaveCategory.WANDS)) + print(darts.kills) + # False + ``` """ model_config = ConfigDict(frozen=True) damage_dice: str | None = None + """The damage the trap deals, as a dice expression like `"1d6"`, or `None` for a trap that deals + none. With `volley_dice` set this is the damage of one projectile rather than the whole trap. The + expression is parsed when the model is built, so a malformed one fails here rather than when the + trap springs.""" volley_dice: str | None = None + """How many projectiles the trap fires, as a dice expression, or `None` for a trap that fires + one thing or none. This is the darts form: `volley_dice="1d6"` with `damage_dice="1d4"` fires + 1d6 darts and rolls 1d4 for each. The dice grammar cannot say "this many times that much" on its + own, which is why it is two fields. It requires `damage_dice`.""" save: SaveSpec | None = None + """The saving throw the victim rolls, or `None` for a trap that allows none. Its `on_save` says + what a successful save does, and `negates` spares the victim outright. A passed save always + spares them from `kills` and `condition` whatever `on_save` says. `half` halves damage, both the + `damage_dice` roll and `fall_feet` damage.""" kills: bool = False + """Whether a failed save kills the victim outright. This is the save-or-die form, poison gas + being the printed example. A passed save always spares them, whatever `on_save` says.""" condition: Condition | None = None + """A condition the trap inflicts on a failed save, or `None`. Blindness is the printed example. + A passed save always spares the victim from it.""" condition_duration_dice: str | None = None + """The condition's duration rolled as a dice expression, for a duration that varies. Set this or + `condition_duration_amount`, not both, and only alongside a `condition`. The roll draws on the + effects stream, like every other effect attachment.""" condition_duration_amount: int | None = None + """The condition's duration as a fixed number, for a duration that does not vary. Set this or + `condition_duration_dice`, and only alongside a `condition`.""" condition_duration_unit: TimeUnit | None = None + """What the duration counts in: rounds, turns, or days. Only meaningful alongside a `condition`. + A condition with no duration at all lasts until something removes it.""" fall_feet: int | None = None + """How far the victim falls, in feet, or `None` for a trap with no drop. This is the pit form. + Falling damage is the SRD's own by distance, separate from `damage_dice`, and a `half` save + halves it too.""" transition: TransitionSpec | None = None + """Where the trap puts the victim, or `None` for a trap that moves nobody. This is the chute + form: the victim slides somewhere else instead of staying where they stood. The transition may + carry no `requires` gate, because the victim is not attempting anything.""" manual: str | None = None + """Prose for a trap the rules do not resolve, or `None`. Nothing here reads it: it is for the + referee or the front end, and it is how you author a trap whose effect is a judgement call rather + than a die roll.""" @field_validator("damage_dice", "volley_dice", "condition_duration_dice") @classmethod @@ -308,22 +577,49 @@ def _condition_duration_needs_a_condition(self) -> TrapEffect: class TrapSpec(BaseModel): - """A trap: room (over an area) or treasure (on a feature). - - `trigger` names the springing action. A room trap springs on `enter` — a - cell of the trapped area — or on `open` — a door of the area, either side: - the blade that drops when the door swings. A treasure trap always springs on - `open`, the cache it guards; there is nothing to enter, so `enter` is - rejected. `affects` defaults to the triggerer; `party` covers forms like - poison gas filling the room. + """A trap: when it springs, what it does, and whom it catches. + + There are two places a trap can sit, and the `kind` says which. A room trap goes on an + [`AreaSpec`][osrlib.crawl.dungeon.AreaSpec] and covers the whole area. A treasure trap goes on a + [`FeatureSpec`][osrlib.crawl.dungeon.FeatureSpec] and guards that one cache. Each model accepts + only its own kind, so a trap cannot end up somewhere it has no meaning. + + A trap does not spring on sight. The engine rolls a 2-in-6 chance each time the triggering action + happens, which is the SRD's rule, so walking into a trapped room is not certain death. A trap the + party has already found stops rolling: a room trap by a successful + [`Search`][osrlib.crawl.commands.Search], a treasure trap by a thief's + [`InspectTreasure`][osrlib.crawl.commands.InspectTreasure]. Only a treasure trap can then be taken + out of play, with [`RemoveTreasureTrap`][osrlib.crawl.commands.RemoveTreasureTrap]. A room trap the + party knows about is avoided rather than disarmed. + + Traps you author are the only traps in the game. Treasure the engine generates is never trapped. + + Attributes: + kind: Whether this is a room trap or a treasure trap. + trigger: The action that springs it. + effect: What it does when it springs. + affects: Whom it catches. + + Raises: + ValueError: If `kind` is `"treasure"` and `trigger` is not `"open"`. """ model_config = ConfigDict(frozen=True) kind: Literal["room", "treasure"] + """`"room"` for a trap over an area, `"treasure"` for one on a cache. + [`AreaSpec`][osrlib.crawl.dungeon.AreaSpec] accepts only room traps and + [`FeatureSpec`][osrlib.crawl.dungeon.FeatureSpec] only treasure traps.""" trigger: Literal["enter", "open"] + """The action that springs the trap. `"enter"` is the party stepping into a cell of the trapped + area. `"open"` is a door being opened: for a room trap, any door of the area, from either side, + which is the blade that drops when the door swings. For a treasure trap, `"open"` is the cache + itself being opened. A treasure trap must use `"open"`, because a cache has nothing to walk into.""" effect: TrapEffect + """What the trap does once it springs. See [`TrapEffect`][osrlib.crawl.dungeon.TrapEffect].""" affects: Literal["triggerer", "party"] = "triggerer" + """Whom the effect lands on: `"triggerer"` for the one character who set it off, the default, or + `"party"` for every living member, which is the form for poison gas filling the room.""" @model_validator(mode="after") def _treasure_traps_spring_on_open(self) -> TrapSpec: @@ -333,36 +629,89 @@ def _treasure_traps_spring_on_open(self) -> TrapSpec: class ValuableSpec(BaseModel): - """An authored named valuable in a cache — instantiated on take. - - The authoring surface for named treasure: a unique gem or piece of jewellery - with its own display name, rather than a generic generated one. `name` is the - display name; the instance id comes from the session allocator when the cache - is emptied. + """A named gem or piece of jewellery you placed by hand in a cache. + + Use this when the treasure is a particular thing with a name, rather than one of the anonymous + gems the treasure generators roll. It goes in a + [`FeatureSpec`][osrlib.crawl.dungeon.FeatureSpec]'s `valuables`. It stays a description until the + party empties the cache, at which point the session turns it into a + [`ValuableInstance`][osrlib.core.items.ValuableInstance] with an id of its own. + + Attributes: + kind: Whether it is a gem or jewellery. + name: What the party sees it called. + value_gp: What it sells for, in gold pieces. + weight_coins: What it weighs, in coins. + + Examples: + ```python + from osrlib.crawl.dungeon import FeatureSpec, ValuableSpec + + chest = FeatureSpec( + id="abbot_chest", + kind="treasure_cache", + valuables=(ValuableSpec(kind="jewellery", name="The abbot's seal ring", value_gp=900),), + ) + print(chest.valuables[0].value_gp) + # 900 + ``` """ model_config = ConfigDict(frozen=True) kind: Literal["gem", "jewellery"] + """Which sort of valuable this is. Nothing mechanical turns on it. It is what the item is, for + display and for any rule your game applies to one sort and not the other.""" name: str = "" + """The display name the party sees, like `"The abbot's seal ring"`. Empty leaves the valuable + unnamed, which is how the generated ones arrive.""" value_gp: int = Field(ge=0) + """What the valuable is worth in gold pieces. This is the sale price in town and the XP the party + earns for bringing it back.""" weight_coins: int = Field(default=0, ge=0) + """What the valuable weighs, in coins, for encumbrance. The default of 0 makes it weightless, + which is the usual treatment for a gem.""" class AreaTreasureSpec(BaseModel): - """An area's generated-treasure declaration: explicit type letters, or unguarded. + """Treasure the engine rolls for an area that has no monsters guarding it. + + Put one on an [`AreaSpec`][osrlib.crawl.dungeon.AreaSpec] when you want the room to hold loot but + do not want to choose it. It rolls the first time the party enters the area and lands as a cache + on the floor, which the party then picks up with + [`TakeTreasure`][osrlib.crawl.commands.TakeTreasure]. Generated treasure is never trapped. Trapping + is authored, through a [`FeatureSpec`][osrlib.crawl.dungeon.FeatureSpec] with a trap on it. + + For an area whose treasure is a monster's hoard, use a + [`KeyedEncounter`][osrlib.crawl.dungeon.KeyedEncounter] and its `hoard` flag instead: that is the + lair treasure, and it comes with the monsters. + + Attributes: + letters: The treasure type letters to roll. + unguarded: Whether to roll the level's unguarded-treasure band instead. - Generates on first entry into the area — how content places generated treasure - in an area with no monsters. `letters` names one or more treasure type letters - (see [the treasure type index][treasure-types-index]); `unguarded=True` instead - rolls the dungeon level's unguarded-treasure band. Exactly one of the two - applies. + Raises: + ValueError: If both `letters` and `unguarded` are given, or neither. + + Examples: + ```python + from osrlib.crawl.dungeon import AreaTreasureSpec + + print(AreaTreasureSpec(letters=("C",)).unguarded) + # False + ``` """ model_config = ConfigDict(frozen=True) letters: tuple[str, ...] = () + """One or more B/X treasure type letters, like `("C",)`. Each letter is its own hoard table, and the + full list is [the treasure type index][treasure-types-index]. Set this or `unguarded`, not + both.""" unguarded: bool = False + """Whether to roll the dungeon level's unguarded-treasure band instead of naming letters. That + band is the SRD's own table for treasure lying about with nothing watching it, and it scales with + the level number. Set this or `letters`, not both.""" @model_validator(mode="after") def _letters_or_unguarded(self) -> AreaTreasureSpec: @@ -372,69 +721,167 @@ def _letters_or_unguarded(self) -> AreaTreasureSpec: class TreasureBundle(BaseModel): - """A mutable generated-treasure bundle: coins, valuables, and magic items.""" + """A working pile of rolled treasure: coins, valuables, and magic items together. + + The treasure generators fill one of these while a hoard rolls, and the engine then moves its + contents into a [`GeneratedCache`][osrlib.crawl.dungeon.GeneratedCache] or a + [`DropPile`][osrlib.crawl.dungeon.DropPile]. You meet it if you drive the generators yourself + outside a session. Inside one, the cache and the pile are what you read. + + Unlike the authored models here it is mutable, because rolling a hoard adds to it entry by entry. + + Attributes: + coins: The coins in the bundle. + valuables: The gems and jewellery in the bundle. + magic_items: The magic items in the bundle. + """ model_config = ConfigDict(validate_assignment=True) coins: Coins = Coins() + """The coins, by denomination. A fresh bundle starts with none of each.""" valuables: list[ValuableInstance] = [] + """The gems and jewellery, each already an instance with its own id and value.""" magic_items: list[MagicItemInstance] = [] + """The magic items, each already rolled out with its charges or quantity.""" @property def empty(self) -> bool: - """Whether the bundle holds nothing at all.""" + """Whether the bundle holds nothing at all. + + A hoard can roll to nothing, and the engine checks this before it writes a cache, so an + empty one never lands on the floor. + """ return self.coins.total_coins == 0 and not self.valuables and not self.magic_items class GeneratedCache(BaseModel): - """An engine-created treasure cache in the state overlay. - - Authored [`FeatureSpec`][osrlib.crawl.dungeon.FeatureSpec]s are frozen content; - the state overlay owns play-created treasure — the template/instance split - applied to loot. Generated hoards are always untrapped: traps are authored - content only, never a generation outcome (see the adaptations register). + """Treasure the engine rolled and put on the floor, in the state overlay. + + Authored caches are [`FeatureSpec`][osrlib.crawl.dungeon.FeatureSpec]s and never change. This is + the other kind: what a monster's lair hoard or an + [`AreaTreasureSpec`][osrlib.crawl.dungeon.AreaTreasureSpec] produced when it rolled. The session + puts one in [`DungeonState.generated_caches`][osrlib.crawl.dungeon.DungeonState] under a minted + id like `"cache-0001"`, announces it with a `HoardGeneratedEvent`, and removes it when the party + empties it with [`TakeTreasure`][osrlib.crawl.commands.TakeTreasure] naming that id. + + Generated hoards are never trapped. Trapping treasure is something you author, not something a + roll produces, which is one of the choices listed in + [the adaptations register](https://mmacy.github.io/osrlib-python/adaptations/), the page recording + where osrlib commits to one reading of an ambiguous rule or supplies a default behind a + [`Ruleset`][osrlib.core.ruleset.Ruleset] flag. + + Attributes: + cell_ref: The cell the cache lies on. + treasure_types: The treasure type letters it rolled from. + coins: The coins in it. + valuables: The gems and jewellery in it. + magic_items: The magic items in it. """ model_config = ConfigDict(validate_assignment=True) cell_ref: str + """Where the cache lies, as a [`cell_ref`][osrlib.crawl.dungeon.cell_ref] string. The party has + to be standing on that cell to take it.""" treasure_types: tuple[str, ...] = () + """The treasure type letters the hoard rolled from, kept for display and for a referee who wants + to see what the dice were asked. Empty for an unguarded roll, which names no letters.""" coins: Coins = Coins() + """The coins in the cache, by denomination.""" valuables: list[ValuableInstance] = [] + """The gems and jewellery in the cache, each with its own id.""" magic_items: list[MagicItemInstance] = [] + """The magic items in the cache, each already rolled out with its charges or quantity.""" class FeatureSpec(BaseModel): - """A keyed feature: a treasure cache, a construction trick, or custom content. - - Stairs are [`TransitionSpec`][osrlib.crawl.dungeon.TransitionSpec]'s alone — no - second home. Caches carry hand-placed contents — `item_ids` (any id in the - session's effective equipment catalog: a shipped id from - [`load_equipment`][osrlib.data.load_equipment], see - [the equipment id index][equipment-index], or one bundled by the adventure's - `items`), `magic_item_ids` (any id from - [`load_magic_items`][osrlib.data.load_magic_items], see - [the magic item id index][magic-items-index]), and `coins` — plus an optional - treasure trap, so a cache's contents can be dropped, found, and recovered like - any other treasure. Hand-placed magic items instantiate when the cache is - emptied: the author names the item, and its creation details (charges, - quantities, sword sentience) roll on the treasure stream via - [`instantiate_magic_item`][osrlib.core.treasure.instantiate_magic_item]. - `cell` binds the feature to a cell; a feature listed on an area with - `cell=None` binds to the whole area. + """A keyed thing in a room: a treasure cache, a construction trick, or your own content. + + Features hang on an [`AreaSpec`][osrlib.crawl.dungeon.AreaSpec] or straight on a + [`LevelSpec`][osrlib.crawl.dungeon.LevelSpec], and they are how a room holds something the party + can find and interact with. Stairs are not features. Those are + [`TransitionSpec`][osrlib.crawl.dungeon.TransitionSpec]s, and they have no second home. + + A `treasure_cache` is the one kind the engine resolves on its own: the party opens it with + [`TakeTreasure`][osrlib.crawl.commands.TakeTreasure] naming the feature's id, and its contents go + into the party's hands. Hand-placed magic items are named here and instantiated when the cache is + emptied, so an item's own details (charges, quantities, whether a sword turns out to be sentient) + roll then, on the treasure stream, through + [`instantiate_magic_item`][osrlib.core.treasure.instantiate_magic_item]. A `construction_trick` + is one of the SRD's weird architectural features, like a room that rotates or an illusory + passage: the party finds it by searching, and your front end says what it does. A `custom` + feature is yours entirely. + + Attributes: + id: The feature's id, unique across its level. + kind: Which sort of feature it is. + description: Prose for your front end. + cell: The cell it sits on, or `None` to bind it to a whole area. + item_ids: Ordinary items in a cache. + magic_item_ids: Magic items in a cache. + coins: Coins in a cache. + valuables: Named gems and jewellery in a cache. + trap: A treasure trap guarding the cache. + + Raises: + ValueError: If `trap` is not a treasure trap. + + Examples: + ```python + from osrlib.core.items import Coins + from osrlib.crawl.dungeon import FeatureSpec + + chest = FeatureSpec( + id="altar_chest", + kind="treasure_cache", + description="A banded chest under the altar.", + cell=(3, 0), + item_ids=("rope_50", "holy_water"), + coins=Coins(gp=120), + ) + print(chest.coins.gp, chest.item_ids) + # 120 ('rope_50', 'holy_water') + ``` """ model_config = ConfigDict(frozen=True) id: str + """The feature's id, which has to be unique across the level, counting features on the level and + on all of its areas together. [`TakeTreasure`][osrlib.crawl.commands.TakeTreasure] and the other + feature commands name it. The id `"pile"` is reserved for the drop pile on a cell, so authored + content may not use it, and `validate_adventure` refuses an adventure that does.""" kind: Literal["treasure_cache", "construction_trick", "custom"] + """What sort of feature this is. `"treasure_cache"` is the only kind the engine resolves for the + party. `"construction_trick"` and `"custom"` contain content your front end interprets.""" description: str = "" + """Prose your front end shows when the party finds the feature. Events carry the feature's id + rather than its words, so the text lives here and the front end looks it up.""" cell: Position | None = None + """The cell the feature sits on. A feature listed on a level needs one. A feature listed on an + area may leave it `None`, which binds the feature to the whole area rather than to one square of + it.""" item_ids: tuple[str, ...] = () + """Ordinary items in the cache, by template id. Any id the session's effective equipment catalog + holds works: a shipped id from [`load_equipment`][osrlib.data.load_equipment], listed in + [the equipment id index][equipment-index], or one the adventure bundles on its `items` field.""" magic_item_ids: tuple[str, ...] = () + """Magic items in the cache, by template id. Any id from + [`load_magic_items`][osrlib.data.load_magic_items], listed in + [the magic item id index][magic-items-index]. Adventures bundle no magic items of their own, so + only the shipped catalog resolves here.""" coins: Coins = Coins() + """Coins in the cache, by denomination.""" valuables: tuple[ValuableSpec, ...] = () + """Named gems and jewellery in the cache. See + [`ValuableSpec`][osrlib.crawl.dungeon.ValuableSpec].""" trap: TrapSpec | None = None + """A trap guarding the cache, or `None`. It has to be a treasure trap, which springs when the + party opens the cache. A thief finds it with + [`InspectTreasure`][osrlib.crawl.commands.InspectTreasure] and takes it out with + [`RemoveTreasureTrap`][osrlib.crawl.commands.RemoveTreasureTrap], one attempt each per + character.""" @model_validator(mode="after") def _trap_kind_matches(self) -> FeatureSpec: @@ -444,19 +891,45 @@ def _trap_kind_matches(self) -> FeatureSpec: class KeyedMonster(BaseModel): - """One monster line of a keyed encounter: the template and its count. + """One line of a keyed encounter: which monster, and how many. + + A [`KeyedEncounter`][osrlib.crawl.dungeon.KeyedEncounter] is a tuple of these, so a room holding + four orcs and their ogre bodyguard is two lines. Give each line a fixed count or count dice, + exactly one of the two. + + Attributes: + template_id: Which monster stands here. + count_dice: How many, rolled when they spawn. + count_fixed: How many, decided now. + + Raises: + ValueError: If both `count_dice` and `count_fixed` are given, or neither. - `template_id` is any id in the session's effective catalog — a shipped id from - [`load_monsters`][osrlib.data.load_monsters] (see - [the monster id index][monsters-index]) or one bundled by the adventure's - `monsters`. + Examples: + ```python + from osrlib.crawl.dungeon import KeyedEncounter, KeyedMonster + + guards = KeyedEncounter(monsters=(KeyedMonster(template_id="goblin", count_fixed=4),)) + print(guards.monsters[0].template_id, guards.monsters[0].count_fixed) + # goblin 4 + ``` """ model_config = ConfigDict(frozen=True) template_id: str + """The monster's template id. Any id the session's effective catalog holds works: a shipped id + from [`load_monsters`][osrlib.data.load_monsters], listed in + [the monster id index][monsters-index], or one the adventure bundles on its `monsters` field.""" count_dice: str | None = None + """How many appear, as a dice expression like `"2d4"`, rolled on the + [`WANDERING_STREAM`][osrlib.crawl.session.WANDERING_STREAM] the first time the party enters the + area, and held at 1 or more. Set this or `count_fixed`, not both. The expression is parsed when + the model is built, so a malformed one fails while you author rather than at play.""" count_fixed: int | None = None + """How many appear, as a number decided now. Set this or `count_dice`, not both. A printed module + gives concrete numbers, and so does [`stock_area`][osrlib.crawl.stocking.stock_area] when it + rolls a room for you.""" @field_validator("count_dice") @classmethod @@ -473,45 +946,121 @@ def _dice_or_fixed(self) -> KeyedMonster: class KeyedEncounter(BaseModel): - """An area's keyed encounter: monsters with counts and optional pins. - - `aware=True` means the monsters expect intruders (they never roll surprise); - `stance` pins the reaction outright (no reaction roll); `alignment` fixes the - spawn alignment for multi-option templates. `hoard=True` (the default) means - the engine generates the keyed monsters' lair hoard the first time the - encounter spawns; `hoard=False` is the treasure-absent keyed room — a monster - room the stocking roll gave no treasure — expressible because SRD stocking - puts treasure on only some monster rooms, while an encounter would otherwise - always bring its lair letters. + """The monsters waiting in a keyed area, and what is already decided about them. + + Put one on an [`AreaSpec`][osrlib.crawl.dungeon.AreaSpec]. The monsters spawn the first time the + party enters any cell of the area, and the session moves into encounter mode: surprise, distance, + and reaction roll unless you have decided them here. The encounter resolves once, and the area + stays clear afterwards. + + Attributes: + monsters: The monster lines, each a template and a count. + alignment: A fixed alignment for templates that offer a choice. + aware: Whether the monsters are expecting the party. + stance: A fixed reaction, instead of a reaction roll. + hoard: Whether the monsters have their lair treasure. + + Examples: + ```python + from osrlib.core.tables import ReactionResult + from osrlib.crawl.dungeon import KeyedEncounter, KeyedMonster + + ambush = KeyedEncounter( + monsters=(KeyedMonster(template_id="goblin", count_fixed=6),), + aware=True, + stance=ReactionResult.ATTACKS, + ) + print(ambush.aware, ambush.hoard) + # True True + ``` """ model_config = ConfigDict(frozen=True) monsters: tuple[KeyedMonster, ...] = Field(min_length=1) + """The monster lines making up the encounter, at least one. See + [`KeyedMonster`][osrlib.crawl.dungeon.KeyedMonster].""" alignment: Alignment | None = None + """The alignment the spawned monsters take, for a template whose own alignment offers more than + one. `None` rolls it the ordinary way. The value has to be one the template allows, and + [`validate_adventure`][osrlib.crawl.adventure.validate_adventure] refuses one that is not.""" aware: bool = False + """Whether the monsters already know the party is coming. Aware monsters never roll surprise, + which is how you author a lookout or an ambush that has heard the party's armour.""" stance: ReactionResult | None = None + """The reaction the monsters take, instead of rolling for it. Set it when the room's monsters + attack on sight or are friendly by design. Leave it `None` and the reaction roll decides.""" hoard: bool = True + """Whether the monsters have their lair treasure with them. The default generates their printed + hoard the first time the encounter spawns. Set it `False` for a monster room with no treasure, + which B/X stocking produces often: the room-contents roll puts treasure in only some monster + rooms, while a monster's printed lair letters would otherwise always come along.""" class AreaSpec(BaseModel): - """A keyed area (a room or cave): a named region over cells with content bindings. - - Areas annotate the grid; cells not in any area are corridor. Content prose - lives here — events carry ids and front ends resolve prose against the - adventure. + """A keyed room or cave: a named region of cells with content bound to it. + + Areas are how you key a dungeon. Each one covers some cells of a + [`LevelSpec`][osrlib.crawl.dungeon.LevelSpec], and cells no area covers are corridor. Entering + any cell of an area is what brings its content into play: the encounter spawns, the trap gets its + spring roll, the treasure rolls, and your front end shows the description. + + Attributes: + id: The area's id, unique across its level. + name: The room's name. + description: Prose for your front end. + cells: The cells the area covers. + encounter: The monsters waiting here. + features: The keyed things in the room. + trap: A room trap over the whole area. + treasure: Generated treasure with nothing guarding it. + + Raises: + ValueError: If `trap` is not a room trap. + + Examples: + ```python + from osrlib.crawl.dungeon import AreaSpec, KeyedEncounter, KeyedMonster + + guard_post = 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),)), + ) + print(guard_post.cells) + # ((3, 0),) + ``` """ model_config = ConfigDict(frozen=True) id: str + """The area's id, which has to be unique across the level. Events carry it, triggers match on it, + and the state overlay records the area's encounter and treasure against it.""" name: str = "" + """The room's name, for your front end to show: `"Guard post"`, `"The abbot's cell"`.""" description: str = "" + """Prose your front end shows when the party walks in. Events carry the area's id rather than its + words, so the text lives here and the front end looks it up. For prose split by audience, see + [`NarrativeBlock`][osrlib.crawl.narrative.NarrativeBlock].""" cells: tuple[Position, ...] = Field(min_length=1) + """The cells the area covers, at least one. They need not be contiguous, though a room usually + is. Every one has to be on the level's grid. + [`AreaSpec.cells[0]`][osrlib.crawl.dungeon.AreaSpec] is where a generated hoard lands.""" encounter: KeyedEncounter | None = None + """The monsters waiting in the room, or `None` for an empty one. See + [`KeyedEncounter`][osrlib.crawl.dungeon.KeyedEncounter].""" features: tuple[FeatureSpec, ...] = () + """The keyed things in the room: caches, tricks, and your own content. A feature here may leave + its `cell` unset, which binds it to the area rather than to one square.""" trap: TrapSpec | None = None + """A trap over the whole area, or `None`. It has to be a room trap, which springs when the party + enters a cell of the area or, with `trigger="open"`, when a door of the area is opened.""" treasure: AreaTreasureSpec | None = None + """Treasure the engine rolls on first entry, for a room with loot and nothing guarding it. A + room whose treasure belongs to its monsters uses the encounter's `hoard` flag instead.""" @model_validator(mode="after") def _trap_kind_matches(self) -> AreaSpec: @@ -521,50 +1070,135 @@ def _trap_kind_matches(self) -> AreaSpec: class WanderingSpec(BaseModel): - """A level's wandering-monster parameters. + """A level's wandering-monster check: how often it rolls, and from what. - The defaults are RAW: a 1-in-6 check every two turns. `table` overrides the - compiled level-band table with an inline custom list (same row model). + Every [`LevelSpec`][osrlib.crawl.dungeon.LevelSpec] has one, and the default is the B/X rule, so + you only write your own to change the odds or the monsters. The session runs the check on its own + clock as the party spends turns, and you never roll it yourself. + + Attributes: + chance_in_six: The odds of monsters showing up. + interval_turns: How often the check runs. + table: A custom monster table for this level. + + Examples: + ```python + from osrlib.crawl.dungeon import WanderingSpec + + quiet = WanderingSpec(chance_in_six=0) + print(quiet.interval_turns) + # 2 + ``` """ model_config = ConfigDict(frozen=True) chance_in_six: int = Field(default=1, ge=0, le=6) + """How many faces of a d6 bring monsters, checked once per interval. The default of 1 is the + printed rule. Set it 0 for a level nothing wanders on, and higher for one that is busier.""" interval_turns: int = Field(default=2, ge=1) + """How many exploration turns pass between checks. The default of 2 is the printed rule.""" table: EncounterTable | None = None + """A custom encounter table for this level, or `None` to use the compiled table for the level's + number band. Setting it replaces the band table entirely, which is how you give a level its own + inhabitants. Same row model as the shipped tables, from + [`load_encounter_tables`][osrlib.data.load_encounter_tables].""" class LevelSpec(BaseModel): - """One dungeon level: a grid of 10' cells with edges, areas, and transitions. + """One dungeon level: a grid of 10-foot cells with its edges, rooms, and stairs. + + A level is where all of this module's geometry comes together. You give it a size, declare the + edges that are not wall, key some of its cells as areas, and hang features and transitions on it. + Then you put one or more levels in a [`DungeonSpec`][osrlib.crawl.dungeon.DungeonSpec] and that + dungeon in an [`Adventure`][osrlib.crawl.adventure.Adventure]. + + The methods read the level back the way the engine does: is this cell on the grid, what stands on + this side of it, which room is it part of, do stairs go from it. A front end drawing a map calls + them, and so does a tool checking your work while you author. + + Attributes: + number: The level's depth number. + width: The grid's width in cells. + height: The grid's height in cells. + edges: Everything that is not wall. + areas: The keyed rooms. + features: Features on the level rather than on a room. + transitions: The ways to other levels. + wandering: The wandering-monster check. + entrance: Where the party arrives from town. + guidance: Ambient steering for a narrating front end. + + Examples: + ```python + from osrlib.crawl.dungeon import Direction, Edge, EdgeKind, LevelSpec + + # Two cells, joined west to east, entered at the west end. + corridor = LevelSpec( + number=1, + width=2, + height=1, + entrance=(0, 0), + edges={"1,0:west": Edge(kind=EdgeKind.OPEN)}, + ) + print(corridor.edge((0, 0), Direction.EAST).kind) + # EdgeKind.OPEN - `number` is 1-based and rules-visible — it keys the encounter-table band. - `entrance` is where `EnterDungeon` and town travel land (required on some level - per adventure validation). + print(corridor.edge((0, 0), Direction.NORTH).kind) + # EdgeKind.WALL + ``` """ model_config = ConfigDict(frozen=True) number: int = Field(ge=1) + """The level's depth, 1-based and visible to the rules: it selects the wandering-monster table + band and the treasure bands. Level 1 is the top. The numbers have to be unique within a dungeon, + and a [`TransitionSpec`][osrlib.crawl.dungeon.TransitionSpec] names one to say where it goes.""" width: int = Field(ge=1) + """How many cells the grid runs east to west. Valid `x` values are `0` to `width - 1`.""" height: int = Field(ge=1) + """How many cells the grid runs north to south. Valid `y` values are `0` to `height - 1`.""" edges: dict[str, Edge] = {} + """Everything on the grid that is not wall, keyed by + [`edge_key`][osrlib.crawl.dungeon.edge_key]. An edge with no entry here is wall, and so is the + level boundary, so an empty map is a level of solid rock. Read it back through + [`edge`][osrlib.crawl.dungeon.LevelSpec.edge] rather than by hand, which supplies the wall for + you.""" areas: tuple[AreaSpec, ...] = () + """The level's keyed rooms and caves. Cells no area covers are corridor. Ids have to be unique + within the level, and every cell an area names has to be on the grid.""" features: tuple[FeatureSpec, ...] = () + """Features that belong to the level rather than to a room: a cache in a corridor, a trick in a + dead end. Each one needs a `cell`, since there is no area to bind it to. Ids share one namespace + with the areas' features.""" transitions: tuple[TransitionSpec, ...] = () + """The stairs, trapdoors, and chutes on this level, each standing on one cell. Transitions belong + to the level, not to an area, even when they stand inside a room.""" wandering: WanderingSpec = WanderingSpec() + """The level's wandering-monster check. The default is the printed rule, a 1-in-6 check every two + turns off the compiled table for this level's number.""" entrance: Position | None = None + """The cell the party arrives at from town, or `None` for a level with no way in from outside. + [`EnterDungeon`][osrlib.crawl.commands.EnterDungeon] and + [`TravelToTown`][osrlib.crawl.commands.TravelToTown] both use it. Some level of every dungeon + needs one, and `validate_adventure` refuses a dungeon where no level has any.""" guidance: str = "" - """Ambient steering for a narrating front end while the party is on this level — - the tone of the place, what it wants said, what it never says. + """Ambient steering for a narrating front end while the party is on this level: the tone of the + place, what you want said about it, what you never want said. - Inert authored data: the engine reads it nowhere, no event carries it, and no - rule turns on it. A narrator reaches it through the adventure document, which is - referee-side by construction — the player view ships no level internals — so it - is trusted like an area's description prose and shown to nobody verbatim.""" + The engine reads it nowhere, no event contains it, and no rule turns on it. It is authored data + for a narrator, which reaches it through the adventure document. That document is referee-side, + since the player view contains no level internals, so treat this the way you treat an area's + description prose and don't show it to the player word for word.""" def in_bounds(self, position: Position) -> bool: """Return whether a cell lies on this level's grid. + Everything off the grid is outside the dungeon, which the engine treats as solid: the party + cannot walk there, and an authored position out here is a content error that + [`validate_adventure`][osrlib.crawl.adventure.validate_adventure] catches. + Args: position: The cell to test. @@ -575,17 +1209,24 @@ def in_bounds(self, position: Position) -> bool: return 0 <= x < self.width and 0 <= y < self.height def edge(self, position: Position, direction: Direction) -> Edge: - """Return the authored edge on one side of a cell. + """Return what stands on one side of a cell. + + This is the read you want rather than indexing `edges` yourself: it canonicalizes the key, so + a cell's east side and its neighbour's west side give the same answer, and it supplies the + wall for everything absent. Call it to find out whether the party can walk that way, whether + there is a door to open, and which door the overlay's state belongs to. - An edge absent from the map is a wall (authored content declares its - passages), and the level boundary is implicitly wall. + An edge with no entry in the map is wall, because authored content declares its passages + rather than its walls, and the level boundary is wall too. Args: position: The cell. direction: Which of the cell's four edges. Returns: - The edge entry. + The edge entry, or a wall edge when none is authored or either cell is off the grid. The + wall is a fresh [`Edge`][osrlib.crawl.dungeon.Edge] rather than a shared one, and it + contains no door. """ if not self.in_bounds(position) or not self.in_bounds(step(position, direction)): return Edge(kind=EdgeKind.WALL) @@ -594,11 +1235,17 @@ def edge(self, position: Position, direction: Direction) -> Edge: def area_at(self, position: Position) -> AreaSpec | None: """Return the keyed area covering a cell, or `None` for corridor. + The engine calls this on every step to work out whether the party has just walked into a + room and its content is due. Call it to label the party's location, or to show which room a + cell belongs to on a map. + Args: position: The cell. Returns: - The first area whose cells include the position, in authored order. + The first area whose `cells` include the position, in the order you authored them, or + `None` when the cell is corridor. Overlapping areas are not rejected, so the authored + order is what decides between them. """ for area in self.areas: if position in area.cells: @@ -606,13 +1253,17 @@ def area_at(self, position: Position) -> AreaSpec | None: return None def transition_at(self, position: Position) -> TransitionSpec | None: - """Return the transition on a cell, or `None`. + """Return the transition standing on a cell, or `None`. + + [`UseStairs`][osrlib.crawl.commands.UseStairs] asks this and refuses when the answer is + `None`, which is also what makes a chute one-way: the arrival cell holds no transition back. + Call it to show a stairs marker on a map, or to offer the command only where it works. Args: position: The cell. Returns: - The transition, if one is authored there. + The first transition authored on that cell, or `None` when none is. """ for transition in self.transitions: if transition.position == position: @@ -621,13 +1272,49 @@ def transition_at(self, position: Position) -> TransitionSpec | None: class DungeonSpec(BaseModel): - """A dungeon: one or more levels joined by transitions.""" + """A dungeon: one or more levels joined by transitions. + + This is the unit an [`Adventure`][osrlib.crawl.adventure.Adventure] holds and the unit + [`EnterDungeon`][osrlib.crawl.commands.EnterDungeon] names. Build its levels first, then wrap + them here, then put the dungeon in an adventure beside its town. An adventure may hold several, + and a [`TransitionSpec`][osrlib.crawl.dungeon.TransitionSpec] may point at another one's id, so a + stair can lead out of one dungeon and into the next. + + Nothing here says which level is the way in. Some level needs an `entrance`, and + [`validate_adventure`][osrlib.crawl.adventure.validate_adventure] is what checks that one does. + + Attributes: + id: The dungeon's id. + name: The dungeon's name. + levels: Its levels. + + Raises: + ValueError: If two levels carry the same `number`. + + Examples: + ```python + from osrlib.crawl.dungeon import DungeonSpec, Edge, EdgeKind, LevelSpec + + corridor = LevelSpec(number=1, width=2, height=1, entrance=(0, 0), edges={"1,0:west": Edge(kind=EdgeKind.OPEN)}) + crypt = DungeonSpec(id="crypt", name="The Old Crypt", levels=(corridor,)) + print(crypt.level(1).width) + # 2 + ``` + """ model_config = ConfigDict(frozen=True) id: str + """The dungeon's id, unique within the adventure. [`EnterDungeon`][osrlib.crawl.commands.EnterDungeon], + the town's `travel_turns` map, transitions between dungeons, and every state-overlay reference + name it.""" name: str = "" + """The dungeon's name, for your front end to show: `"The Old Crypt"`.""" levels: tuple[LevelSpec, ...] = Field(min_length=1) + """The dungeon's levels, at least one, with unique `number`s. Look one up with + [`level`][osrlib.crawl.dungeon.DungeonSpec.level] rather than by position. The order does matter + in one place: [`EnterDungeon`][osrlib.crawl.commands.EnterDungeon] lands the party on the first + level in this tuple that has an `entrance`.""" @model_validator(mode="after") def _level_numbers_unique(self) -> DungeonSpec: @@ -639,6 +1326,9 @@ def _level_numbers_unique(self) -> DungeonSpec: def level(self, number: int) -> LevelSpec: """Return the level with `number`. + Use this to turn a level number out of a command, an event, or a transition back into the + level it names, rather than searching `levels` yourself. + Args: number: The 1-based level number. @@ -646,7 +1336,7 @@ def level(self, number: int) -> LevelSpec: The level spec. Raises: - ValueError: If no level has that number. + ValueError: If no level has that number. The message names the dungeon and the number. """ for level in self.levels: if level.number == number: @@ -655,15 +1345,52 @@ def level(self, number: int) -> LevelSpec: class PartyLocation(BaseModel): - """Where the party is: the base town, or a dungeon cell with facing.""" + """Where the party is: in the base town, or on a dungeon cell facing a direction. + + The session keeps one of these on + [`DungeonState.location`][osrlib.crawl.dungeon.DungeonState] and moves it as the party moves. You + read it to draw the map and to know which mode the party is in, and you never write it. The referee + command [`PlaceParty`][osrlib.crawl.commands.PlaceParty] is how a game moves the party by fiat. + + The two shapes are exclusive and the model enforces it: a town location carries no dungeon + fields, and a dungeon location carries all four. + + Attributes: + kind: Which of the two shapes this is. + dungeon_id: The dungeon the party is in. + level_number: The level it is on. + position: The cell it stands on. + facing: The direction it faces. + + Raises: + ValueError: If `kind` is `"dungeon"` and any dungeon field is missing, or if `kind` is + `"town"` and any is set. + + Examples: + ```python + from osrlib.crawl.dungeon import Direction, PartyLocation + + here = PartyLocation(kind="dungeon", dungeon_id="crypt", level_number=1, position=(0, 0), facing=Direction.EAST) + print(here.position, here.facing) + # (0, 0) east + ``` + """ model_config = ConfigDict(frozen=True) kind: Literal["town", "dungeon"] + """`"town"` when the party is in the base town between delves, `"dungeon"` when it is standing on + a grid. A new session starts in town.""" dungeon_id: str | None = None + """The id of the dungeon the party is in, and `None` in town.""" level_number: int | None = None + """The 1-based number of the level the party is on, and `None` in town.""" position: Position | None = None + """The cell the party stands on, and `None` in town.""" facing: Direction | None = None + """The direction the party faces, and `None` in town. Facing is what a front end draws the view + from. Movement itself names its own direction, so the party can step any way it likes regardless + of which way it looks.""" @model_validator(mode="after") def _dungeon_fields_travel_together(self) -> PartyLocation: @@ -677,84 +1404,221 @@ def _dungeon_fields_travel_together(self) -> PartyLocation: class DoorState(BaseModel): - """One door's mutable overlay: open, wedged, discovered, unlocked. - - `opened_by_party` is the swing-shut rule's memory: only doors the party - opened (by whatever means) swing shut behind it; authored-open doors stay. + """What has happened to one door: open, wedged, discovered, unlocked. + + This is the mutable half of a door. [`DoorSpec`][osrlib.crawl.dungeon.DoorSpec] is how you + authored it and never changes. This records what the party has done since. Get one from + [`DungeonState.door`][osrlib.crawl.dungeon.DungeonState.door] with the door's + [`edge_ref`][osrlib.crawl.dungeon.edge_ref]. + + Attributes: + open: Whether the door stands open. + wedged: Whether a spike holds it. + discovered: Whether a secret door has been found. + unlocked: Whether a lock has been dealt with. + opened_by_party: Whether the party is the one that opened it. """ model_config = ConfigDict(validate_assignment=True) open: bool = False + """Whether the door stands open right now. A door with `starts_open` set begins here as `True`. + The party walks through an open door without opening it again.""" wedged: bool = False + """Whether an iron spike holds the door, from [`WedgeDoor`][osrlib.crawl.commands.WedgeDoor]. A + wedged door does not swing shut behind the party, which is the point of carrying spikes.""" discovered: bool = False + """Whether a secret door has been found. A `secret` door does nothing for the party until a + successful secret-door [`Search`][osrlib.crawl.commands.Search] sets this. Until then the edge reads + as wall. A normal door ignores it.""" unlocked: bool = False + """Whether a locked door has been dealt with, by + [`PickLock`][osrlib.crawl.commands.PickLock] or by a referee + [`SetDoorState`][osrlib.crawl.commands.SetDoorState]. A door stays unlocked once it is: relocking + is a referee's write, not something closing the door does.""" opened_by_party: bool = False + """Whether the party is the one that opened this door. The swing-shut rule reads it: only doors + the party opened, by whatever means, swing closed behind it, while a door you authored open stays + open.""" class DroppedItem(BaseModel): - """One dropped item stack in a pile.""" + """One stack of an ordinary item lying on the floor. + + A [`DropPile`][osrlib.crawl.dungeon.DropPile] holds these. Identical items stack, so five iron + spikes are one entry with a quantity rather than five entries. + + Attributes: + item_id: Which item this is. + quantity: How many are in the stack. + """ model_config = ConfigDict(validate_assignment=True) item_id: str + """The item's template id, resolving against the session's effective equipment catalog.""" quantity: int = Field(ge=1) + """How many are in the stack, at least one. A stack that reaches zero is removed from the pile + rather than kept at zero.""" class DropPile(BaseModel): - """Dropped items and coins on a cell — droppable, recoverable, distraction bait. - - Drops and loot round-trip: battle-end loot, death-save survivors, and player - drops all land here and `TakeTreasure` recovers them. + """What is lying on one cell's floor, waiting to be picked up. + + Piles are where loose goods end up: gear the party dropped with + [`DropItems`][osrlib.crawl.commands.DropItems], what the monsters left at the end of a fight, and + the part of a cache the party could not carry. The party picks a pile up with + [`TakeTreasure`][osrlib.crawl.commands.TakeTreasure] naming the reserved feature id `"pile"`, + which is why no authored feature may use that id. Goods the party scatters as bait while it runs + from a pursuer are the exception: those are gone rather than dropped here. + + The session keeps these in [`DungeonState.piles`][osrlib.crawl.dungeon.DungeonState] keyed by + [`cell_ref`][osrlib.crawl.dungeon.cell_ref]. Piles form only where the party has stood, so a pile + is always on a cell the party knows about. + + Attributes: + items: The ordinary items in the pile. + coins: The coins in the pile. + valuables: The gems and jewellery in the pile. + magic_items: The magic items in the pile. """ model_config = ConfigDict(validate_assignment=True) items: list[DroppedItem] = [] + """The ordinary items lying here, stacked by template id.""" coins: Coins = Coins() + """The coins lying here, by denomination.""" valuables: list[ValuableInstance] = [] + """The gems and jewellery lying here, each keeping the id it already had.""" magic_items: list[MagicItemInstance] = [] + """The magic items lying here, each keeping its own charges or quantity.""" class DungeonState(BaseModel): - """The mutable overlay play writes over the frozen adventure content. - - References are strings so the overlay serializes flat: explored and seen - cells key by `"{dungeon}:{level}"`, doors by - [`edge_ref`][osrlib.crawl.dungeon.edge_ref], traps and caches by - `"{dungeon}:{level}:{area_or_feature_id}"`, piles by - [`cell_ref`][osrlib.crawl.dungeon.cell_ref]. `explored` is the party's - footprint — the cells it has physically entered — while `seen` is its map - memory: the cells its own light has shown it, read by the player projection - only. Attempt memory (listen once per character per door, search once per - character per cell per kind, the pick-lock lockout with the thief's level at - failure) lives here too — it is game state, not procedure-local bookkeeping. + """Everything play has changed: the overlay the session writes over frozen content. + + The adventure you authored never changes. This is where the running game records what happened to + it, and it is the part a save file contains. [`GameSession.new`][osrlib.crawl.session.GameSession.new] + makes one, the command handlers write it, and you read it through + [`build_player_view`][osrlib.crawl.views.build_player_view] or + [`build_referee_view`][osrlib.crawl.views.build_referee_view] rather than reaching in, so you get + the right visibility for your audience. + + References into content are strings rather than objects, so the overlay serializes flat and a + save never has to carry the adventure with it. They come in four shapes: `"{dungeon}:{level}"` + keys the explored and seen maps, [`edge_ref`][osrlib.crawl.dungeon.edge_ref] keys doors, + `"{dungeon}:{level}:{area_or_feature_id}"` keys areas and authored features, and + [`cell_ref`][osrlib.crawl.dungeon.cell_ref] keys drop piles. + + Two maps of cells look alike and are not. `explored` is the party's footprint, the cells it has + physically walked, and it is what movement cost reads. `seen` is the party's map memory, the + cells its own light has shown it, and it is read by the player projection only so a front end's + automap can keep a room the party looked into and walked past. + + The attempt memories are here rather than in a procedure's local variables because they are game + state that has to survive a save: who has already listened at this door, who has already searched + this cell for this kind of thing, and which thief failed this lock and at what level. + + Attributes: + location: Where the party is. + explored: Cells the party has walked. + seen: Cells the party has looked at. + doors: What has happened to each door. + sprung_traps: Traps that have gone off. + removed_traps: Traps a thief has taken out. + found_traps: Traps the party knows about. + found_tricks: Construction tricks the party has found. + discovered_features: Unused. + emptied_caches: Authored caches the party has emptied. + piles: What is lying on the floor, by cell. + generated_caches: Treasure the engine rolled, by cache id. + generated_treasure_areas: Areas whose treasure has already rolled. + resolved_encounters: Areas whose keyed encounter is over. + listen_attempts: Who has listened at each door. + search_attempts: Who has searched each cell for what. + inspect_attempts: Who has inspected each cache for traps. + removal_attempts: Who has tried to remove each trap. + lock_failures: Which thief failed each lock, and at what level. """ model_config = ConfigDict(validate_assignment=True) location: PartyLocation = PartyLocation(kind="town") + """Where the party is standing, or that it is in town. A new session starts in town.""" explored: dict[str, list[Position]] = {} + """The cells the party has physically entered, keyed `"{dungeon}:{level}"`. This is the footprint + movement cost reads: stepping back into an explored cell is three times as fast as breaking new + ground. Drop-pile visibility reads it too.""" seen: dict[str, list[Position]] = {} + """The cells the party's light has shown it, keyed `"{dungeon}:{level}"`. This is map memory, + read by the player projection only, and it never affects movement cost. Cells append in sorted + `(x, y)` order so a save is byte-identical across runs.""" doors: dict[str, DoorState] = {} + """Each door's [`DoorState`][osrlib.crawl.dungeon.DoorState], keyed by + [`edge_ref`][osrlib.crawl.dungeon.edge_ref]. Entries appear on first touch rather than up front, + so a door nobody has reached has none. Read one through + [`door`][osrlib.crawl.dungeon.DungeonState.door].""" sprung_traps: list[str] = [] + """The traps that have gone off, by area or feature reference. A sprung trap is done: it never + rolls again.""" removed_traps: list[str] = [] + """The treasure traps a thief has taken out with + [`RemoveTreasureTrap`][osrlib.crawl.commands.RemoveTreasureTrap], by feature reference. A removed + trap never rolls again either. Room traps never appear here, since nothing disarms one.""" found_traps: list[str] = [] + """The traps the party knows about, by area or feature reference, from a successful + [`Search`][osrlib.crawl.commands.Search] on a room trap or + [`InspectTreasure`][osrlib.crawl.commands.InspectTreasure] on a treasure trap. A found trap stops + taking its spring roll, which is what finding one gets you, and a treasure trap has to be here before + [`RemoveTreasureTrap`][osrlib.crawl.commands.RemoveTreasureTrap] will work on it.""" found_tricks: list[str] = [] + """The construction tricks the party has found by searching, by feature reference.""" discovered_features: list[str] = [] + """Nothing writes this. Secret doors record their discovery on + [`DoorState.discovered`][osrlib.crawl.dungeon.DoorState] and found features on `found_traps` and + `found_tricks`, so this stays empty in every session the engine runs.""" emptied_caches: list[str] = [] + """The authored caches the party has emptied, by feature reference. An emptied cache gives + nothing more. Engine-rolled caches are removed from `generated_caches` outright instead of being + listed here.""" piles: dict[str, DropPile] = {} + """What is lying on the floor, keyed by [`cell_ref`][osrlib.crawl.dungeon.cell_ref]. See + [`DropPile`][osrlib.crawl.dungeon.DropPile].""" generated_caches: dict[str, GeneratedCache] = {} + """Treasure the engine rolled, keyed by a minted cache id like `"cache-0001"`. A + `HoardGeneratedEvent` announces the id, [`TakeTreasure`][osrlib.crawl.commands.TakeTreasure] names + it, and emptying one deletes the entry.""" generated_treasure_areas: list[str] = [] + """The areas whose [`AreaTreasureSpec`][osrlib.crawl.dungeon.AreaTreasureSpec] has already rolled, + by area reference, so entering a room twice does not double its loot.""" resolved_encounters: list[str] = [] + """The areas whose keyed encounter is over, by area reference. The room stays clear afterwards.""" listen_attempts: dict[str, list[str]] = {} + """Which characters have listened at each door: character ids keyed by + [`edge_ref`][osrlib.crawl.dungeon.edge_ref]. One try each, so a party cannot listen its way past + a bad roll by queueing up.""" search_attempts: dict[str, list[str]] = {} + """Which characters have searched each cell for each kind of thing: character ids keyed + `"{cell_ref}:{kind}"`, where the kind is what [`Search`][osrlib.crawl.commands.Search] was looking + for. One try each per cell per kind.""" inspect_attempts: dict[str, list[str]] = {} + """Which characters have inspected each cache for traps: character ids keyed by feature + reference. One try each.""" removal_attempts: dict[str, list[str]] = {} + """Which characters have tried to remove each trap: character ids keyed by feature reference. One + try each, so a failed removal is final for that thief.""" lock_failures: dict[str, dict[str, int]] = {} + """Which thief failed which lock, and at what level: character id to level, keyed by + [`edge_ref`][osrlib.crawl.dungeon.edge_ref]. The level is why it records a number rather than a + flag. A thief who failed a lock may try it again once they have gained a level, and this is what + that comparison reads.""" def is_explored(self, dungeon_id: str, level_number: int, position: Position) -> bool: - """Return whether the party has explored a cell. + """Return whether the party has walked a cell. + + Movement cost reads this: a step back into an explored cell costs a third of a step into new + ground. Call it to shade a map, or to work out what a move is about to cost. Args: dungeon_id: The dungeon id. @@ -762,12 +1626,16 @@ def is_explored(self, dungeon_id: str, level_number: int, position: Position) -> position: The cell. Returns: - True when the cell is in the explored set. + True when the party has physically entered that cell. A cell the party has only seen by + its own light answers False. """ return position in self.explored.get(f"{dungeon_id}:{level_number}", []) def mark_explored(self, dungeon_id: str, level_number: int, position: Position) -> None: - """Mark a cell explored (idempotent). + """Mark a cell as walked. + + The session calls this as the party arrives. Marking a cell twice changes nothing, so you can + call it without checking first. Args: dungeon_id: The dungeon id. @@ -782,20 +1650,22 @@ def mark_explored(self, dungeon_id: str, level_number: int, position: Position) cells.append(position) def mark_seen(self, dungeon_id: str, level_number: int, positions: Iterable[Position]) -> None: - """Mark cells seen (idempotent): map memory, what the party has glimpsed by light. + """Mark cells as seen: the party's map memory of what its light has shown it. - Seen cells are read by the player projection only, so a front end's - automap remembers a room the party's light showed it after the party - walks on. They never affect movement cost — that reads the explored - footprint via [`is_explored`][osrlib.crawl.dungeon.DungeonState.is_explored] - — or drop-pile visibility, which stays on explored cells (piles only ever - form where the party has stood). New positions append in sorted `(x, y)` - order so serialization is deterministic across runs. + Seen cells are read by the player projection only, so a front end's automap remembers a room + the party's light reached after the party has walked on. They never affect movement cost, + which reads the walked footprint through + [`is_explored`][osrlib.crawl.dungeon.DungeonState.is_explored], and they never affect + drop-pile visibility, which stays on walked cells because piles only form where the party has + stood. + + Marking a cell twice changes nothing. New cells append in sorted `(x, y)` order, so a save is + byte-identical across runs regardless of the order you pass them in. Args: dungeon_id: The dungeon id. level_number: The 1-based level number. - positions: The cells to remember; already-seen cells are skipped. + positions: The cells to remember. Already-seen cells are skipped. """ key = f"{dungeon_id}:{level_number}" fresh = sorted(set(positions) - set(self.seen.get(key, []))) @@ -803,13 +1673,20 @@ def mark_seen(self, dungeon_id: str, level_number: int, positions: Iterable[Posi self.seen.setdefault(key, []).extend(fresh) def door(self, ref: str) -> DoorState: - """Return (creating on first touch) the mutable state for one door. + """Return one door's live state, creating the entry the first time you ask. + + Door entries are not created up front, so this is how you read one without worrying about + whether the party has reached that door yet. The object it returns is the one in the map, so + writing to it writes to the overlay. Args: - ref: The door's [`edge_ref`][osrlib.crawl.dungeon.edge_ref]. + ref: The door's [`edge_ref`][osrlib.crawl.dungeon.edge_ref]. Both sides of a door + canonicalize to the same reference, so either one reaches the same state. Returns: - The door's overlay entry. + The door's overlay entry, freshly created and all-`False` when the door has not been + touched before. A door you authored `starts_open` is seeded to open by the session, + not here. """ state = self.doors.get(ref) if state is None: diff --git a/src/osrlib/crawl/party.py b/src/osrlib/crawl/party.py index fd6df92..43986fb 100644 --- a/src/osrlib/crawl/party.py +++ b/src/osrlib/crawl/party.py @@ -1,10 +1,19 @@ """The crawl party: marching order, group movement, and combat ranks. -The member list order **is** marching order — there is no separate order field to -desync; `reorder` swaps in place, and `ReorderParty` is the only command that -mutates it. Dead members stay in the party (their gear is carried state; excluding -them is a game decision via referee commands) but never count toward movement rate, -ranks, checks, or provisions. +You build a [`Party`][osrlib.crawl.party.Party] out of the +[`Character`][osrlib.core.character.Character]s your game rolled up, and you hand it to +[`GameSession.new`][osrlib.crawl.session.GameSession.new] beside the adventure. From then on the +session owns it: the party moves as one body, and `session.party` is where you read it back. + +The member list order *is* marching order. There is no second field to keep in step with it, so the +first member is the one in front. [`ReorderParty`][osrlib.crawl.commands.ReorderParty] is the only +command that rewrites that order, and [`reorder`][osrlib.crawl.party.Party.reorder] is what it calls. + +Dead members stay in the list. Their gear is still on them, so removing them would lose it, and +deciding a corpse is left behind is a game's call to make with the referee commands rather than +something the rules do for you. Nothing that counts bodies counts a dead one: movement rate, combat +ranks, ability checks, and provisions all read +[`living_members`][osrlib.crawl.party.Party.living_members]. """ from collections.abc import Sequence @@ -21,27 +30,84 @@ class Party(BaseModel): - """The adventuring party, in marching order.""" + """The adventuring party, in marching order. + + Construct one with at least one member and pass it to + [`GameSession.new`][osrlib.crawl.session.GameSession.new], which assigns each member an entity id + and keeps the party for the life of the session. The methods here answer what the crawl procedures + need to know about the group as a whole: who is still standing, how fast the group walks, and who + stands where in a fight. + + A party is mutable and validates on assignment, so the session can heal, wound, and re-equip its + members in place. The list is never empty: a party whose last member dies ends the session in + `game_over` rather than emptying out. + + Attributes: + members: The characters, front of the line first. + + Examples: + ```python + from osrlib.core.abilities import AbilityScore + from osrlib.core.alignment import Alignment + from osrlib.core.character import Character + from osrlib.crawl.party import Party + + rolled = { + "name": "Hild", + "class_id": "fighter", + "race": "human", + "level": 1, + "xp": 0, + "scores": {ability: 12 for ability in AbilityScore}, + "alignment": Alignment.LAWFUL, + "max_hp": 8, + "current_hp": 8, + } + party = Party(members=[Character(**rolled), Character(**{**rolled, "name": "Osric"})]) + print([member.name for member in party.living_members()]) + # ['Hild', 'Osric'] + ``` + """ model_config = ConfigDict(validate_assignment=True) members: list[Character] = Field(min_length=1) + """The party's characters, in marching order: index 0 walks in front and meets what the party + walks into first. At least one member is required. Ids are assigned by + [`GameSession.new`][osrlib.crawl.session.GameSession.new] when the party joins a session, so a + party you just built has `None` in every `Character.id` until then. Change the order with the + [`ReorderParty`][osrlib.crawl.commands.ReorderParty] command rather than by assigning here, so + the change is logged and replays.""" def living_members(self) -> list[Character]: - """Return the living members, in marching order.""" + """Return the living members, in marching order. + + A member is living until something gives them the `dead` condition. This is the list every + group rule works from, so a fallen member stops counting toward movement, ranks, and checks + the moment they drop, without leaving the party. + + Returns: + The members without the `dead` condition, in marching order. Empty when the whole party + has fallen, which is the session's `game_over` condition. + """ return [member for member in self.members if not has_condition(member, Condition.DEAD)] def member(self, character_id: str) -> Character: """Return the member with `character_id`. + Use this to turn an id out of a command or an event back into the character it names. Ids + come from [`GameSession.new`][osrlib.crawl.session.GameSession.new], which stamps each member + as `character-NNNN` in party order, and events carry them rather than names. + Args: character_id: The member's entity id. Returns: - The character. + The character. Dead members answer here too, because their gear and their record are + still the party's. Raises: - ValueError: If no member has that id. + ValueError: If no member has that id. The message names the id. """ for member in self.members: if member.id == character_id: @@ -49,13 +115,23 @@ def member(self, character_id: str) -> Character: raise ValueError(f"no party member with id {character_id!r}") def movement_rate(self, ruleset: Ruleset) -> int: - """Return the party's exploration rate: the slowest living member's (SRD group rule). + """Return the party's exploration rate: the slowest living member's. + + B/X moves a group at the pace of its slowest member, so one overloaded character slows + everybody. Call [`Character.movement_rate`][osrlib.core.character.Character.movement_rate] + for one character's own allowance, and + [`exploration_rate`][osrlib.crawl.exploration.exploration_rate] for the rate the running + session charges the party, which computes the same minimum and halves a member's rate first + when hunger or thirst has caught up with them under the `deprivation_penalties` ruleset flag. Args: - ruleset: The ruleset whose encumbrance mode governs. + ruleset: The ruleset whose encumbrance mode governs. Encumbrance is what turns carried + weight into a rate, and the modes differ in what they weigh. Returns: - The rate in feet per turn; 0 when nobody is alive. + The rate in feet per exploration turn: 120, 90, 60, 30, or 0. A rate of 0 means the party + cannot move at all, either because its slowest living member is overloaded or because + nobody is alive to walk. """ rates = [member.movement_rate(ruleset) for member in self.living_members()] return min(rates, default=0) @@ -63,15 +139,23 @@ def movement_rate(self, ruleset: Ruleset) -> int: def ranks(self, width: int) -> list[list[Character]]: """Chunk the living members into combat ranks of `width`, in marching order. - The fallen collapse forward by construction: ranks derive from the living - marching order each time they're read. + A rank is one row of the formation: the first `width` living members stand in front and take + the melee, the rest queue behind them. Battle calls this for you with the width it measured + from the space the party is standing in, so you rarely pass your own. Call it yourself to + draw the formation, or to answer "who is in front" outside a fight. + + Ranks are derived on every read rather than stored, so the fallen collapse forward on their + own: when a front-rank member dies, the next living member is in front from the next read on. Args: - width: The formation width (3 in a keyed area, 2 in corridor under the - `formation_width_limit` flag). + width: How many characters stand abreast. Battle derives this from the party's frontage + (see [`FIGHTER_FRONTAGE_FEET`][osrlib.crawl.battle.FIGHTER_FRONTAGE_FEET]) while the + `formation_width_limit` ruleset flag is on, and puts the whole party in one rank when + it is off. Returns: - The ranks, front first. + The ranks, front first. The last rank holds the remainder and can be shorter than + `width`. Empty when nobody is alive. Raises: ValueError: If `width` is not positive. @@ -82,13 +166,21 @@ def ranks(self, width: int) -> list[list[Character]]: return [living[index : index + width] for index in range(0, len(living), width)] def reorder(self, character_ids: Sequence[str]) -> None: - """Rewrite the marching order — `ReorderParty`'s apply step. + """Rewrite the marching order in place. + + This is what [`ReorderParty`][osrlib.crawl.commands.ReorderParty] calls once it has accepted + the command. Issue that command through + [`GameSession.execute`][osrlib.crawl.session.GameSession.execute] rather than calling this + yourself, so the change is logged and a replay reproduces it. Args: - character_ids: Every member's id, in the new order (a permutation). + character_ids: Every member's id, in the new order. It has to be a permutation of the + current membership: no id may be added, dropped, or repeated. Dead members are named + here like anyone else, since they are still in the party. Raises: - ValueError: If the ids are not exactly the current membership. + ValueError: If the ids are not exactly the current membership, or if any member has no id + yet (a party that has not joined a session). """ by_id = {member.id: member for member in self.members if member.id is not None} if len(by_id) != len(self.members) or sorted(character_ids) != sorted(by_id): diff --git a/src/osrlib/crawl/stocking.py b/src/osrlib/crawl/stocking.py index 1e2bcd3..b9a0406 100644 --- a/src/osrlib/crawl/stocking.py +++ b/src/osrlib/crawl/stocking.py @@ -1,40 +1,58 @@ -"""SRD dungeon stocking: roll one keyed area's contents from the stocking tables. - -The stocking *tables* ship as data already — the room-contents d6 with its -per-row treasure-presence chance ([`StockingTable`][osrlib.core.treasure.StockingTable]), -the compiled level-band encounter tables -([`load_encounter_tables`][osrlib.data.load_encounter_tables]), and the treasure -generators. This module is the procedure that consumes them: given a dungeon -level, an effective monster catalog, and one RNG stream, -[`stock_area`][osrlib.crawl.stocking.stock_area] rolls what a single keyed area -holds and answers a frozen [`StockedArea`][osrlib.crawl.stocking.StockedArea] — -content models an author can review, place, and edit, never engine state. - -Determinism is the whole point: every draw comes from the one passed -[`RngStream`][osrlib.core.rng.RngStream], in a fixed order, so a stocked area is -reproducible from `(the stream's state, the level, the table)` alone. The order, -mirrored on the crawl's own wandering resolution so stocking a row yields -precisely what a wandering encounter on that row would: - -1. the stocking d6 (room contents), then the treasure d6 — but only when the - selected row's `treasure_chance_in_six` is non-zero (a printed `None` chance - consumes no die); -2. on a monster room, the encounter table's d20 row, then that row's count dice - (a fixed count consumes none), clamped `max(1, count)`; -3. then either one `variant_dice` roll (the hydra form: the printed HD dice - select the template once) or, for a packed-variant pool row, one uniform pick - per individual. - -The boundary is deliberate. A monster room's rolled individuals group by template -into [`KeyedMonster`][osrlib.crawl.dungeon.KeyedMonster] lines whose `count_fixed` -is set at stocking time — printed modules give concrete counts, and a concrete -number is what an author reviews and edits. An empty or trap room that rolls treasure gets an -unguarded [`AreaTreasureSpec`][osrlib.crawl.dungeon.AreaTreasureSpec]; a monster -room's treasure is the encounter itself (its `hoard` flag), never a second -declaration. Traps and specials produce no models — B/X ships example lists as -referee prose, not tables — and an NPC-party row has no authorable content at -all: [`stock_area`][osrlib.crawl.stocking.stock_area] reports the rolled kind and -count and stops. The procedure ends where the referee's design begins. +"""Stocking: roll what one keyed room holds, from the B/X tables. + +[`stock_area`][osrlib.crawl.stocking.stock_area] is the entry point, and the two models here are what +it returns. Give it a dungeon level number, +a monster catalog, and an RNG stream, and it rolls one room the way the B/X procedure does: the +room-contents d6, the treasure chance that row prints, and, on a monster room, the level's encounter +table and the count that row calls for. What comes back is a frozen +[`StockedArea`][osrlib.crawl.stocking.StockedArea] holding content models you can read, edit, and +place: a [`KeyedEncounter`][osrlib.crawl.dungeon.KeyedEncounter] to drop on an +[`AreaSpec`][osrlib.crawl.dungeon.AreaSpec], or an +[`AreaTreasureSpec`][osrlib.crawl.dungeon.AreaTreasureSpec] to put on one. It is an authoring tool, +not part of play: nothing here touches a session, and a stocked area is content rather than state. + +The tables it rolls on ship as data already. The room-contents d6 with its per-row treasure chance is +[`StockingTable`][osrlib.core.treasure.StockingTable], the encounter tables are +[`load_encounter_tables`][osrlib.data.load_encounter_tables], and the treasure generators live in +`osrlib.core.treasure`. This module is the procedure that puts them together. + +Every draw comes from the one stream you pass, in a fixed order, so the same stream state and the same +level give the same room every time. The order matches the crawl's own wandering resolution, so +stocking a row yields exactly what a wandering encounter on that row would: + +1. the room-contents d6, then the treasure d6, the second only when the selected row prints a + non-zero treasure chance (a row with no printed chance consumes no die). +2. on a monster room, the encounter table's d20 row, then that row's count dice (a row with a fixed + count consumes none), with the result held at 1 or more. +3. then either one `variant_dice` roll, which is the hydra form where the printed hit-dice roll + selects the template once, or, for a packed-variant pool row, one uniform pick per individual. + +Where the procedure stops is deliberate. A monster room's rolled individuals are grouped into +[`KeyedMonster`][osrlib.crawl.dungeon.KeyedMonster] lines with fixed counts, because a printed module +gives concrete numbers and a concrete number is what you review and edit. An empty or trap room that +rolled treasure gets an unguarded `AreaTreasureSpec`. A monster room's treasure is the encounter's +own `hoard` flag rather than a second declaration. Traps and specials produce no model at all, because +B/X prints example lists for those as referee prose rather than as tables, and an NPC-party row has no +authorable content either, so `stock_area` reports the kind and the count and stops. The procedure +ends where the dice end, and the rest is yours to design. + +Typical usage: + +```python +from osrlib.core.rng import RngStream +from osrlib.crawl.stocking import stock_area +from osrlib.data import load_monsters + +catalog = load_monsters() +stream = RngStream.from_seed_material(7, "stocking") +for _ in range(3): + area = stock_area(1, catalog=catalog, stream=stream) + lines = [(line.template_id, line.count_fixed) for line in (area.encounter.monsters if area.encounter else ())] + print(area.contents, area.treasure_present, lines) +# monster False [('gecko', 3)] +# monster False [('trader', 3)] +# special False [] +``` """ from typing import Literal @@ -57,41 +75,72 @@ class StockedNpcParty(BaseModel): - """An NPC-party stocking roll: the rolled party kind and its count. + """The party kind and size a monster room's encounter row rolled. + + An NPC party has no authorable content model. A party is built at play from character classes and + their gear rather than from a keyed encounter or treasure letters, so there is nothing for + [`stock_area`][osrlib.crawl.stocking.stock_area] to hand back and place. The roll still named a + row and a count, so it reports that much and leaves the party for you to write by hand. - An NPC-party encounter-table row has no authorable content model — a party - is generated at play from class treasure, not a keyed encounter and not - treasure letters. The dice still spoke (the row, then its count), so - [`stock_area`][osrlib.crawl.stocking.stock_area] reports what they said and - leaves the party for the author to place by hand. + You get one on [`StockedArea.npc_party`][osrlib.crawl.stocking.StockedArea], and only on a room + whose `contents` is `"monster"`. + + Attributes: + kind: Which encounter list the party came off. + count: How many are in it. """ model_config = ConfigDict(frozen=True) kind: Literal["basic", "expert"] + """Which of the two NPC-party lists the row came off: `"basic"` or `"expert"`. The two lists + describe different sorts of party, and the row you rolled names one of the two.""" count: int = Field(ge=1) + """How many are in the party, from the row's count, held at 1 or more.""" class StockedArea(BaseModel): - """The whole answer of stocking one keyed area — content models, never state. - - `contents` is the stocking d6's outcome and `treasure_present` the treasure - d6's (always `False` when the row's printed chance is zero, e.g. a special). - At most one authorable payload rides alongside: `encounter` for a monster - room, `npc_party` for a monster room whose d20 row rolled an NPC party, or - `treasure` (only ever the unguarded form) for an empty or trap room that - rolled treasure. A monster room's treasure is its encounter's `hoard` flag, - so `treasure` stays `None` there; traps and specials carry no model of their - own — those are the referee's to design. + """Everything the rolls produced for one keyed room: content models you place, never game state. + + This is what [`stock_area`][osrlib.crawl.stocking.stock_area] returns. Read `contents` first to + find out what sort of room you rolled, then take whichever payload came with it and write it onto + an [`AreaSpec`][osrlib.crawl.dungeon.AreaSpec] you are building. + + At most one payload rides along: `encounter` for a monster room, `npc_party` for a monster room + whose encounter row turned out to be an NPC party, or `treasure` for an empty or trap room that + rolled treasure. A monster room's treasure is its encounter's `hoard` flag rather than a separate + declaration, so `treasure` is always `None` there. Traps and specials have no payload at all, + because B/X leaves those to the referee. + + Attributes: + contents: The room-contents d6's result. + treasure_present: The treasure d6's result. + encounter: The monsters, on a monster room. + npc_party: The NPC party, when the encounter row rolled one. + treasure: The unguarded treasure, on an empty or trap room that rolled some. """ model_config = ConfigDict(frozen=True) contents: Literal["empty", "monster", "special", "trap"] + """The room-contents d6's result: `"empty"`, `"monster"`, `"special"`, or `"trap"`. A special is + a magical or unusual feature and a trap is a room trap, and B/X prints example lists for both as + referee prose, so neither comes with a model.""" treasure_present: bool + """Whether the treasure d6 came up in the room's favour. It is always `False` when the row prints + no treasure chance, which is the case for a special, and no die is rolled then. On a monster room + this is the same value as the encounter's `hoard` flag.""" encounter: KeyedEncounter | None = None + """The monsters in the room, on a monster room, and `None` otherwise. The lines carry fixed + counts, already rolled, and the encounter's `hoard` follows `treasure_present`. It is `None` on a + monster room whose row rolled an NPC party.""" npc_party: StockedNpcParty | None = None + """The NPC party the encounter row rolled, or `None`. Only ever set on a monster room, and never + alongside `encounter`. See [`StockedNpcParty`][osrlib.crawl.stocking.StockedNpcParty].""" treasure: AreaTreasureSpec | None = None + """Unguarded treasure for an empty or trap room that rolled some, and `None` otherwise. It is + always the unguarded form rather than named letters, since nothing is lairing here to have a + printed hoard.""" def stock_area( @@ -101,36 +150,48 @@ def stock_area( stream: RngStream, table: EncounterTable | None = None, ) -> StockedArea: - """Roll one keyed area's contents from the SRD stocking tables. + """Roll one keyed room's contents from the B/X stocking tables. + + Call this once per room you want the dice to fill, while you are authoring. It rolls the + room-contents d6 and, when that row prints a treasure chance, the treasure d6. On a monster room + it then rolls the encounter table, using `table` when you pass one and the level's compiled band + otherwise. Take the [`StockedArea`][osrlib.crawl.stocking.StockedArea] it returns and write its + payload onto an [`AreaSpec`][osrlib.crawl.dungeon.AreaSpec] you are building, then check the + finished adventure with + [`validate_adventure`][osrlib.crawl.adventure.validate_adventure]. - Runs the room-contents d6 and, when the row calls for it, the treasure d6; - on a monster room it then rolls the encounter table (the caller's `table` - when set, else the level's compiled band). Every draw comes from `stream` - in the fixed order documented on the module, so the result is reproducible - from the stream's state alone. + Every draw comes from `stream` in the fixed order [`osrlib.crawl.stocking`][osrlib.crawl.stocking] + sets out, so the result is reproducible from the stream's state and the level alone. Pass the same + stream to successive calls to stock a whole level, and it advances between rooms. + + This is not something you call during play. It writes nothing, reads no session, and rolls + content models rather than spawning anything. The session's own wandering check is + [`wandering_check`][osrlib.crawl.exploration.wandering_check], and it works from the same tables. Args: - level_number: The dungeon level being stocked — selects the compiled - encounter band when no `table` override is given, and (via the - treasure generators at play) the unguarded band. - catalog: The effective monster catalog (the shipped catalog composed - with the adventure's bundled templates, the way - `GameSession.effective_monsters` composes it). Each rolled monster - id is resolved through it exactly as `session.spawn` would at play, - so a stocked encounter references only monsters the catalog holds - and an authored override table naming a bundled monster resolves. - stream: The RNG stream every draw advances — what makes the result reproducible. - table: An authored encounter-table override (the level's - `WanderingSpec.table`), mirroring `wandering_check`'s own - resolution; `None` uses `load_encounter_tables().for_level`. + level_number: The dungeon level being stocked. It selects the compiled encounter band when you + pass no `table`, and it is the level whose unguarded-treasure band the resulting + `AreaTreasureSpec` rolls on later, at play. + catalog: The monster catalog to resolve rolled ids against. Pass the session's effective + catalog, which is the shipped one composed with the adventure's bundled templates the way + [`GameSession.effective_monsters`][osrlib.crawl.session.GameSession.effective_monsters] + composes it, or [`load_monsters`][osrlib.data.load_monsters] on its own when the adventure + bundles nothing. Every rolled id is resolved through it exactly as spawning would at play, + so a stocked encounter can only reference monsters that exist. + stream: The [`RngStream`][osrlib.core.rng.RngStream] every draw advances. It is what makes the + result reproducible, and it is the only randomness in the call. + table: An encounter table to roll on instead of the level's compiled band, usually a level's + own `WanderingSpec.table`. This mirrors how the crawl's wandering check resolves its + table, so a level with custom inhabitants stocks from them too. `None` uses + [`load_encounter_tables`][osrlib.data.load_encounter_tables] for the level. Returns: - The stocked area. + What the room holds. See [`StockedArea`][osrlib.crawl.stocking.StockedArea]. Raises: - ValueError: If the encounter table rolls a monster id the effective - catalog does not hold — a malformed table, the same refusal - `session.spawn` raises at play. + ValueError: If the encounter table rolls a monster id `catalog` does not hold, which means the + table is malformed. This is the same refusal spawning raises at play, brought forward to + authoring time. Examples: ```python @@ -138,8 +199,12 @@ def stock_area( from osrlib.crawl.stocking import stock_area from osrlib.data import load_monsters - area = stock_area(1, catalog=load_monsters(), stream=RngStream.from_seed_material(42, "stock:1/1/7")) - assert area.contents in ("empty", "monster", "special", "trap") + area = stock_area(1, catalog=load_monsters(), stream=RngStream.from_seed_material(7, "stocking")) + print(area.contents, area.treasure_present) + # monster False + + print([(line.template_id, line.count_fixed) for line in area.encounter.monsters]) + # [('gecko', 3)] ``` """ result = roll_room_contents(stream) @@ -147,9 +212,9 @@ def stock_area( treasure_present = result.treasure_present if contents == "monster": return _stock_monster_room(level_number, catalog, stream, table, treasure_present) - # Empty and trap rooms that rolled treasure get an unguarded cache; a special - # never rolls treasure (its printed chance is zero) and, like every non-monster - # room, carries no encounter — the referee designs the special and the trap. + # Empty and trap rooms that rolled treasure get an unguarded cache. A special never + # rolls treasure, because its printed chance is zero, and like every non-monster room + # it carries no encounter: the referee designs the special and the trap. treasure = AreaTreasureSpec(unguarded=True) if treasure_present and contents in ("empty", "trap") else None return StockedArea(contents=contents, treasure_present=treasure_present, treasure=treasure) @@ -161,7 +226,7 @@ def _stock_monster_room( table: EncounterTable | None, treasure_present: bool, ) -> StockedArea: - """Roll a monster room's d20 encounter and build its keyed lines — the crawl's own resolution.""" + """Roll a monster room's d20 encounter and build its keyed lines, the way the crawl resolves one.""" resolved_table = table if table is not None else load_encounter_tables().for_level(level_number) row = resolved_table.rows[stream.randbelow(20)] if row.count_fixed is not None: @@ -181,7 +246,7 @@ def _stock_monster_room( ) template_ids = select_encounter_individuals(entry, count, stream) # Resolve every distinct rolled id through the effective catalog exactly as - # session.spawn would — a stocked encounter references only real monsters. + # session.spawn would, so a stocked encounter references only real monsters. for template_id in dict.fromkeys(template_ids): catalog.get(template_id) return StockedArea( @@ -192,7 +257,11 @@ def _stock_monster_room( def _group_monsters(template_ids: list[str]) -> tuple[KeyedMonster, ...]: - """Fold individuals into one `KeyedMonster` line per template, first-appearance order, count fixed.""" + """Fold the rolled individuals into one `KeyedMonster` line per template, in first-appearance order. + + Counts are fixed rather than dice: the roll has already happened, and a concrete number is what an + author reviews and edits. + """ counts: dict[str, int] = {} for template_id in template_ids: counts[template_id] = counts.get(template_id, 0) + 1 From af0851033fe99ddb756471f2b5bda3d6b74e130a Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Sun, 13 Sep 2026 22:23:12 -0700 Subject: [PATCH 2/2] Docstrings: four corrections from the review of crawl authoring Show `wall` and `open` in the edge examples, since EdgeKind is a StrEnum and print emits the value. Say that EnterDungeon lands the party facing north, on both DungeonSpec.levels and LevelSpec.entrance, and that TravelToTown refuses to leave from anywhere but the entrance cell. Put validate_adventure's check list in the order the body runs it, with the town-travel and dungeon-entrance checks before the per-level loop. Add the dice-expression ValueError to TrapEffect's Raises. Claude-Session: https://claude.ai/code/session_01GL26QnA6dCrvUc3WmhzFSa --- src/osrlib/crawl/adventure.py | 31 ++++++++++++++++++------------- src/osrlib/crawl/dungeon.py | 21 ++++++++++++--------- 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/src/osrlib/crawl/adventure.py b/src/osrlib/crawl/adventure.py index 8eb6e95..b55f765 100644 --- a/src/osrlib/crawl/adventure.py +++ b/src/osrlib/crawl/adventure.py @@ -572,19 +572,24 @@ def validate_adventure(adventure: Adventure, monsters: MonsterCatalog, equipment never start on content that would fail partway through a delve. It changes nothing and returns nothing, so a clean adventure comes back unchanged. - It checks the following, roughly in the order the message lists them. Bundled monster ids, - against the shipped catalog and each other. Bundled item ids, against the equipment catalog, the shipped - magic-item catalog, and each other. Then, for each level: area cells and feature cells on the - grid, feature ids unique across the level, cache item ids and magic item ids resolving, keyed - encounter template ids resolving and any fixed alignment being one the template allows, inline - wandering-table monster ids resolving, the item ids named by `has_item` gates on doors and - transitions resolving, transitions landing on real cells of real levels, the town's travel entries - naming real dungeons, and every dungeon having an entrance on some level. Then, for each trigger: - its id unique, the area, level, dungeon, item, and monster its pattern names resolving, and for - each consequence the item it grants, the monster it spawns, a door actually standing at the cell a - door-state consequence names, and a placement landing on the grid. Then, for each quest: its id unique, and the - same checks over every clause it has (its activation, each objective's completion, each hidden - objective's reveal) and every reward it pays. + It checks the following, in the order the message lists them. First the bundled ids: monster ids + against the shipped catalog and each other, then item ids against the equipment catalog, the + shipped magic-item catalog, and each other. Then the town's travel entries naming real dungeons, + and every dungeon having an entrance on some level. + + Then, for each level: feature ids unique across the level and none of them the reserved id + `"pile"`, the entrance on the grid, area ids unique, area cells on the grid, keyed encounter + template ids resolving and any fixed alignment being one the template allows, feature cells on + the grid with their cache item ids and magic item ids resolving, every level-scope feature having + a cell, inline wandering-table monster ids resolving, the item ids named by `has_item` gates on + doors and transitions resolving, and transitions standing on the grid and landing on real cells of + real levels. + + Then, for each trigger: its id unique, the area, level, dungeon, item, and monster its pattern + names resolving, and for each consequence the item it grants, the monster it spawns, a door + actually standing at the cell a door-state consequence names, and a placement landing on the grid. + Then, for each quest: its id unique, and the same checks over every clause it has (its activation, + each objective's completion, each hidden objective's reveal) and every reward it pays. One rule is about authoring rather than about a dangling id. A consequence that addresses a character has to do so through a party selector, because character ids are allocated when a diff --git a/src/osrlib/crawl/dungeon.py b/src/osrlib/crawl/dungeon.py index 80df3f1..8ed9fde 100644 --- a/src/osrlib/crawl/dungeon.py +++ b/src/osrlib/crawl/dungeon.py @@ -41,7 +41,7 @@ # 1,0:west print(crypt.level(1).edge((0, 0), Direction.NORTH).kind) -# EdgeKind.WALL +# wall ``` """ @@ -489,8 +489,9 @@ class TrapEffect(BaseModel): manual: Prose for a trap the rules do not resolve. Raises: - ValueError: If a duration is given with no `condition`, if `volley_dice` is given with no - `damage_dice`, or if `transition` carries a gate. + ValueError: If `damage_dice`, `volley_dice`, or `condition_duration_dice` is not a dice + expression the grammar accepts, if a duration is given with no `condition`, if + `volley_dice` is given with no `damage_dice`, or if `transition` carries a gate. Examples: ```python @@ -1142,10 +1143,10 @@ class LevelSpec(BaseModel): edges={"1,0:west": Edge(kind=EdgeKind.OPEN)}, ) print(corridor.edge((0, 0), Direction.EAST).kind) - # EdgeKind.OPEN + # open print(corridor.edge((0, 0), Direction.NORTH).kind) - # EdgeKind.WALL + # wall ``` """ @@ -1180,9 +1181,11 @@ class LevelSpec(BaseModel): turns off the compiled table for this level's number.""" entrance: Position | None = None """The cell the party arrives at from town, or `None` for a level with no way in from outside. - [`EnterDungeon`][osrlib.crawl.commands.EnterDungeon] and - [`TravelToTown`][osrlib.crawl.commands.TravelToTown] both use it. Some level of every dungeon - needs one, and `validate_adventure` refuses a dungeon where no level has any.""" + [`EnterDungeon`][osrlib.crawl.commands.EnterDungeon] puts the party here facing north, whatever + the geometry around the cell looks like, and + [`TravelToTown`][osrlib.crawl.commands.TravelToTown] refuses to leave unless the party is + standing on it. Some level of every dungeon needs one, and `validate_adventure` refuses a dungeon + where no level has any.""" guidance: str = "" """Ambient steering for a narrating front end while the party is on this level: the tone of the place, what you want said about it, what you never want said. @@ -1314,7 +1317,7 @@ class DungeonSpec(BaseModel): """The dungeon's levels, at least one, with unique `number`s. Look one up with [`level`][osrlib.crawl.dungeon.DungeonSpec.level] rather than by position. The order does matter in one place: [`EnterDungeon`][osrlib.crawl.commands.EnterDungeon] lands the party on the first - level in this tuple that has an `entrance`.""" + level in this tuple that has an `entrance`, facing north.""" @model_validator(mode="after") def _level_numbers_unique(self) -> DungeonSpec: