Skip to content

osrlib.crawl.dungeon

The dungeon: the grid you author, and the overlay play writes over it.

This module holds both halves of a dungeon. DungeonSpec and everything under it (levels, edges, areas, features, traps, transitions) is authored content: frozen models you build, hand to an Adventure, and never change again. 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. You never construct DungeonState yourself: 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.

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 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:

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

Position module-attribute

Position = tuple[int, int]

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 is how you ask, and validate_adventure is what catches an authored one that lands outside.

AreaSpec

Bases: BaseModel

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, 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:

Name Type Description
id str

The area's id, unique across its level.

name str

The room's name.

description str

Prose for your front end.

cells tuple[Position, ...]

The cells the area covers.

encounter KeyedEncounter | None

The monsters waiting here.

features tuple[FeatureSpec, ...]

The keyed things in the room.

trap TrapSpec | None

A room trap over the whole area.

treasure AreaTreasureSpec | None

Generated treasure with nothing guarding it.

Raises:

Type Description
ValueError

If trap is not a room trap.

Examples:

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

id instance-attribute

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 class-attribute instance-attribute

name: str = ''

The room's name, for your front end to show: "Guard post", "The abbot's cell".

description class-attribute instance-attribute

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.

cells class-attribute instance-attribute

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] is where a generated hoard lands.

encounter class-attribute instance-attribute

encounter: KeyedEncounter | None = None

The monsters waiting in the room, or None for an empty one. See KeyedEncounter.

features class-attribute instance-attribute

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 class-attribute instance-attribute

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 class-attribute instance-attribute

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.

AreaTreasureSpec

Bases: BaseModel

Treasure the engine rolls for an area that has no monsters guarding it.

Put one on an 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. Generated treasure is never trapped. Trapping is authored, through a FeatureSpec with a trap on it.

For an area whose treasure is a monster's hoard, use a KeyedEncounter and its hoard flag instead: that is the lair treasure, and it comes with the monsters.

Attributes:

Name Type Description
letters tuple[str, ...]

The treasure type letters to roll.

unguarded bool

Whether to roll the level's unguarded-treasure band instead.

Raises:

Type Description
ValueError

If both letters and unguarded are given, or neither.

Examples:

from osrlib.crawl.dungeon import AreaTreasureSpec

print(AreaTreasureSpec(letters=("C",)).unguarded)
# False

letters class-attribute instance-attribute

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. Set this or unguarded, not both.

unguarded class-attribute instance-attribute

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.

Direction

Bases: StrEnum

The four grid directions the party faces and moves in.

This is the direction vocabulary the whole crawl uses: which way MoveParty steps, which side of a cell OpenDoor works on, which way a transition faces the party on arrival. There is no up or down here. Between levels is a TransitionSpec.

The wire values are lowercase and they serialize into commands, events, and saves, so changing one is a schema_version bump.

NORTH class-attribute instance-attribute

NORTH = 'north'

Decreasing y: toward the top of the map.

EAST class-attribute instance-attribute

EAST = 'east'

Increasing x: toward the right of the map.

SOUTH class-attribute instance-attribute

SOUTH = 'south'

Increasing y: toward the bottom of the map.

WEST class-attribute instance-attribute

WEST = 'west'

Decreasing x: toward the left of the map.

vector property

vector: tuple[int, int]

The (dx, dy) step for one cell in this direction.

Add it to a position to get the neighbour, or call step, which does the addition for you.

opposite property

opposite: 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.

DoorSpec

Bases: BaseModel

A door on an edge, as you authored it.

Put one on an 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 in the overlay instead, which is where you read whether a door is open right now.

Attributes:

Name Type Description
kind Literal['normal', 'secret']

Whether the door is visible from the start or has to be found.

stuck bool

Whether the door needs forcing before it opens.

locked bool

Whether the door needs a key or a thief before it opens.

starts_open bool

Whether the door stands open when the party first arrives.

requires GateSpec | None

An authored condition the party must satisfy to open the door.

Examples:

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

kind class-attribute instance-attribute

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 class-attribute instance-attribute

stuck: bool = False

Whether the door is stuck shut. The engine refuses OpenDoor on a stuck door, so the party has to force it with ForceDoor, a strength check that costs a turn whether or not it works.

locked class-attribute instance-attribute

locked: bool = False

