Skip to content

osrlib.crawl.stocking

Stocking: roll what one keyed room holds, from the B/X tables.

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 holding content models you can read, edit, and place: a KeyedEncounter to drop on an AreaSpec, or an 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, the encounter tables are 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 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:

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 []

StockedArea

Bases: BaseModel

Everything the rolls produced for one keyed room: content models you place, never game state.

This is what 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 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:

Name Type Description
contents Literal['empty', 'monster', 'special', 'trap']

The room-contents d6's result.

treasure_present bool

The treasure d6's result.

encounter KeyedEncounter | None

The monsters, on a monster room.

npc_party StockedNpcParty | None

The NPC party, when the encounter row rolled one.

treasure AreaTreasureSpec | None

The unguarded treasure, on an empty or trap room that rolled some.

contents instance-attribute

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

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

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

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.

treasure class-attribute instance-attribute

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.

StockedNpcParty

Bases: BaseModel

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

You get one on StockedArea.npc_party, and only on a room whose contents is "monster".

Attributes:

Name Type Description
kind Literal['basic', 'expert']

Which encounter list the party came off.

count int

How many are in it.

kind instance-attribute

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

count: int = Field(ge=1)

How many are in the party, from the row's count, held at 1 or more.

stock_area

stock_area(
    level_number: int, *, catalog: MonsterCatalog, stream: RngStream, table: EncounterTable | None = None
) -> StockedArea

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 it returns and write its payload onto an AreaSpec you are building, then check the finished adventure with validate_adventure.

Every draw comes from stream in the fixed order 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, and it works from the same tables.

Parameters:

Name Type Description Default
level_number int

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.

required
catalog MonsterCatalog

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 composes it, or 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.

required
stream RngStream

The RngStream every draw advances. It is what makes the result reproducible, and it is the only randomness in the call.

required
table EncounterTable | None

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 for the level.

None

Returns:

Type Description
StockedArea

What the room holds. See StockedArea.

Raises:

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

from osrlib.core.rng import RngStream
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(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)]