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..b55f765 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,84 @@ 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, 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 + 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..8ed9fde 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) +# 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,149 @@ 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 `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 + 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 +578,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 +630,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 +722,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 +892,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 +947,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 +1071,137 @@ 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) + # 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) + # 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] 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 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 +1212,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 +1238,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 +1256,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 +1275,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`, facing north.""" @model_validator(mode="after") def _level_numbers_unique(self) -> DungeonSpec: @@ -639,6 +1329,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 +1339,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 +1348,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 +1407,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 +1629,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 +1653,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 +1676,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