Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

## [1.3.0] - 2026-07-24

### Added

- `osrlib.crawl.stocking.stock_area` — the SRD dungeon-stocking procedure that consumes the shipped stocking tables. Given a dungeon level, an effective monster catalog, and one `RngStream`, it rolls a single keyed area's contents (the stocking d6, then the treasure d6 when the row calls for it, then, on a monster room, the encounter table's d20 row, its count, and the variant or per-individual pool picks) and answers a frozen `StockedArea` — content models an author can review, place, and edit. A monster room's rolled individuals group by template into `KeyedMonster` lines with concrete counts; an empty or trap room that rolls treasure gets an unguarded `AreaTreasureSpec`; an NPC-party row reports its rolled kind and count as a `StockedNpcParty` and stops (a party has no keyed content model). Every draw comes from the passed stream in a fixed order, so a stocked area is reproducible from the stream's state alone. Traps and specials produce no models — the procedure ends where the referee's design begins.
- `KeyedEncounter.hoard` (default `True`) — gates whether the engine generates the keyed monsters' lair hoard when the encounter first spawns. The default preserves every existing document's play semantics; `hoard=False` expresses the treasure-absent keyed room (a monster room the SRD stocking roll gave no treasure), which an unconditional lair hoard could not otherwise represent.
- `GiveItems`, a command that hands items and coins from one party member to another in zero game time — the distribute-the-load move for shifting weight off an overloaded companion so the party's marching rate recovers. Legal in town and while exploring (not mid-encounter or in battle); both members must be able-bodied and distinct. A given magic item releases its worn effects and lands unequipped in the recipient's pack, mundane items merge into a like stack, valuables and coins move across, and the transfer emits a player-visible `ItemsGivenEvent`. The giver must actually carry what's named, and a revealed cursed item cannot be handed off.

## [1.2.1] - 2026-07-20
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "osrlib"
version = "1.2.1"
version = "1.3.0"
description = "B/X (1981 Basic/Expert) tabletop RPG rules engine for turn-based dungeon crawlers"
readme = "README.md"
# The wheel ships Open Game Content (osrlib/data/*.json and its LICENSE-OGL.md
Expand Down
38 changes: 37 additions & 1 deletion src/osrlib/core/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator

from osrlib.core.classes import SavingThrows
from osrlib.core.dice import parse
from osrlib.core.dice import parse, roll
from osrlib.core.monsters import MonsterHitDice
from osrlib.core.rng import RngStream

