From a691283512e619d1aeabe3577a2c3b4e72c6f992 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 24 Jul 2026 08:02:27 -0700 Subject: [PATCH 1/3] Add stock_area SRD dungeon-stocking procedure and the KeyedEncounter.hoard gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `osrlib.crawl.stocking.stock_area` consumes the shipped stocking tables to roll one keyed area's contents deterministically from a single RngStream, answering a frozen `StockedArea` of content models (a keyed encounter, an unguarded area treasure, or an NPC-party report) an author can review, place, and edit. Every draw comes from the passed stream in a fixed order (contents d6, treasure d6, d20, count, then variant/pool), mirroring the crawl's wandering resolution so a stocked row yields what a wandering encounter on that row would. `KeyedEncounter.hoard` (default True) gates `_generate_lair_hoard` so the treasure-absent keyed room — a monster room the stocking roll gave no treasure — is expressible; the default preserves every existing document's play semantics. Claude-Session: https://claude.ai/code/session_01SN8gc3PnSwXgKqqgfVcA4x --- CHANGELOG.md | 2 + src/osrlib/crawl/dungeon.py | 8 +- src/osrlib/crawl/exploration.py | 4 +- src/osrlib/crawl/stocking.py | 216 ++++++++++++++++++++++++++++ tests/test_stocking.py | 244 ++++++++++++++++++++++++++++++++ 5 files changed, 472 insertions(+), 2 deletions(-) create mode 100644 src/osrlib/crawl/stocking.py create mode 100644 tests/test_stocking.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c1c5a8..67f02ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### 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 diff --git a/src/osrlib/crawl/dungeon.py b/src/osrlib/crawl/dungeon.py index e599aa5..dc384be 100644 --- a/src/osrlib/crawl/dungeon.py +++ b/src/osrlib/crawl/dungeon.py @@ -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) @@ -434,6 +439,7 @@ class KeyedEncounter(BaseModel): alignment: Alignment | None = None aware: bool = False stance: ReactionResult | None = None + hoard: bool = True class AreaSpec(BaseModel): diff --git a/src/osrlib/crawl/exploration.py b/src/osrlib/crawl/exploration.py index 6b1e15a..3b881b4 100644 --- a/src/osrlib/crawl/exploration.py +++ b/src/osrlib/crawl/exploration.py @@ -858,7 +858,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, diff --git a/src/osrlib/crawl/stocking.py b/src/osrlib/crawl/stocking.py new file mode 100644 index 0000000..06cd989 --- /dev/null +++ b/src/osrlib/crawl/stocking.py @@ -0,0 +1,216 @@ +"""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 parse, roll +from osrlib.core.monsters import MonsterCatalog +from osrlib.core.rng import RngStream +from osrlib.core.tables import EncounterTable +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), + ) + # 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: + # The hydra form: the printed HD dice select the template once. + dice = roll(entry.variant_dice, stream) + template_ids = [ids[dice.total - _dice_minimum(entry.variant_dice)]] * count + elif len(ids) > 1: + # Packed-variant pool: each individual picks uniformly (the crawl's convention). + template_ids = [ids[stream.randbelow(len(ids))] for _ in range(count)] + else: + template_ids = [ids[0]] * count + # 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()) + + +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 diff --git a/tests/test_stocking.py b/tests/test_stocking.py new file mode 100644 index 0000000..c45d805 --- /dev/null +++ b/tests/test_stocking.py @@ -0,0 +1,244 @@ +"""SRD dungeon stocking tests: `stock_area` goldens and the keyed-hoard gate. + +`stock_area` is deterministic from its stream, so the goldens pin a seed's exact +answer — a change in the pinned draw order (contents d6, treasure d6, d20, count, +then variant/pool) breaks them. The monster-branch cases (variant, pool, NPC +party, count-pinning, catalog resolution) drive a *uniform* table — twenty d20 +rows carrying the same entry — so whatever the d20 rolls, the row under test is +selected. The gate tests drive a real session into a keyed area and compare the +same seed with `hoard=True` and `hoard=False`. +""" + +import pytest + +from crawl_fixtures import build_party +from osrlib.core.rng import RngStream +from osrlib.core.tables import ( + EncounterTable, + EncounterTableRow, + MonsterEncounterEntry, + NpcPartyEncounterEntry, +) +from osrlib.crawl.adventure import Adventure, TownSpec +from osrlib.crawl.commands import EnterDungeon, MoveParty +from osrlib.crawl.dungeon import ( + AreaSpec, + Direction, + DungeonSpec, + Edge, + EdgeKind, + KeyedEncounter, + KeyedMonster, + LevelSpec, + WanderingSpec, + edge_key, +) +from osrlib.crawl.session import GameSession +from osrlib.crawl.stocking import StockedNpcParty, stock_area +from osrlib.data import load_monsters + +CATALOG = load_monsters() +KEY = "stock:test" + + +def fresh(seed: int) -> RngStream: + return RngStream.from_seed_material(seed, KEY) + + +def stock(seed: int, table: EncounterTable | None = None): + return stock_area(1, catalog=CATALOG, stream=fresh(seed), table=table) + + +def uniform(entry, *, count_fixed=None, count_dice=None) -> EncounterTable: + """A 20-row d20 table carrying the same entry at every roll — the d20 is fixed out.""" + rows = tuple( + EncounterTableRow(roll=i, name=f"row{i}", entry=entry, count_fixed=count_fixed, count_dice=count_dice) + for i in range(1, 21) + ) + return EncounterTable(id="custom", label="Custom", min_level=1, max_level=1, rows=rows) + + +def find_monster(table: EncounterTable, limit: int = 400): + """The first seed whose stocking d6 lands a monster room over the given table.""" + for seed in range(limit): + area = stock(seed, table) + if area.contents == "monster": + return seed + raise AssertionError("no monster room rolled within the seed budget") + + +class TestStockAreaGoldens: + """Pinned per-contents-kind answers over the shipped level-1 band table.""" + + def test_monster_without_treasure(self): + area = stock(0) + assert area.contents == "monster" and area.treasure_present is False + assert area.treasure is None and area.npc_party is None + assert area.encounter == KeyedEncounter(monsters=(KeyedMonster(template_id="orc", count_fixed=2),), hoard=False) + + def test_monster_with_treasure_sets_hoard(self): + area = stock(2) + assert area.contents == "monster" and area.treasure_present is True + # The encounter is the monster room's treasure declaration; no AreaTreasureSpec. + assert area.treasure is None + assert area.encounter == KeyedEncounter( + monsters=(KeyedMonster(template_id="spitting_cobra", count_fixed=5),), hoard=True + ) + + def test_empty_with_treasure_is_unguarded(self): + area = stock(7) + assert area.contents == "empty" and area.treasure_present is True + assert area.treasure is not None and area.treasure.unguarded is True and area.treasure.letters == () + assert area.encounter is None and area.npc_party is None + + def test_trap_with_treasure_is_unguarded(self): + area = stock(18) + assert area.contents == "trap" and area.treasure_present is True + assert area.treasure is not None and area.treasure.unguarded is True + assert area.encounter is None + + def test_special_carries_no_model(self): + area = stock(1) + assert area.contents == "special" and area.treasure_present is False + assert area.encounter is None and area.npc_party is None and area.treasure is None + + +class TestMonsterBranch: + """The d20 resolution mirrored on the crawl's wandering conventions.""" + + def test_counts_are_pinned_as_fixed(self): + table = uniform(MonsterEncounterEntry(monster_ids=("goblin",)), count_dice="2d4") + area = stock(find_monster(table), table) + assert area.encounter is not None + line = area.encounter.monsters[0] + assert line.count_fixed is not None and line.count_dice is None + + def test_variant_row_selects_one_template(self): + # 1d3 spans three ids; the roll selects one, and all individuals are it. + table = uniform( + MonsterEncounterEntry(monster_ids=("goblin", "orc", "kobold"), variant_dice="1d3"), count_fixed=3 + ) + area = stock(find_monster(table), table) + assert area.encounter == KeyedEncounter( + monsters=(KeyedMonster(template_id="goblin", count_fixed=3),), hoard=area.treasure_present + ) + + def test_pool_row_mixes_individuals_and_groups_by_template(self): + table = uniform(MonsterEncounterEntry(monster_ids=("goblin", "orc", "kobold")), count_dice="2d4") + area = stock(find_monster(table), table) + assert area.encounter is not None + lines = area.encounter.monsters + # Every id comes from the pool, and the per-template counts sum to the roll. + assert all(line.template_id in ("goblin", "orc", "kobold") for line in lines) + assert all(line.count_fixed is not None for line in lines) + total = sum(line.count_fixed for line in lines) + assert 2 <= total <= 8 + + def test_pool_seed_zero_is_pinned(self): + table = uniform(MonsterEncounterEntry(monster_ids=("goblin", "orc", "kobold")), count_dice="2d4") + area = stock(0, table) + assert area.contents == "monster" + assert area.encounter == KeyedEncounter( + monsters=( + KeyedMonster(template_id="kobold", count_fixed=1), + KeyedMonster(template_id="orc", count_fixed=1), + ), + hoard=area.treasure_present, + ) + + def test_npc_party_row_reports_kind_and_count(self): + table = uniform(NpcPartyEncounterEntry(party_kind="basic"), count_fixed=4) + area = stock(find_monster(table), table) + assert area.encounter is None and area.treasure is None + assert area.npc_party == StockedNpcParty(kind="basic", count=4) + + def test_unknown_monster_id_raises(self): + good = uniform(MonsterEncounterEntry(monster_ids=("goblin",)), count_fixed=1) + seed = find_monster(good) + bad = uniform(MonsterEncounterEntry(monster_ids=("not_a_real_monster",)), count_fixed=1) + with pytest.raises(ValueError, match="unknown monster id"): + stock(seed, bad) + + +class TestDeterminism: + def test_same_seed_same_area(self): + assert stock(42).model_dump() == stock(42).model_dump() + + def test_stream_advances_deterministically(self): + first, second = fresh(42), fresh(42) + stock_area(1, catalog=CATALOG, stream=first) + stock_area(1, catalog=CATALOG, stream=second) + assert first.export_state() == second.export_state() + + def test_per_address_streams_are_independent(self): + # Two addresses off one master seed roll independently — re-rolling one + # never disturbs the other, which is what order-independent re-rolls need. + room_7 = RngStream.from_seed_material(99, "stock:d/1/7") + room_9 = RngStream.from_seed_material(99, "stock:d/1/9") + area_7a = stock_area(1, catalog=CATALOG, stream=room_7).model_dump() + area_9 = stock_area(1, catalog=CATALOG, stream=room_9).model_dump() + # Re-derive room 7 from scratch; room 9 was never touched by it. + area_7b = stock_area(1, catalog=CATALOG, stream=RngStream.from_seed_material(99, "stock:d/1/7")).model_dump() + assert area_7a == area_7b + assert ( + area_9 + == stock_area(1, catalog=CATALOG, stream=RngStream.from_seed_material(99, "stock:d/1/9")).model_dump() + ) + + +def _hoard_adventure(hoard: bool) -> Adventure: + """A one-corridor dungeon: entrance at (0,0), a keyed goblin den (lair type C) east at (1,0).""" + edges: dict[str, Edge] = {edge_key((0, 0), Direction.EAST): Edge(kind=EdgeKind.OPEN)} + level = LevelSpec( + number=1, + width=3, + height=1, + edges=edges, + areas=( + AreaSpec( + id="1", + name="Goblin den", + cells=((1, 0),), + encounter=KeyedEncounter(monsters=(KeyedMonster(template_id="goblin", count_fixed=2),), hoard=hoard), + ), + ), + entrance=(0, 0), + wandering=WanderingSpec(chance_in_six=0), + ) + return Adventure( + name="Hoard test", + description="One keyed room.", + town=TownSpec(name="Threshold", services=("inn",), travel_turns={"d": 1}), + dungeons=(DungeonSpec(id="d", name="Den", levels=(level,)),), + ) + + +def _run_keyed(hoard: bool, seed: int) -> GameSession: + # Walking into the keyed area (not teleporting there) runs arrival processing, + # which is what fires the keyed-encounter check and its gated lair hoard. + session = GameSession.new(build_party(), _hoard_adventure(hoard), seed=seed) + session.execute(EnterDungeon(dungeon_id="d")) + session.execute(MoveParty(direction=Direction.EAST)) + return session + + +class TestHoardGate: + def test_hoard_flag_gates_the_lair_cache(self): + # Find a seed whose goblin lair (type C) actually yields a cache with hoard on. + seed = next( + (s for s in range(200) if _run_keyed(True, s).dungeon_state.generated_caches), + None, + ) + assert seed is not None, "no non-empty goblin lair hoard within the seed budget" + on = _run_keyed(True, seed) + # Exactly one cache: the lair. There is no AreaTreasureSpec, so the first-entry + # treasure path contributes nothing — treasure generates exactly once. + assert len(on.dungeon_state.generated_caches) == 1 + # The same seed with the gate closed generates nothing at all. + off = _run_keyed(False, seed) + assert off.dungeon_state.generated_caches == {} + + def test_hoard_false_emits_no_hoard_event(self): + result = _run_keyed(False, 3) + codes = {event.code for event in result.event_log} + assert "treasure.hoard.generated" not in codes From 86a38d1eca8f513fa1a38ec007a861472652f414 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 24 Jul 2026 08:15:20 -0700 Subject: [PATCH 2/3] Address rubber-duck review: share one encounter-individual resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract `select_encounter_individuals` into core/tables.py as the single home for resolving a monster row's individuals (variant/pool/single) from a stream, and have both `wandering_check` and `stock_area` consume it. This removes the copy-pasted resolution and the duplicated `_dice_minimum`, so a stocked row can never drift from a wandering roll on that row — the byte-for-byte contract is now structural, not a hand-kept parallel. Adds resolver unit tests pinning its draw. Claude-Session: https://claude.ai/code/session_01SN8gc3PnSwXgKqqgfVcA4x --- src/osrlib/core/tables.py | 38 ++++++++++++++++++++++++++++++++- src/osrlib/crawl/exploration.py | 19 ++--------------- src/osrlib/crawl/stocking.py | 23 +++----------------- tests/test_stocking.py | 25 ++++++++++++++++++++++ 4 files changed, 67 insertions(+), 38 deletions(-) diff --git a/src/osrlib/core/tables.py b/src/osrlib/core/tables.py index b363725..fb53672 100644 --- a/src/osrlib/core/tables.py +++ b/src/osrlib/core/tables.py @@ -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", @@ -62,6 +63,7 @@ "monster_save_band_label", "monster_xp", "reaction_result", + "select_encounter_individuals", "thac0_for_hd", "to_hit_ac", "turning_column", @@ -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. diff --git a/src/osrlib/crawl/exploration.py b/src/osrlib/crawl/exploration.py index 3b881b4..b453e68 100644 --- a/src/osrlib/crawl/exploration.py +++ b/src/osrlib/crawl/exploration.py @@ -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, @@ -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)) @@ -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 diff --git a/src/osrlib/crawl/stocking.py b/src/osrlib/crawl/stocking.py index 06cd989..1e2bcd3 100644 --- a/src/osrlib/crawl/stocking.py +++ b/src/osrlib/crawl/stocking.py @@ -41,10 +41,10 @@ from pydantic import BaseModel, ConfigDict, Field -from osrlib.core.dice import parse, roll +from osrlib.core.dice import roll from osrlib.core.monsters import MonsterCatalog from osrlib.core.rng import RngStream -from osrlib.core.tables import EncounterTable +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 @@ -179,18 +179,7 @@ def _stock_monster_room( treasure_present=treasure_present, npc_party=StockedNpcParty(kind=entry.party_kind, count=count), ) - # 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: - # The hydra form: the printed HD dice select the template once. - dice = roll(entry.variant_dice, stream) - template_ids = [ids[dice.total - _dice_minimum(entry.variant_dice)]] * count - elif len(ids) > 1: - # Packed-variant pool: each individual picks uniformly (the crawl's convention). - template_ids = [ids[stream.randbelow(len(ids))] for _ in range(count)] - else: - template_ids = [ids[0]] * 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): @@ -208,9 +197,3 @@ def _group_monsters(template_ids: list[str]) -> tuple[KeyedMonster, ...]: 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()) - - -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 diff --git a/tests/test_stocking.py b/tests/test_stocking.py index c45d805..20d48a0 100644 --- a/tests/test_stocking.py +++ b/tests/test_stocking.py @@ -18,6 +18,7 @@ EncounterTableRow, MonsterEncounterEntry, NpcPartyEncounterEntry, + select_encounter_individuals, ) from osrlib.crawl.adventure import Adventure, TownSpec from osrlib.crawl.commands import EnterDungeon, MoveParty @@ -242,3 +243,27 @@ def test_hoard_false_emits_no_hoard_event(self): result = _run_keyed(False, 3) codes = {event.code for event in result.event_log} assert "treasure.hoard.generated" not in codes + + +class TestSharedResolution: + """`select_encounter_individuals` is the one home both wandering_check and stock_area + consume, so a stocked row can never drift from a wandering roll on that row. These pin + the resolver's own draw so a change to it is a deliberate, visible edit.""" + + def test_variant_row_selects_one_template_for_the_group(self): + entry = MonsterEncounterEntry(monster_ids=("a", "b", "c"), variant_dice="1d3") + ids = select_encounter_individuals(entry, 4, fresh(0)) + assert len(ids) == 4 and len(set(ids)) == 1 and ids[0] in ("a", "b", "c") + + def test_pool_row_picks_uniformly_per_individual(self): + entry = MonsterEncounterEntry(monster_ids=("a", "b", "c")) + ids = select_encounter_individuals(entry, 6, fresh(0)) + assert len(ids) == 6 and all(pick in ("a", "b", "c") for pick in ids) + + def test_single_id_row_repeats(self): + entry = MonsterEncounterEntry(monster_ids=("a",)) + assert select_encounter_individuals(entry, 3, fresh(0)) == ["a", "a", "a"] + + def test_resolver_is_deterministic(self): + entry = MonsterEncounterEntry(monster_ids=("a", "b", "c")) + assert select_encounter_individuals(entry, 5, fresh(0)) == select_encounter_individuals(entry, 5, fresh(0)) From dd36842b59e1ca8bcdd65df9288e96e9b73fd139 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 24 Jul 2026 16:23:00 -0700 Subject: [PATCH 3/3] Release 1.3.0 --- CHANGELOG.md | 2 ++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67f02ea..c8e37d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ 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. diff --git a/pyproject.toml b/pyproject.toml index 2dd39e3..525bc22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/uv.lock b/uv.lock index af6ee22..7892610 100644 --- a/uv.lock +++ b/uv.lock @@ -487,7 +487,7 @@ wheels = [ [[package]] name = "osrlib" -version = "1.2.1" +version = "1.3.0" source = { editable = "." } dependencies = [ { name = "pydantic" },