Whether the door is locked. The engine refuses to open a locked door until something unlocks it: PickLock by a thief, or a referee SetDoorState. Forcing still works, since a locked door can be broken open.

starts_open class-attribute instance-attribute

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 class-attribute instance-attribute

requires: GateSpec | None = None

An authored gate on opening the door, or None for a door anyone may open. A gate (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 addresses only the lock, and 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.

DoorState

Bases: BaseModel

What has happened to one door: open, wedged, discovered, unlocked.

This is the mutable half of a door. DoorSpec is how you authored it and never changes. This records what the party has done since. Get one from DungeonState.door with the door's edge_ref.

Attributes:

Name Type Description
open bool

Whether the door stands open.

wedged bool

Whether a spike holds it.

discovered bool

Whether a secret door has been found.

unlocked bool

Whether a lock has been dealt with.

opened_by_party bool

Whether the party is the one that opened it.

open class-attribute instance-attribute

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 class-attribute instance-attribute

wedged: bool = False

Whether an iron spike holds the door, from WedgeDoor. A wedged door does not swing shut behind the party, which is the point of carrying spikes.

discovered class-attribute instance-attribute

discovered: bool = False

Whether a secret door has been found. A secret door does nothing for the party until a successful secret-door Search sets this. Until then the edge reads as wall. A normal door ignores it.

unlocked class-attribute instance-attribute

unlocked: bool = False

Whether a locked door has been dealt with, by PickLock or by a referee SetDoorState. A door stays unlocked once it is: relocking is a referee's write, not something closing the door does.

opened_by_party class-attribute instance-attribute

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.

DropPile

Bases: BaseModel

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, 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 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 keyed by cell_ref. Piles form only where the party has stood, so a pile is always on a cell the party knows about.

Attributes:

Name Type Description
items list[DroppedItem]

The ordinary items in the pile.

coins Coins

The coins in the pile.

valuables list[ValuableInstance]

The gems and jewellery in the pile.

magic_items list[MagicItemInstance]

The magic items in the pile.

items class-attribute instance-attribute

items: list[DroppedItem] = []

The ordinary items lying here, stacked by template id.

coins class-attribute instance-attribute

coins: Coins = Coins()

The coins lying here, by denomination.

valuables class-attribute instance-attribute

valuables: list[ValuableInstance] = []

The gems and jewellery lying here, each keeping the id it already had.

magic_items class-attribute instance-attribute

magic_items: list[MagicItemInstance] = []

The magic items lying here, each keeping its own charges or quantity.

DroppedItem

Bases: BaseModel

One stack of an ordinary item lying on the floor.

A DropPile holds these. Identical items stack, so five iron spikes are one entry with a quantity rather than five entries.

Attributes:

Name Type Description
item_id str

Which item this is.

quantity int

How many are in the stack.

item_id instance-attribute

item_id: str

The item's template id, resolving against the session's effective equipment catalog.

quantity class-attribute instance-attribute

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.

DungeonSpec

Bases: BaseModel

A dungeon: one or more levels joined by transitions.

This is the unit an Adventure holds and the unit 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 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 is what checks that one does.

Attributes:

Name Type Description
id str

The dungeon's id.

name str

The dungeon's name.

levels tuple[LevelSpec, ...]

Its levels.

Raises:

Type Description
ValueError

If two levels carry the same number.

Examples:

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

id instance-attribute

id: str

The dungeon's id, unique within the adventure. EnterDungeon, the town's travel_turns map, transitions between dungeons, and every state-overlay reference name it.

name class-attribute instance-attribute

name: str = ''

The dungeon's name, for your front end to show: "The Old Crypt".

levels class-attribute instance-attribute

levels: tuple[LevelSpec, ...] = Field(min_length=1)

The dungeon's levels, at least one, with unique numbers. Look one up with level rather than by position. The order does matter in one place: EnterDungeon lands the party on the first level in this tuple that has an entrance, facing north.

level

level(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.

Parameters:

Name Type Description Default
number int

The 1-based level number.

required

Returns:

Type Description
LevelSpec

The level spec.

Raises:

Type Description
ValueError

If no level has that number. The message names the dungeon and the number.

DungeonState

Bases: BaseModel

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 makes one, the command handlers write it, and you read it through build_player_view or 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 keys doors, "{dungeon}:{level}:{area_or_feature_id}" keys areas and authored features, and 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:

Name Type Description
location PartyLocation

Where the party is.

explored dict[str, list[Position]]

Cells the party has walked.

seen dict[str, list[Position]]

Cells the party has looked at.

doors dict[str, DoorState]

What has happened to each door.

sprung_traps list[str]

Traps that have gone off.

removed_traps list[str]

Traps a thief has taken out.

found_traps list[str]

Traps the party knows about.

found_tricks list[str]

Construction tricks the party has found.

emptied_caches list[str]

Authored caches the party has emptied.

piles dict[str, DropPile]

What is lying on the floor, by cell.

generated_caches dict[str, GeneratedCache]

Treasure the engine rolled, by cache id.

generated_treasure_areas list[str]

Areas whose treasure has already rolled.

resolved_encounters list[str]

Areas whose keyed encounter is over.

listen_attempts dict[str, list[str]]

Who has listened at each door.

search_attempts dict[str, list[str]]

Who has searched each cell for what.

inspect_attempts dict[str, list[str]]

Who has inspected each cache for traps.

removal_attempts dict[str, list[str]]

Who has tried to remove each trap.

lock_failures dict[str, dict[str, int]]

Which thief failed each lock, and at what level.

location class-attribute instance-attribute

location: PartyLocation = PartyLocation(kind='town')

Where the party is standing, or that it is in town. A new session starts in town.

explored class-attribute instance-attribute

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 class-attribute instance-attribute

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 class-attribute instance-attribute

doors: dict[str, DoorState] = {}

Each door's DoorState, keyed by edge_ref. Entries appear on first touch rather than up front, so a door nobody has reached has none. Read one through door.

sprung_traps class-attribute instance-attribute

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 class-attribute instance-attribute

removed_traps: list[str] = []

The treasure traps a thief has taken out with RemoveTreasureTrap, by feature reference. A removed trap never rolls again either. Room traps never appear here, since nothing disarms one.

found_traps class-attribute instance-attribute

found_traps: list[str] = []

The traps the party knows about, by area or feature reference, from a successful Search on a room trap or 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 will work on it.

found_tricks class-attribute instance-attribute

found_tricks: list[str] = []

The construction tricks the party has found by searching, by feature reference.

emptied_caches class-attribute instance-attribute

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 class-attribute instance-attribute

piles: dict[str, DropPile] = {}

What is lying on the floor, keyed by cell_ref. See DropPile.

generated_caches class-attribute instance-attribute

generated_caches: dict[str, GeneratedCache] = {}

Treasure the engine rolled, keyed by a minted cache id like "cache-0001". A HoardGeneratedEvent announces the id, TakeTreasure names it, and emptying one deletes the entry.

generated_treasure_areas class-attribute instance-attribute

generated_treasure_areas: list[str] = []

The areas whose AreaTreasureSpec has already rolled, by area reference, so entering a room twice does not double its loot.

resolved_encounters class-attribute instance-attribute

resolved_encounters: list[str] = []

The areas whose keyed encounter is over, by area reference. The room stays clear afterwards.

listen_attempts class-attribute instance-attribute

listen_attempts: dict[str, list[str]] = {}

Which characters have listened at each door: character ids keyed by edge_ref. One try each, so a party cannot listen its way past a bad roll by queueing up.

search_attempts class-attribute instance-attribute

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 was looking for. One try each per cell per kind.

inspect_attempts class-attribute instance-attribute

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 class-attribute instance-attribute

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 class-attribute instance-attribute

lock_failures: dict[str, dict[str, int]] = {}

Which thief failed which lock, and at what level: character id to level, keyed by 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.

is_explored

is_explored(dungeon_id: str, level_number: int, position: Position) -> bool

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.

Parameters:

Name Type Description Default
dungeon_id str

The dungeon id.

required
level_number int

The 1-based level number.

required
position Position

The cell.

required

Returns:

Type Description
bool

True when the party has physically entered that cell. A cell the party has only seen by its own light answers False.

mark_explored

mark_explored(dungeon_id: str, level_number: int, position: Position) -> None

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.

Parameters:

Name Type Description Default
dungeon_id str

The dungeon id.

required
level_number int

The 1-based level number.

required
position Position

The cell.

required

mark_seen

mark_seen(dungeon_id: str, level_number: int, positions: Iterable[Position]) -> None

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 reached after the party has walked on. They never affect movement cost, which reads the walked footprint through 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.

Parameters:

Name Type Description Default
dungeon_id str

The dungeon id.

required
level_number int

The 1-based level number.

required
positions Iterable[Position]

The cells to remember. Already-seen cells are skipped.

required

door

door(ref: str) -> DoorState

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.

Parameters:

Name Type Description Default
ref str

The door's edge_ref. Both sides of a door canonicalize to the same reference, so either one reaches the same state.

required

Returns:

Type Description
DoorState

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.

Edge

Bases: BaseModel

One entry in a level's edges map: what stands on the boundary between two cells.

You write these into LevelSpec.edges keyed by edge_key. Only the exceptions need entries, because an edge with no entry is wall. Read one back with LevelSpec.edge, which supplies a wall for anything absent and for the level boundary.

Attributes:

Name Type Description
kind EdgeKind

What occupies the edge.

door DoorSpec | None

The door, when kind is door.

Raises:

Type Description
ValueError

If kind is door and door is None, or door is set on any other kind.

Examples:

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

kind instance-attribute

kind: EdgeKind

Whether the boundary is open, wall, or a door.

door class-attribute instance-attribute

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.

EdgeKind

Bases: StrEnum

What occupies an edge between two cells.

This is the kind of an Edge entry, and it is what makes the boundary between two cells passable or not.

OPEN class-attribute instance-attribute

OPEN = 'open'

Nothing in the way: the party walks across.

WALL class-attribute instance-attribute

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 class-attribute instance-attribute

DOOR = 'door'

A door stands here, and the entry contains the DoorSpec that describes it. An edge of this kind must include one, and no other kind may.

FeatureSpec

Bases: BaseModel

A keyed thing in a room: a treasure cache, a construction trick, or your own content.

Features hang on an AreaSpec or straight on a LevelSpec, and they are how a room holds something the party can find and interact with. Stairs are not features. Those are TransitionSpecs, 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 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. 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:

Name Type Description
id str

The feature's id, unique across its level.

kind Literal['treasure_cache', 'construction_trick', 'custom']

Which sort of feature it is.

description str

Prose for your front end.

cell Position | None

The cell it sits on, or None to bind it to a whole area.

item_ids tuple[str, ...]

Ordinary items in a cache.

magic_item_ids tuple[str, ...]

Magic items in a cache.

coins Coins

Coins in a cache.

valuables tuple[ValuableSpec, ...]

Named gems and jewellery in a cache.

trap TrapSpec | None

A treasure trap guarding the cache.

Raises:

Type Description
ValueError

If trap is not a treasure trap.

Examples:

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

id instance-attribute

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 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 instance-attribute

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 class-attribute instance-attribute

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 class-attribute instance-attribute

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 class-attribute instance-attribute

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, listed in the equipment id index, or one the adventure bundles on its items field.

magic_item_ids class-attribute instance-attribute

magic_item_ids: tuple[str, ...] = ()

Magic items in the cache, by template id. Any id from load_magic_items, listed in the magic item id index. Adventures bundle no magic items of their own, so only the shipped catalog resolves here.

coins class-attribute instance-attribute

coins: Coins = Coins()

Coins in the cache, by denomination.

valuables class-attribute instance-attribute

valuables: tuple[ValuableSpec, ...] = ()

Named gems and jewellery in the cache. See ValuableSpec.

trap class-attribute instance-attribute

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 and takes it out with RemoveTreasureTrap, one attempt each per character.

GeneratedCache

Bases: BaseModel

Treasure the engine rolled and put on the floor, in the state overlay.

Authored caches are FeatureSpecs and never change. This is the other kind: what a monster's lair hoard or an AreaTreasureSpec produced when it rolled. The session puts one in DungeonState.generated_caches under a minted id like "cache-0001", announces it with a HoardGeneratedEvent, and removes it when the party empties it with 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, the page recording where osrlib commits to one reading of an ambiguous rule or supplies a default behind a Ruleset flag.

Attributes:

Name Type Description
cell_ref str

The cell the cache lies on.

treasure_types tuple[str, ...]

The treasure type letters it rolled from.

coins Coins

The coins in it.

valuables list[ValuableInstance]

The gems and jewellery in it.

magic_items list[MagicItemInstance]

The magic items in it.

cell_ref instance-attribute

cell_ref: str

Where the cache lies, as a cell_ref string. The party has to be standing on that cell to take it.

treasure_types class-attribute instance-attribute

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 class-attribute instance-attribute

coins: Coins = Coins()

The coins in the cache, by denomination.

valuables class-attribute instance-attribute

valuables: list[ValuableInstance] = []

The gems and jewellery in the cache, each with its own id.

magic_items class-attribute instance-attribute

magic_items: list[MagicItemInstance] = []

The magic items in the cache, each already rolled out with its charges or quantity.

KeyedEncounter

Bases: BaseModel

The monsters waiting in a keyed area, and what is already decided about them.

Put one on an 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:

Name Type Description
monsters tuple[KeyedMonster, ...]

The monster lines, each a template and a count.

alignment Alignment | None

A fixed alignment for templates that offer a choice.

aware bool

Whether the monsters are expecting the party.

stance ReactionResult | None

A fixed reaction, instead of a reaction roll.

hoard bool

Whether the monsters have their lair treasure.

Examples:

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

monsters class-attribute instance-attribute

monsters: tuple[KeyedMonster, ...] = Field(min_length=1)

The monster lines making up the encounter, at least one. See KeyedMonster.

alignment class-attribute instance-attribute

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 refuses one that is not.

aware class-attribute instance-attribute

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 class-attribute instance-attribute

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 class-attribute instance-attribute

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.

KeyedMonster

Bases: BaseModel

One line of a keyed encounter: which monster, and how many.

A 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:

Name Type Description
template_id str

Which monster stands here.

count_dice str | None

How many, rolled when they spawn.

count_fixed int | None

How many, decided now.

Raises:

Type Description
ValueError

If both count_dice and count_fixed are given, or neither.

Examples:

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

template_id instance-attribute

template_id: str

The monster's template id. Any id the session's effective catalog holds works: a shipped id from load_monsters, listed in the monster id index, or one the adventure bundles on its monsters field.

count_dice class-attribute instance-attribute

count_dice: str | None = None

How many appear, as a dice expression like "2d4", rolled on the 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 class-attribute instance-attribute

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 when it rolls a room for you.

LevelSpec

Bases: BaseModel

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 and that dungeon in an 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:

Name Type Description
number int

The level's depth number.

width int

The grid's width in cells.

height int

The grid's height in cells.

edges dict[str, Edge]

Everything that is not wall.

areas tuple[AreaSpec, ...]

The keyed rooms.

features tuple[FeatureSpec, ...]

Features on the level rather than on a room.

transitions tuple[TransitionSpec, ...]

The ways to other levels.

wandering WanderingSpec

The wandering-monster check.

entrance Position | None

Where the party arrives from town.

guidance str

Ambient steering for a narrating front end.

Examples:

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

print(corridor.edge((0, 0), Direction.NORTH).kind)
# wall

number class-attribute instance-attribute

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 names one to say where it goes.

width class-attribute instance-attribute

width: int = Field(ge=1)

How many cells the grid runs east to west. Valid x values are 0 to width - 1.

height class-attribute instance-attribute

height: int = Field(ge=1)

How many cells the grid runs north to south. Valid y values are 0 to height - 1.

edges class-attribute instance-attribute

edges: dict[str, Edge] = {}

Everything on the grid that is not wall, keyed by 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 rather than by hand, which supplies the wall for you.

areas class-attribute instance-attribute

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 class-attribute instance-attribute

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 class-attribute instance-attribute

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 class-attribute instance-attribute

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 class-attribute instance-attribute

entrance: Position | None = None

The cell the party arrives at from town, or None for a level with no way in from outside. EnterDungeon puts the party here facing north, whatever the geometry around the cell looks like, and 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 class-attribute instance-attribute

guidance: str = ''

Ambient steering for a narrating front end while the party is on this level: the tone of the place, what you want said about it, what you never want said.

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.

in_bounds

in_bounds(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 catches.

Parameters:

Name Type Description Default
position Position

The cell to test.

required

Returns:

Type Description
bool

True when 0 <= x < width and 0 <= y < height.

edge

edge(position: Position, direction: Direction) -> Edge

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

Parameters:

Name Type Description Default
position Position

The cell.

required
direction Direction

Which of the cell's four edges.

required

Returns:

Type Description
Edge

The edge entry, or a wall edge when none is authored or either cell is off the grid. The wall is a fresh Edge rather than a shared one, and it contains no door.

area_at

area_at(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.

Parameters:

Name Type Description Default
position Position

The cell.

required

Returns:

Type Description
AreaSpec | None

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.

transition_at

transition_at(position: Position) -> TransitionSpec | None

Return the transition standing on a cell, or None.

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.

Parameters:

Name Type Description Default
position Position

The cell.

required

Returns:

Type Description
TransitionSpec | None

The first transition authored on that cell, or None when none is.

PartyLocation

Bases: BaseModel

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 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 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:

Name Type Description
kind Literal['town', 'dungeon']

Which of the two shapes this is.

dungeon_id str | None

The dungeon the party is in.

level_number int | None

The level it is on.

position Position | None

The cell it stands on.

facing Direction | None

The direction it faces.

Raises:

Type Description
ValueError

If kind is "dungeon" and any dungeon field is missing, or if kind is "town" and any is set.

Examples:

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

kind instance-attribute

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 class-attribute instance-attribute

dungeon_id: str | None = None

The id of the dungeon the party is in, and None in town.

level_number class-attribute instance-attribute

level_number: int | None = None

The 1-based number of the level the party is on, and None in town.

position class-attribute instance-attribute

position: Position | None = None

The cell the party stands on, and None in town.

facing class-attribute instance-attribute

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.

TransitionSpec

Bases: BaseModel

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. 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, which lands it at to_position facing to_facing. 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:

Name Type Description
kind Literal['stairs_up', 'stairs_down', 'trapdoor', 'chute']

Which kind of connection this is.

position Position

The cell it stands on.

to_dungeon_id str

The dungeon the party arrives in.

to_level_number int

The level the party arrives on.

to_position Position

The cell the party arrives at.

to_facing Direction

The direction the party faces on arrival.

requires GateSpec | None

An authored condition the party must satisfy to take it.

kind instance-attribute

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 instance-attribute

position: Position

The cell on this level where the transition stands. The party has to be standing here for UseStairs to do anything.

to_dungeon_id instance-attribute

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 class-attribute instance-attribute

to_level_number: int = Field(ge=1)

The 1-based number of the level the party arrives on.

to_position instance-attribute

to_position: Position

The cell the party arrives at, on the destination level's grid.

to_facing instance-attribute

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 class-attribute instance-attribute

requires: GateSpec | None = None

An authored gate on taking the transition, or None for one anyone may take. 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, 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 may carry no gate at all: that is a forced relocation rather than an attempt, and the trap effect rejects one that does.

TrapEffect

Bases: BaseModel

What a sprung trap does to its victim.

You attach one to a 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:

Name Type Description
damage_dice str | None

The damage the trap deals.

volley_dice str | None

The number of projectiles, for a trap that fires several.

save SaveSpec | None

The saving throw the victim gets.

kills bool

Whether a failed save kills outright.

condition Condition | None

A condition a failed save inflicts.

condition_duration_dice str | None

The condition's duration, rolled.

condition_duration_amount int | None

The condition's duration, fixed.

condition_duration_unit TimeUnit | None

The unit the duration counts in.

fall_feet int | None

How far the victim falls.

transition TransitionSpec | None

Where the trap drops the victim.

manual str | None

Prose for a trap the rules do not resolve.

Raises:

Type Description
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:

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

damage_dice class-attribute instance-attribute

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 class-attribute instance-attribute

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 class-attribute instance-attribute

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 class-attribute instance-attribute

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 class-attribute instance-attribute

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 class-attribute instance-attribute

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 class-attribute instance-attribute

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 class-attribute instance-attribute

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 class-attribute instance-attribute

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 class-attribute instance-attribute

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 class-attribute instance-attribute

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.

TrapSpec

Bases: BaseModel

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 and covers the whole area. A treasure trap goes on a 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, a treasure trap by a thief's InspectTreasure. Only a treasure trap can then be taken out of play, with 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:

Name Type Description
kind Literal['room', 'treasure']

Whether this is a room trap or a treasure trap.

trigger Literal['enter', 'open']

The action that springs it.

effect TrapEffect

What it does when it springs.

affects Literal['triggerer', 'party']

Whom it catches.

Raises:

Type Description
ValueError

If kind is "treasure" and trigger is not "open".

kind instance-attribute

kind: Literal['room', 'treasure']

"room" for a trap over an area, "treasure" for one on a cache. AreaSpec accepts only room traps and FeatureSpec only treasure traps.

trigger instance-attribute

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 instance-attribute

effect: TrapEffect

What the trap does once it springs. See TrapEffect.

affects class-attribute instance-attribute

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.

TreasureBundle

Bases: BaseModel

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 or a 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:

Name Type Description
coins Coins

The coins in the bundle.

valuables list[ValuableInstance]

The gems and jewellery in the bundle.

magic_items list[MagicItemInstance]

The magic items in the bundle.

coins class-attribute instance-attribute

coins: Coins = Coins()

The coins, by denomination. A fresh bundle starts with none of each.

valuables class-attribute instance-attribute

valuables: list[ValuableInstance] = []

The gems and jewellery, each already an instance with its own id and value.

magic_items class-attribute instance-attribute

magic_items: list[MagicItemInstance] = []

The magic items, each already rolled out with its charges or quantity.

empty property

empty: bool

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.

ValuableSpec

Bases: BaseModel

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's valuables. It stays a description until the party empties the cache, at which point the session turns it into a ValuableInstance with an id of its own.

Attributes:

Name Type Description
kind Literal['gem', 'jewellery']

Whether it is a gem or jewellery.

name str

What the party sees it called.

value_gp int

What it sells for, in gold pieces.

weight_coins int

What it weighs, in coins.

Examples:

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

kind instance-attribute

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 class-attribute instance-attribute

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 class-attribute instance-attribute

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 class-attribute instance-attribute

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.

WanderingSpec

Bases: BaseModel

A level's wandering-monster check: how often it rolls, and from what.

Every 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:

Name Type Description
chance_in_six int

The odds of monsters showing up.

interval_turns int

How often the check runs.

table EncounterTable | None

A custom monster table for this level.

Examples:

from osrlib.crawl.dungeon import WanderingSpec

quiet = WanderingSpec(chance_in_six=0)
print(quiet.interval_turns)
# 2

chance_in_six class-attribute instance-attribute

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 class-attribute instance-attribute

interval_turns: int = Field(default=2, ge=1)

How many exploration turns pass between checks. The default of 2 is the printed rule.

table class-attribute instance-attribute

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.

cell_ref

cell_ref(dungeon_id: str, level_number: int, position: Position) -> str

Return the reference string that names one cell across a whole adventure.

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, and an ActiveEffect.target_ref in this form anchors an effect to a dungeon cell rather than to a creature.

The format is "cell:{dungeon}:{level}:{x},{y}".

Parameters:

Name Type Description Default
dungeon_id str

The dungeon id, as it appears on its DungeonSpec.

required
level_number int

The 1-based level number.

required
position Position

The cell.

required

Returns:

Type Description
str

The cell reference string.

Examples:

from osrlib.crawl.dungeon import cell_ref

print(cell_ref("crypt", 1, (2, 3)))
# cell:crypt:1:2,3

edge_key

edge_key(position: Position, direction: Direction) -> str

Return the canonical key for the edge on direction's side of position.

Use this whenever you write a LevelSpec.edges 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 looks up for you at read time.

Parameters:

Name Type Description Default
position Position

The cell.

required
direction Direction

Which of the cell's four edges.

required

Returns:

Type Description
str

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:

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

edge_ref

edge_ref(dungeon_id: str, level_number: int, position: Position, direction: Direction) -> str

Return the reference string that names one physical edge across a whole adventure.

This is what DungeonState.doors 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 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.

The format is "{dungeon}:{level}:{x},{y}:{side}".

Parameters:

Name Type Description Default
dungeon_id str

The dungeon id.

required
level_number int

The 1-based level number.

required
position Position

The cell.

required
direction Direction

Which of the cell's four edges.

required

Returns:

Type Description
str

The edge reference string, canonicalized like edge_key.

Examples:

from osrlib.crawl.dungeon import Direction, edge_ref

print(edge_ref("crypt", 1, (0, 0), Direction.EAST))
# crypt:1:1,0:west

step

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 whether the answer is on the grid and 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.

Parameters:

Name Type Description Default
position Position

The starting cell.

required
direction Direction

The direction to step.

required

Returns:

Type Description
Position

The adjacent cell address, which may lie outside the level.

Examples:

from osrlib.crawl.dungeon import Direction, step

print(step((1, 1), Direction.NORTH))
# (1, 0)