__all__ = [
"TURNING_COLUMNS",
Expand All @@ -62,6 +63,7 @@
"monster_save_band_label",
"monster_xp",
"reaction_result",
"select_encounter_individuals",
"thac0_for_hd",
"to_hit_ac",
"turning_column",
Expand Down Expand Up @@ -434,6 +436,40 @@ def _rows_cover_the_d20(self) -> EncounterTable:
return self


def _dice_minimum(expression: str) -> int:
"""The lowest total a dice expression can roll — a variant row's first-template offset."""
parsed = parse(expression)
return parsed.count + parsed.modifier


def select_encounter_individuals(entry: MonsterEncounterEntry, count: int, stream: RngStream) -> list[str]:
"""Resolve a monster row's individuals to template ids — the shared encounter-and-stocking draw.

The wandering-encounter check and dungeon stocking select the same way, from
the same stream in the same order, so a stocked row yields exactly what a
wandering roll on that row would: a `variant_dice` row rolls once (the hydra
form, the printed HD dice selecting one template for the whole group), a
packed pool row picks uniformly per individual, and a single-id row repeats.

Args:
entry: The row's monster entry.
count: The number of individuals — already rolled and clamped by the caller.
stream: The RNG stream every draw advances.

Returns:
One template id per individual, in draw order.
"""
# A list (never the model's variadic tuple) so index access is unconditioned by
# length narrowing; the entry model guarantees at least one id.
ids = list(entry.monster_ids)
if entry.variant_dice is not None:
total = roll(entry.variant_dice, stream).total
return [ids[total - _dice_minimum(entry.variant_dice)]] * count
if len(ids) > 1:
return [ids[stream.randbelow(len(ids))] for _ in range(count)]
return [ids[0]] * count


class NpcClassLevelRow(BaseModel):
"""One d8 row of the *NPC Adventurer Class and Level* table.

Expand Down
8 changes: 7 additions & 1 deletion src/osrlib/crawl/dungeon.py
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,12 @@ class KeyedEncounter(BaseModel):

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

model_config = ConfigDict(frozen=True)
Expand All @@ -434,6 +439,7 @@ class KeyedEncounter(BaseModel):
alignment: Alignment | None = None
aware: bool = False
stance: ReactionResult | None = None
hoard: bool = True


class AreaSpec(BaseModel):
Expand Down
23 changes: 5 additions & 18 deletions src/osrlib/crawl/exploration.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
memorize_spells,
validate_cast,
)
from osrlib.core.tables import select_encounter_individuals
from osrlib.core.validation import Rejection
from osrlib.crawl.commands import (
CastSpell,
Expand Down Expand Up @@ -579,16 +580,7 @@ def wandering_check(session, *, resting: bool = False) -> tuple[list[Event], boo
)
_assign_carried(session, [({}, bundle)])
return events, True
if entry.variant_dice is not None:
# The hydra form: the printed HD dice select the template once.
dice = roll(entry.variant_dice, stream)
minimum = _dice_minimum(entry.variant_dice)
template_ids = [entry.monster_ids[dice.total - minimum]] * count
elif len(entry.monster_ids) > 1:
# Packed-variant pool: each individual picks uniformly (pinned).
template_ids = [entry.monster_ids[stream.randbelow(len(entry.monster_ids))] for _ in range(count)]
else:
template_ids = [entry.monster_ids[0]] * count
template_ids = select_encounter_individuals(entry, count, stream)
instances = []
for template_id in template_ids:
instances.extend(session.spawn(template_id, 1))
Expand All @@ -605,13 +597,6 @@ def wandering_check(session, *, resting: bool = False) -> tuple[list[Event], boo
return events, True


def _dice_minimum(expression: str) -> int:
from osrlib.core.dice import parse

parsed = parse(expression)
return parsed.count + parsed.modifier


# ---------------------------------------------------------------------- arrival processing


Expand Down Expand Up @@ -858,7 +843,9 @@ def _keyed_encounter_check(session) -> list[Event]:
# Carried treasure generates at spawn, per keyed line in printed order;
# the lair hoard follows (pinned draw order on the treasure stream).
carried.append(_generate_carried_treasure(session, instances))
events = _generate_lair_hoard(session, area, templates)
# The lair hoard is gated: a keyed encounter the author marked hoardless (a
# stocking roll that gave the monster room no treasure) generates no cache.
events = _generate_lair_hoard(session, area, templates) if area.encounter.hoard else []
events.extend(
encounter_module.start_encounter(
session,
Expand Down
199 changes: 199 additions & 0 deletions src/osrlib/crawl/stocking.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
"""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.
"""

from typing import Literal

from pydantic import BaseModel, ConfigDict, Field

from osrlib.core.dice import roll
from osrlib.core.monsters import MonsterCatalog
from osrlib.core.rng import RngStream
from osrlib.core.tables import EncounterTable, select_encounter_individuals
from osrlib.core.treasure import roll_room_contents
from osrlib.crawl.dungeon import AreaTreasureSpec, KeyedEncounter, KeyedMonster
from osrlib.data import load_encounter_tables

__all__ = [
"StockedArea",
"StockedNpcParty",
"stock_area",
]


class StockedNpcParty(BaseModel):
"""An NPC-party stocking roll: the rolled party kind and its count.

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

model_config = ConfigDict(frozen=True)

kind: Literal["basic", "expert"]
count: int = Field(ge=1)


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

model_config = ConfigDict(frozen=True)

contents: Literal["empty", "monster", "special", "trap"]
treasure_present: bool
encounter: KeyedEncounter | None = None
npc_party: StockedNpcParty | None = None
treasure: AreaTreasureSpec | None = None


def stock_area(
level_number: int,
*,
catalog: MonsterCatalog,
stream: RngStream,
table: EncounterTable | None = None,
) -> StockedArea:
"""Roll one keyed area's contents from the SRD stocking tables.

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.

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

Returns:
The stocked area.

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.

Examples:
```python
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(42, "stock:1/1/7"))
assert area.contents in ("empty", "monster", "special", "trap")
```
"""
result = roll_room_contents(stream)
contents = result.row.contents
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.
treasure = AreaTreasureSpec(unguarded=True) if treasure_present and contents in ("empty", "trap") else None
return StockedArea(contents=contents, treasure_present=treasure_present, treasure=treasure)


def _stock_monster_room(
level_number: int,
catalog: MonsterCatalog,
stream: RngStream,
table: EncounterTable | None,
treasure_present: bool,
) -> StockedArea:
"""Roll a monster room's d20 encounter and build its keyed lines — the crawl's own resolution."""
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:
count = row.count_fixed
else:
assert row.count_dice is not None # the row model guarantees exactly one of the two
count = roll(row.count_dice, stream).total
count = max(1, count)
entry = row.entry
if entry.kind == "npc_party":
# No authorable content: the row rolled its count, and the party is the
# author's to place. The encounter and treasure stay None.
return StockedArea(
contents="monster",
treasure_present=treasure_present,
npc_party=StockedNpcParty(kind=entry.party_kind, count=count),
)
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.
for template_id in dict.fromkeys(template_ids):
catalog.get(template_id)
return StockedArea(
contents="monster",
treasure_present=treasure_present,
encounter=KeyedEncounter(monsters=_group_monsters(template_ids), hoard=treasure_present),
)


def _group_monsters(template_ids: list[str]) -> tuple[KeyedMonster, ...]:
"""Fold individuals into one `KeyedMonster` line per template, first-appearance order, count fixed."""
counts: dict[str, int] = {}
for template_id in template_ids:
counts[template_id] = counts.get(template_id, 0) + 1
return tuple(KeyedMonster(template_id=template_id, count_fixed=count) for template_id, count in counts.items())
Loading
Loading