From 334f64793afa59e19506b501471abf0c22cc64b7 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 17 Jul 2026 20:21:09 -0700 Subject: [PATCH 1/3] implement phase 9: adventure-bundled monsters resolved through the session's effective catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adventure.monsters carries custom MonsterTemplates; GameSession builds the base ∪ bundled union once in __init__ (typed ContentValidationError backstop for doctored saves) and every template-id resolution site — spawn, the SpawnMonsters existence check, keyed encounters, listen checks — reads it. validate_adventure keeps its signature and unions internally: collisions report per id, keyed references and alignment pins resolve the dedup union, and inline wandering-table monster_ids are now validated up front (closing a pre-existing play-time ValueError surface). Claude-Session: https://claude.ai/code/session_018e1XMwtEpnzA4zuuPZhzaS --- src/osrlib/core/tables.py | 6 +- src/osrlib/crawl/adventure.py | 70 +++++- src/osrlib/crawl/dungeon.py | 6 +- src/osrlib/crawl/exploration.py | 6 +- src/osrlib/crawl/session.py | 41 +++- tests/test_adventure_monsters.py | 364 +++++++++++++++++++++++++++++++ 6 files changed, 468 insertions(+), 25 deletions(-) create mode 100644 tests/test_adventure_monsters.py diff --git a/src/osrlib/core/tables.py b/src/osrlib/core/tables.py index af97863..b363725 100644 --- a/src/osrlib/core/tables.py +++ b/src/osrlib/core/tables.py @@ -326,8 +326,10 @@ def reaction_result(table: ReactionTable, total: int) -> ReactionResult: class MonsterEncounterEntry(BaseModel): """An encounter-table cell resolving to monster template ids. - `monster_ids` are monster ids from [`load_monsters`][osrlib.data.load_monsters] — - see [the monster id index][monsters-index]. A single id is the common case. + `monster_ids` resolve against the session's effective catalog — shipped ids from + [`load_monsters`][osrlib.data.load_monsters] (see + [the monster id index][monsters-index]) or, in an adventure's inline wandering + table, ids the adventure bundles. A single id is the common case. Multiple ids are a packed-variant pool (`Veteran` over `veteran_1..3`): the tabletop table leaves the pick to the referee, so osrlib has each spawned individual pick uniformly from the pool on the wandering stream — deterministic, diff --git a/src/osrlib/crawl/adventure.py b/src/osrlib/crawl/adventure.py index ba0aa6e..a702463 100644 --- a/src/osrlib/crawl/adventure.py +++ b/src/osrlib/crawl/adventure.py @@ -16,7 +16,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator from osrlib.core.items import EquipmentCatalog -from osrlib.core.monsters import MonsterCatalog +from osrlib.core.monsters import MonsterCatalog, MonsterTemplate from osrlib.crawl.dungeon import DungeonSpec, FeatureSpec, LevelSpec from osrlib.errors import ContentValidationError @@ -44,7 +44,17 @@ class TownSpec(BaseModel): class Adventure(BaseModel): - """An adventure: one or more dungeons plus the base town and metadata.""" + """An adventure: one or more dungeons plus the base town and metadata. + + `monsters` are the adventure's bundled custom + [`MonsterTemplate`][osrlib.core.monsters.MonsterTemplate]s: they join the + shipped catalog for this adventure's sessions everywhere the engine resolves + template ids (keyed encounters, `SpawnMonsters`, inline wandering tables, + listen checks). Bundled ids must not collide with the shipped catalog or each + other — a collision is a validation error, never an override. The empty tuple + is the universal default: an adventure that bundles nothing plays exactly as + before. + """ model_config = ConfigDict(frozen=True) @@ -53,6 +63,7 @@ class Adventure(BaseModel): hooks: tuple[str, ...] = () town: TownSpec dungeons: tuple[DungeonSpec, ...] = Field(min_length=1) + monsters: tuple[MonsterTemplate, ...] = () @model_validator(mode="after") def _dungeon_ids_unique(self) -> Adventure: @@ -79,6 +90,28 @@ def dungeon(self, dungeon_id: str) -> DungeonSpec: raise ValueError(f"unknown dungeon id {dungeon_id!r}") +def _effective_monsters(adventure: Adventure, base: MonsterCatalog) -> tuple[MonsterCatalog, tuple[str, ...]]: + """Build the adventure's effective monster catalog: base ∪ bundled, first occurrence wins. + + Always returns a usable catalog plus every skipped colliding id (empty means + clean) — both callers get a total answer, and each turns a non-empty collision + list into its own typed failure. An empty bundle returns the base catalog + object itself: no copy, no behavior change for adventures that bundle nothing. + """ + if not adventure.monsters: + return base, () + seen = {template.id for template in base.monsters} + accepted: list[MonsterTemplate] = [] + colliding: list[str] = [] + for template in adventure.monsters: + if template.id in seen: + colliding.append(template.id) + continue + seen.add(template.id) + accepted.append(template) + return MonsterCatalog(monsters=(*base.monsters, *accepted)), tuple(colliding) + + def _validate_feature( feature: FeatureSpec, level: LevelSpec, owner: str, equipment: EquipmentCatalog, errors: list[str] ) -> None: @@ -94,22 +127,28 @@ def _validate_feature( def validate_adventure(adventure: Adventure, monsters: MonsterCatalog, equipment: EquipmentCatalog) -> None: """Validate an adventure's cross-references — the fail-fast content gate. - Checks, per level: area cells and features in bounds, feature ids unique, - cache item ids resolving against the equipment catalog, keyed-encounter - template ids (and any fixed spawn alignment) resolving against the monster - catalog, transition destinations resolving to real cells, town travel - entries naming real dungeons, and an entrance existing somewhere in every - dungeon. + Checks: bundled monster ids colliding with the shipped catalog or each other; + then, per level: area cells and features in bounds, feature ids unique, cache + item ids resolving against the equipment catalog, keyed-encounter template ids + (and any fixed spawn alignment) and inline wandering-table monster ids + resolving against the effective catalog, transition destinations resolving to + real cells, town travel entries naming real dungeons, and an entrance existing + somewhere in every dungeon. Args: adventure: The adventure to validate. - monsters: The monster catalog keyed encounters resolve against. + monsters: The *base* monster catalog — validation unions it internally + with the adventure's bundled templates, and every monster reference + resolves against that union. equipment: The equipment catalog cache contents resolve against. Raises: ContentValidationError: Listing every dangling reference found. """ errors: list[str] = [] + effective, colliding = _effective_monsters(adventure, monsters) + for monster_id in colliding: + errors.append(f"bundled monster id {monster_id!r} collides with the catalog") for dungeon_id in adventure.town.travel_turns: if not any(dungeon.id == dungeon_id for dungeon in adventure.dungeons): errors.append(f"town travel names unknown dungeon {dungeon_id!r}") @@ -139,7 +178,7 @@ def validate_adventure(adventure: Adventure, monsters: MonsterCatalog, equipment if area.encounter is not None: for keyed in area.encounter.monsters: try: - template = monsters.get(keyed.template_id) + template = effective.get(keyed.template_id) except ValueError: errors.append(f"{owner}: area {area.id!r} references unknown monster {keyed.template_id!r}") continue @@ -155,6 +194,17 @@ def validate_adventure(adventure: Adventure, monsters: MonsterCatalog, equipment if feature.cell is None: errors.append(f"{owner}: level-scope feature {feature.id!r} needs a cell") _validate_feature(feature, level, owner, equipment, errors) + if level.wandering.table is not None: + for row in level.wandering.table.rows: + if row.entry.kind != "monster": + continue + for monster_id in row.entry.monster_ids: + try: + effective.get(monster_id) + except ValueError: + errors.append( + f"{owner}: wandering row {row.name!r} references unknown monster {monster_id!r}" + ) for transition in level.transitions: if not level.in_bounds(transition.position): errors.append(f"{owner}: transition at {transition.position} is out of bounds") diff --git a/src/osrlib/crawl/dungeon.py b/src/osrlib/crawl/dungeon.py index a40f7eb..e599aa5 100644 --- a/src/osrlib/crawl/dungeon.py +++ b/src/osrlib/crawl/dungeon.py @@ -394,8 +394,10 @@ def _trap_kind_matches(self) -> FeatureSpec: class KeyedMonster(BaseModel): """One monster line of a keyed encounter: the template and its count. - `template_id` is any id from [`load_monsters`][osrlib.data.load_monsters] — see - [the monster id index][monsters-index]. + `template_id` is any id in the session's effective catalog — a shipped id from + [`load_monsters`][osrlib.data.load_monsters] (see + [the monster id index][monsters-index]) or one bundled by the adventure's + `monsters`. """ model_config = ConfigDict(frozen=True) diff --git a/src/osrlib/crawl/exploration.py b/src/osrlib/crawl/exploration.py index 4aeb6af..a9127a1 100644 --- a/src/osrlib/crawl/exploration.py +++ b/src/osrlib/crawl/exploration.py @@ -126,7 +126,7 @@ TreasureSoldEvent, WanderingCheckEvent, ) -from osrlib.data import load_classes, load_encounter_tables, load_equipment, load_monsters, load_spells +from osrlib.data import load_classes, load_encounter_tables, load_equipment, load_spells if TYPE_CHECKING: from osrlib.core.character import Character @@ -849,7 +849,7 @@ def _keyed_encounter_check(session) -> list[Event]: count = keyed.count_fixed else: count = max(1, roll(keyed.count_dice, session.streams.get(WANDERING_STREAM)).total) - template = load_monsters().get(keyed.template_id) + template = session.effective_monsters.get(keyed.template_id) templates.append(template) instances = session.spawn(keyed.template_id, count, alignment=area.encounter.alignment) groups.append((template.name, instances)) @@ -1139,7 +1139,7 @@ def _handle_listen_at_door(session, command: ListenAtDoor) -> tuple[list[Rejecti if check.passed and area is not None and area.encounter is not None: area_ref = _area_ref(session, area.id) if area_ref not in session.dungeon_state.resolved_encounters: - monsters = load_monsters() + monsters = session.effective_monsters noisy = any("undead" not in monsters.get(keyed.template_id).categories for keyed in area.encounter.monsters) if noisy: heard = True diff --git a/src/osrlib/crawl/session.py b/src/osrlib/crawl/session.py index 67cee8a..cf9730d 100644 --- a/src/osrlib/crawl/session.py +++ b/src/osrlib/crawl/session.py @@ -48,11 +48,11 @@ Visibility, ) from osrlib.core.items import ItemInstance -from osrlib.core.monsters import MONSTER_SPAWN_STREAM, IdAllocator, MonsterInstance, spawn_monster +from osrlib.core.monsters import MONSTER_SPAWN_STREAM, IdAllocator, MonsterCatalog, MonsterInstance, spawn_monster from osrlib.core.rng import RngStreams from osrlib.core.ruleset import Ruleset from osrlib.core.validation import Rejection -from osrlib.crawl.adventure import Adventure, validate_adventure +from osrlib.crawl.adventure import Adventure, _effective_monsters, validate_adventure from osrlib.crawl.commands import ( AdvanceTime, AwardXP, @@ -83,6 +83,7 @@ ) from osrlib.crawl.party import Party from osrlib.data import load_equipment, load_monsters +from osrlib.errors import ContentValidationError from osrlib.versioning import SCHEMA_VERSION, engine_version if TYPE_CHECKING: @@ -212,9 +213,21 @@ def __init__( streams: RngStreams, master_seed: int, ) -> None: - """Internal constructor — use [`GameSession.new`][osrlib.crawl.session.GameSession.new] or `load_game`.""" + """Internal constructor — use [`GameSession.new`][osrlib.crawl.session.GameSession.new] or `load_game`. + + Raises: + ContentValidationError: If the adventure bundles monster ids that + collide with the shipped catalog or each other — the typed + backstop for `load_game`, which trusts saved content otherwise. + """ self.party = party self.adventure = adventure + catalog, colliding = _effective_monsters(adventure, load_monsters()) + if colliding: + raise ContentValidationError( + "adventure bundles colliding monster ids: " + ", ".join(repr(monster_id) for monster_id in colliding) + ) + self._monster_catalog = catalog self.ruleset = ruleset self.streams = streams self.master_seed = master_seed @@ -288,6 +301,17 @@ def metadata(self) -> dict[str, object]: """The front-end handshake: the schema and engine versions.""" return {"schema_version": SCHEMA_VERSION, "engine_version": engine_version()} + @property + def effective_monsters(self) -> MonsterCatalog: + """The session's monster catalog: the shipped catalog plus the adventure's bundled templates. + + Every engine site that resolves a template id — spawning, keyed + encounters, wandering rows, listen checks — resolves against this + catalog. For an adventure that bundles nothing it *is* the shipped + catalog ([`load_monsters`][osrlib.data.load_monsters]'s cached object). + """ + return self._monster_catalog + # ------------------------------------------------------------------ dispatch def execute(self, command: Command) -> CommandResult: @@ -405,16 +429,17 @@ def spawn(self, template_id: str, count: int, *, alignment: Alignment | None = N """Spawn `count` instances into the registry, ids from the session allocator. Args: - template_id: A monster template id from - [`load_monsters`][osrlib.data.load_monsters] — see - [the monster id index][monsters-index]. + template_id: Any id in the session's + [`effective_monsters`][osrlib.crawl.session.GameSession.effective_monsters] + catalog — shipped (see [the monster id index][monsters-index]) or + bundled by the adventure. count: How many to spawn. alignment: An alignment override from keyed content. Returns: The spawned instances, in spawn order. """ - template = load_monsters().get(template_id) + template = self.effective_monsters.get(template_id) spawned = [] for _ in range(count): instance = spawn_monster( @@ -810,7 +835,7 @@ def _handle_spawn_monsters(session: GameSession, command: SpawnMonsters) -> tupl from osrlib.crawl import encounter as encounter_module try: - load_monsters().get(command.template_id) + session.effective_monsters.get(command.template_id) except ValueError: return [Rejection(code="session.command.unknown_monster", params={"template": command.template_id})], [] if session.encounter is not None or session.battle is not None: diff --git a/tests/test_adventure_monsters.py b/tests/test_adventure_monsters.py new file mode 100644 index 0000000..aad0c41 --- /dev/null +++ b/tests/test_adventure_monsters.py @@ -0,0 +1,364 @@ +"""Adventure-bundled monsters (phase 9): the field, validation, engine paths, persistence.""" + +import json + +import pytest + +from crawl_fixtures import build_adventure, build_party +from osrlib.core.alignment import Alignment +from osrlib.core.monsters import MonsterTemplate +from osrlib.core.tables import EncounterTable, EncounterTableRow, MonsterEncounterEntry, ReactionResult +from osrlib.crawl import exploration +from osrlib.crawl.adventure import Adventure, validate_adventure +from osrlib.crawl.commands import ( + BattleDeclaration, + EnterDungeon, + EquipItem, + GrantItem, + LightSource, + ListenAtDoor, + MoveParty, + PlaceParty, + ResolveBattleRound, + SpawnMonsters, + TakeTreasure, + TravelToTown, +) +from osrlib.crawl.dungeon import Direction, KeyedEncounter, KeyedMonster, PartyLocation, WanderingSpec +from osrlib.crawl.session import GameSession +from osrlib.data import load_equipment, load_monsters +from osrlib.errors import ContentValidationError +from osrlib.persistence import load_game, replay_game, save_game, session_state + +CUSTOM_ID = "cave_wretch" + + +def wretch(monster_id: str = CUSTOM_ID, **overrides) -> MonsterTemplate: + """A deliberately fragile custom monster: 1 fixed hp, AC 9 [10], a bespoke 7 XP.""" + data = { + "id": monster_id, + "name": "Cave Wretch", + "page": "Custom", + "ac": 9, + "ac_ascending": 10, + "hit_dice": {"count": 0, "fixed_hp": 1}, + "attacks": ({"attacks": ({"name": "claw", "damage": "1d2"},)},), + "thac0": 19, + "attack_bonus": 0, + "movement": ({"rate_feet": 90, "encounter_rate_feet": 30},), + "saves": { + "values": {"death": 14, "wands": 15, "paralysis": 16, "breath": 17, "spells": 18}, + "save_as": "NH", + }, + "morale": 12, + "alignment": {"options": ("chaotic",)}, + "xp": 7, + "number_appearing": {"dungeon": {"dice": "1d4"}, "lair": {"dice": "1d6"}}, + "treasure": {"letters": ("R",)}, + "categories": ("humanoid",), + } + data.update(overrides) + return MonsterTemplate.model_validate(data) + + +def bundled_adventure( + *, + monsters: tuple[MonsterTemplate, ...] | None = None, + keyed: KeyedEncounter | None = None, + wandering_chance: int = 0, + table: EncounterTable | None = None, +) -> Adventure: + """The fixture delve with room_a's keyed encounter, the bundle, or level 1's wandering table swapped.""" + adventure = build_adventure(wandering_chance=wandering_chance) + dungeon = adventure.dungeons[0] + level_1 = dungeon.levels[0] + if keyed is not None: + areas = tuple( + area.model_copy(update={"encounter": keyed}) if area.id == "room_a" else area for area in level_1.areas + ) + level_1 = level_1.model_copy(update={"areas": areas}) + if table is not None: + level_1 = level_1.model_copy( + update={"wandering": WanderingSpec(chance_in_six=wandering_chance, interval_turns=2, table=table)} + ) + dungeon = dungeon.model_copy(update={"levels": (level_1, dungeon.levels[1])}) + update: dict = {"dungeons": (dungeon,)} + if monsters is not None: + update["monsters"] = monsters + return adventure.model_copy(update=update) + + +def wretch_table() -> EncounterTable: + row_template = { + "name": "Cave Wretch", + "entry": MonsterEncounterEntry(monster_ids=(CUSTOM_ID,)), + "count_fixed": 1, + } + return EncounterTable( + id="wretches_only", + label="Wretches only", + min_level=1, + max_level=None, + rows=tuple(EncounterTableRow(roll=roll, **row_template) for roll in range(1, 21)), + ) + + +def lit_session(adventure: Adventure, seed: int = 5, *, armed: bool = False) -> GameSession: + session = GameSession.new(build_party(), adventure, seed=seed) + session.execute(GrantItem(character_id="character-0001", item_id="torch", quantity=6)) + session.execute(GrantItem(character_id="character-0001", item_id="tinder_box")) + if armed: + session.execute(GrantItem(character_id="character-0001", item_id="sword")) + session.execute(EquipItem(character_id="character-0001", item_id="sword")) + session.execute(EnterDungeon(dungeon_id="delve")) + for _ in range(20): + lit = session.execute(LightSource(character_id="character-0001", item_id="torch")) + if any(event.code == "exploration.light.lit" for event in lit.events): + return session + raise AssertionError("torch never lit in twenty tinder attempts") + + +def into_room_a(session: GameSession) -> None: + """Walk into room_a through arrival processing (the door state opened directly).""" + session.execute( + PlaceParty( + location=PartyLocation( + kind="dungeon", dungeon_id="delve", level_number=1, position=(2, 0), facing=Direction.SOUTH + ) + ) + ) + session.dungeon_state.door("delve:1:2,1:north").open = True + session.execute(MoveParty(direction=Direction.SOUTH)) + + +class TestTheField: + def test_the_default_is_the_empty_tuple(self): + adventure = build_adventure() + assert adventure.monsters == () + assert adventure.model_dump(mode="json")["monsters"] == [] + + def test_a_bundled_template_round_trips_the_document(self): + adventure = bundled_adventure(monsters=(wretch(),)) + reloaded = Adventure.model_validate(json.loads(json.dumps(adventure.model_dump(mode="json")))) + assert reloaded == adventure + assert reloaded.monsters[0].id == CUSTOM_ID + + def test_a_bundled_template_survives_save_and_load(self): + session = GameSession.new(build_party(), bundled_adventure(monsters=(wretch(),)), seed=5) + restored = load_game(json.loads(json.dumps(save_game(session)))) + assert session_state(restored) == session_state(session) + assert restored.adventure.monsters == (wretch(),) + + def test_a_version_2_save_without_the_key_loads_with_the_default(self): + session = GameSession.new(build_party(), build_adventure(), seed=5) + document = json.loads(json.dumps(save_game(session))) + del document["payload"]["adventure"]["monsters"] + restored = load_game(document) + assert restored.adventure.monsters == () + + +class TestValidation: + def test_duplicate_bundled_ids_fail_the_standard_gate(self): + adventure = bundled_adventure(monsters=(wretch(), wretch())) + with pytest.raises(ContentValidationError, match="adventure validation failed") as excinfo: + validate_adventure(adventure, load_monsters(), load_equipment()) + assert f"bundled monster id {CUSTOM_ID!r} collides with the catalog" in str(excinfo.value) + + def test_a_base_catalog_collision_names_every_colliding_id(self): + adventure = bundled_adventure(monsters=(wretch("goblin"), wretch("skeleton"))) + with pytest.raises(ContentValidationError) as excinfo: + validate_adventure(adventure, load_monsters(), load_equipment()) + message = str(excinfo.value) + assert "bundled monster id 'goblin' collides with the catalog" in message + assert "bundled monster id 'skeleton' collides with the catalog" in message + + def test_a_collision_reports_no_spurious_unknown_monster_echo(self): + # room_a keys 'goblin'; the colliding bundle must produce exactly the + # collision line — keyed references resolve the dedup union's first + # occurrence and stay quiet. + adventure = bundled_adventure(monsters=(wretch("goblin"),)) + with pytest.raises(ContentValidationError) as excinfo: + validate_adventure(adventure, load_monsters(), load_equipment()) + message = str(excinfo.value) + assert "bundled monster id 'goblin' collides with the catalog" in message + assert "unknown monster" not in message + + def test_a_keyed_reference_to_a_bundled_id_validates(self): + adventure = bundled_adventure( + monsters=(wretch(),), + keyed=KeyedEncounter(monsters=(KeyedMonster(template_id=CUSTOM_ID, count_fixed=2),)), + ) + validate_adventure(adventure, load_monsters(), load_equipment()) + + def test_a_dangling_bundled_looking_id_still_fails(self): + adventure = bundled_adventure( + keyed=KeyedEncounter(monsters=(KeyedMonster(template_id=CUSTOM_ID, count_fixed=2),)), + ) + with pytest.raises(ContentValidationError, match="unknown monster"): + validate_adventure(adventure, load_monsters(), load_equipment()) + + def test_alignment_pins_resolve_against_the_bundled_template(self): + keyed = KeyedEncounter( + monsters=(KeyedMonster(template_id=CUSTOM_ID, count_fixed=1),), alignment=Alignment.CHAOTIC + ) + validate_adventure(bundled_adventure(monsters=(wretch(),), keyed=keyed), load_monsters(), load_equipment()) + pinned_outside = keyed.model_copy(update={"alignment": Alignment.LAWFUL}) + with pytest.raises(ContentValidationError, match="pins alignment"): + validate_adventure( + bundled_adventure(monsters=(wretch(),), keyed=pinned_outside), load_monsters(), load_equipment() + ) + + def test_inline_wandering_rows_resolve_against_the_union(self): + validate_adventure( + bundled_adventure(monsters=(wretch(),), table=wretch_table()), load_monsters(), load_equipment() + ) + with pytest.raises(ContentValidationError, match="wandering row"): + validate_adventure(bundled_adventure(table=wretch_table()), load_monsters(), load_equipment()) + + def test_npc_party_wandering_rows_pass_untouched(self): + row_template = {"name": "Basic Adventurers", "entry": {"kind": "npc_party", "party_kind": "basic"}} + table = EncounterTable( + id="npcs_only", + label="NPCs only", + min_level=1, + max_level=None, + rows=tuple(EncounterTableRow(roll=roll, count_dice="1d4", **row_template) for roll in range(1, 21)), + ) + validate_adventure(bundled_adventure(table=table), load_monsters(), load_equipment()) + + +class TestTheEngine: + def test_a_keyed_encounter_spawns_a_bundled_template_on_arrival(self): + adventure = bundled_adventure( + monsters=(wretch(),), + keyed=KeyedEncounter(monsters=(KeyedMonster(template_id=CUSTOM_ID, count_fixed=2),), aware=True), + ) + session = lit_session(adventure) + into_room_a(session) + assert session.encounter is not None and session.encounter.kind == "keyed" + assert {instance.template.id for instance in session.monsters.values()} == {CUSTOM_ID} + + def test_spawn_monsters_spawns_a_bundled_id(self): + session = lit_session(bundled_adventure(monsters=(wretch(),))) + result = session.execute(SpawnMonsters(template_id=CUSTOM_ID, count_fixed=1, distance_feet=30)) + assert result.accepted + spawned = next(event for event in result.events if event.code == "session.monsters.spawned") + assert spawned.template_id == CUSTOM_ID + + def test_a_listen_check_reads_a_bundled_template_categories(self): + # An undead bundled monster is never noisy: whatever the detection rolls, + # no listener ever hears the room. + adventure = bundled_adventure( + monsters=(wretch("grave_wretch", categories=("undead",)),), + keyed=KeyedEncounter(monsters=(KeyedMonster(template_id="grave_wretch", count_fixed=3),)), + ) + session = lit_session(adventure) + session.execute( + PlaceParty( + location=PartyLocation( + kind="dungeon", dungeon_id="delve", level_number=1, position=(2, 0), facing=Direction.SOUTH + ) + ) + ) + for character_id in ("character-0001", "character-0002", "character-0003", "character-0004"): + result = session.execute(ListenAtDoor(direction=Direction.SOUTH, character_id=character_id)) + listened = next(event for event in result.events if event.event_type == "listened") + assert listened.code == "exploration.listen.silent" + assert session.heard_areas == [] + + def test_a_wandering_row_spawns_a_bundled_id_through_session_spawn(self): + adventure = bundled_adventure(monsters=(wretch(),), wandering_chance=6, table=wretch_table()) + session = lit_session(adventure) + events, encountered = exploration.wandering_check(session) + assert encountered + assert {instance.template.id for instance in session.monsters.values()} == {CUSTOM_ID} + + def test_a_bundled_monster_fights_to_xp_and_treasure(self): + # The downstream-unchanged pin: keyed spawn, battle to victory, loot the + # drop pile, return to town — the award reads the embedded template's + # bespoke 7 XP and the type-R carried gold (2d6 gp, never empty). + adventure = bundled_adventure( + monsters=(wretch(),), + keyed=KeyedEncounter( + monsters=(KeyedMonster(template_id=CUSTOM_ID, count_fixed=1),), + aware=True, + stance=ReactionResult.ATTACKS, + ), + ) + session = lit_session(adventure, seed=9, armed=True) + into_room_a(session) + assert session.mode.value == "battle" + group_id = session.encounter.groups[0].id + for _ in range(40): + if session.mode.value != "battle": + break + attack = BattleDeclaration( + character_id="character-0001", action="attack", target_group_id=group_id, weapon_id="sword" + ) + holds = tuple( + BattleDeclaration(character_id=member.id, action="hold") + for member in session.party.living_members() + if member.id != "character-0001" + ) + result = session.execute(ResolveBattleRound(declarations=(attack, *holds))) + if not result.accepted: # out of melee reach: the whole party closes instead + close = tuple( + BattleDeclaration(character_id=member.id, action="move", move="close", target_group_id=group_id) + for member in session.party.living_members() + ) + assert session.execute(ResolveBattleRound(declarations=close)).accepted + assert session.mode.value == "exploring" + assert [record.xp for record in session.defeated_monsters] == [7] + assert session.defeated_monsters[0].template_id == CUSTOM_ID + assert session.execute(TakeTreasure(feature_id="pile")).accepted + session.execute(MoveParty(direction=Direction.NORTH)) + session.execute(MoveParty(direction=Direction.WEST)) + session.execute(MoveParty(direction=Direction.WEST)) + result = session.execute(TravelToTown()) + award = next(event for event in result.events if event.code == "session.xp.adventure_award") + assert award.monster_xp == 7 + assert award.treasure_xp >= 2 # type R: 2d6 gp carried, taken from the pile + + +class TestPersistenceAndReplay: + def test_spawned_bundled_monsters_save_load_and_replay_to_equal_state(self): + adventure = bundled_adventure(monsters=(wretch(),)) + session = GameSession.new(build_party(), adventure, seed=11) + commands = [ + GrantItem(character_id="character-0001", item_id="torch", quantity=6), + GrantItem(character_id="character-0001", item_id="tinder_box"), + EnterDungeon(dungeon_id="delve"), + SpawnMonsters(template_id=CUSTOM_ID, count_fixed=2, distance_feet=30), + ] + accepted = [command for command in commands if session.execute(command).accepted] + assert any(instance.template.id == CUSTOM_ID for instance in session.monsters.values()) + restored = load_game(json.loads(json.dumps(save_game(session)))) + assert session_state(restored) == session_state(session) + from osrlib.core.character import party_to_document + from osrlib.core.ruleset import Ruleset + + replayed = replay_game(11, party_to_document(build_party().members), adventure, Ruleset(), accepted) + assert session_state(replayed) == session_state(session) + + def test_a_doctored_save_with_a_colliding_bundled_id_fails_typed(self): + session = GameSession.new(build_party(), bundled_adventure(monsters=(wretch(),)), seed=5) + document = json.loads(json.dumps(save_game(session))) + goblin = load_monsters().get("goblin") + document["payload"]["adventure"]["monsters"].append(goblin.model_dump(mode="json")) + with pytest.raises(ContentValidationError, match="colliding monster ids"): + load_game(document) + + +class TestEffectiveCatalog: + def test_an_empty_bundle_is_the_cached_base_catalog_object(self): + session = GameSession.new(build_party(), build_adventure(), seed=5) + assert session.effective_monsters is load_monsters() + + def test_a_bundle_resolves_base_and_bundled_ids(self): + session = GameSession.new(build_party(), bundled_adventure(monsters=(wretch(),)), seed=5) + assert session.effective_monsters.get("goblin").id == "goblin" + assert session.effective_monsters.get(CUSTOM_ID).xp == 7 + + def test_the_property_is_stable_across_calls(self): + session = GameSession.new(build_party(), bundled_adventure(monsters=(wretch(),)), seed=5) + assert session.effective_monsters is session.effective_monsters From 368c94de9dc2e4b3767bc1dca65e8087d8fbafe5 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 17 Jul 2026 20:24:47 -0700 Subject: [PATCH 2/3] apply phase 9 docs and spec impacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec: the adventure model's bundled-templates clause, the frozen-data note (bundled templates are adventure data, not SRD data), and roadmap entry 9. The authoring guide retitles to cover monsters and gains the bundling section with a runnable example (template helpers, the collision rule, the shipped- index note). Changelog: the Added bullet and the wandering-table validation tightening under Changed. docs/adaptations.md gains no entries — this phase pins engineering seams, not rules readings. Claude-Session: https://claude.ai/code/session_018e1XMwtEpnzA4zuuPZhzaS --- CHANGELOG.md | 8 ++ docs/guides/authoring-custom-content.md | 139 ++++++++++++++++++++++-- docs/spec.md | 5 +- mkdocs.yml | 2 +- 4 files changed, 140 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75f0c75..17bbed6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- `Adventure.monsters` — an adventure document can bundle its own custom `MonsterTemplate`s, which join the shipped catalog for that adventure's sessions everywhere the engine resolves template ids: keyed encounters, `SpawnMonsters`, inline wandering tables, listen checks, and `GameSession.spawn`. Downstream of spawn nothing changes — combat, XP, treasure, persistence, and replay carry bundled monsters unmodified. Bundled ids must not collide with the shipped catalog or each other; collisions fail `validate_adventure` (and, for doctored saves, `load_game`) with `ContentValidationError`. The session exposes the union as the read-only `GameSession.effective_monsters` property. + +### Changed + +- `validate_adventure` now checks inline wandering-table monster ids: an adventure whose level wandering table names a dangling monster id — previously accepted by the gate and left to crash at play time — fails validation up front. + ## [1.1.0] - 2026-07-05 ### Added diff --git a/docs/guides/authoring-custom-content.md b/docs/guides/authoring-custom-content.md index 5c4a63d..1d68511 100644 --- a/docs/guides/authoring-custom-content.md +++ b/docs/guides/authoring-custom-content.md @@ -1,14 +1,16 @@ -# Authoring custom classes and spells - -The seven classes and the spell list that ship with osrlib are compiled from the OSE SRD into the -package's data files by a build pipeline — that pipeline is not the extension point, and there is no -way to feed your own content into it. What *is* supported is authoring your own class and spell -definitions in code, validating them the same way the shipped catalogs validate their own content, and -running them through the same kernel that plays a fighter or a magic-user: creation, advancement, -memorization, and casting. A class's `race` and a spell's `spell_list` are both open, validated string -ids for exactly this reason — nothing in the kernel restricts them to the values the shipped classes -happen to use. This page builds one small custom class and one custom spell for it, and [the complete -program](#the-complete-program) at the end runs every step shown along the way. +# Authoring custom classes, spells, and monsters + +The seven classes, the spell list, and the monster catalog that ship with osrlib are compiled from the +OSE SRD into the package's data files by a build pipeline — that pipeline is not the extension point, +and there is no way to feed your own content into it. What *is* supported is authoring your own class, +spell, and monster definitions in code, validating them the same way the shipped catalogs validate +their own content, and running them through the same kernel that plays a fighter or a goblin: creation, +advancement, memorization, casting, and — for monsters — spawning, combat, XP, and treasure. A class's +`race` and a spell's `spell_list` are both open, validated string ids for exactly this reason — nothing +in the kernel restricts them to the values the shipped classes happen to use. This page builds one +small custom class and one custom spell for it ([the complete program](#the-complete-program) runs +every step shown along the way), then [a custom monster bundled into an +adventure](#bundling-custom-monsters-with-an-adventure) for the crawl layer. ## The shape of a class definition @@ -426,12 +428,125 @@ assert cast_result.affected_ids == (wounded.id,) assert warden.memorized_spells == () ``` +## Bundling custom monsters with an adventure + +Monsters take a different transport than classes and spells, because the crawl layer already has a +document that carries content: the adventure. `Adventure.monsters` bundles your own +[`MonsterTemplate`][osrlib.core.monsters.MonsterTemplate]s with the adventure document, and every +session running that adventure resolves them everywhere it resolves a shipped template id — keyed +encounters, [`SpawnMonsters`][osrlib.crawl.commands.SpawnMonsters], inline wandering tables, listen +checks, and [`GameSession.spawn`][osrlib.crawl.session.GameSession.spawn]. No loader reassignment, no +registration: the document carries the content, and +[`GameSession.effective_monsters`][osrlib.crawl.session.GameSession.effective_monsters] is the shipped +catalog plus the bundle. Downstream of spawning nothing is different for a bundled monster — a spawned +[`MonsterInstance`][osrlib.core.monsters.MonsterInstance] embeds its full template, so combat, morale, +XP, treasure, saves, and replay never look the id up again. + +A template is a frozen model you build with `model_validate`, exactly like the class and spell above. +Three table helpers derive the stat-block numbers the SRD would print so your creation matches the +attack matrix, the monster save bands, and the XP awards table: +[`thac0_for_hd`][osrlib.core.tables.thac0_for_hd], +[`monster_save_band_label`][osrlib.core.tables.monster_save_band_label], and +[`monster_xp`][osrlib.core.tables.monster_xp]. The one rule is the collision rule: a bundled id must +not collide with the shipped catalog or another bundled id — +[`validate_adventure`][osrlib.crawl.adventure.validate_adventure] rejects collisions outright, never +overrides (an adventure that wants a variant orc names a variant id). Note that [the monster id +index][monsters-index] documents the shipped catalog only; bundled ids live in the adventure that +carries them: + +```python +from osrlib.core.alignment import Alignment +from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character +from osrlib.core.monsters import MonsterHitDice, MonsterTemplate +from osrlib.core.rng import RngStreams +from osrlib.core.ruleset import Ruleset +from osrlib.core.tables import monster_save_band_label, monster_xp, thac0_for_hd +from osrlib.crawl.adventure import Adventure, TownSpec, validate_adventure +from osrlib.crawl.dungeon import AreaSpec, DungeonSpec, KeyedEncounter, KeyedMonster, LevelSpec +from osrlib.crawl.party import Party +from osrlib.crawl.session import GameSession +from osrlib.data import load_combat_tables, load_equipment, load_monsters + +# 2+1 HD with one special ability: the helpers derive THAC0 (the +1 attacks one +# HD higher), the save band, and the XP award from the printed tables. +hd = MonsterHitDice(count=2, modifier=1, asterisks=1) +thac0, attack_bonus = thac0_for_hd(hd.count, bonus_modifier=hd.modifier > 0) + +BONE_WARDEN = MonsterTemplate.model_validate( + { + "id": "bone_warden", + "name": "Bone Warden", + "page": "Custom", + "ac": 5, + "ac_ascending": 14, + "hit_dice": hd.model_dump(), + "attacks": ({"attacks": ({"name": "halberd", "damage": "1d10"},)},), + "thac0": thac0, + "attack_bonus": attack_bonus, + "movement": ({"rate_feet": 60, "encounter_rate_feet": 20},), + "saves": { + "values": {"death": 12, "wands": 13, "paralysis": 14, "breath": 15, "spells": 16}, + "save_as": monster_save_band_label(hd), + }, + "morale": 12, + "alignment": {"options": ("chaotic",)}, + "xp": monster_xp(load_combat_tables(), hd), + "number_appearing": {"dungeon": {"dice": "1d4"}, "lair": {"fixed": 1}}, + "categories": ("undead",), + } +) + +# Bundle it: the adventure document carries the template, and a keyed area +# references it like any shipped id. +level = LevelSpec( + number=1, + width=2, + height=1, + entrance=(0, 0), + areas=( + AreaSpec( + id="ossuary", + name="The ossuary", + cells=((1, 0),), + encounter=KeyedEncounter(monsters=(KeyedMonster(template_id="bone_warden", count_fixed=1),)), + ), + ), +) +adventure = Adventure( + name="The Bone Warden's Vigil", + town=TownSpec(name="Threshold"), + dungeons=(DungeonSpec(id="crypt", name="The Crypt", levels=(level,)),), + monsters=(BONE_WARDEN,), +) + +# The same gate the shipped content passes — the base catalog goes in unchanged, +# and validation unions it with the bundle internally. +validate_adventure(adventure, load_monsters(), load_equipment()) + +rng = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM) +hero = create_character(name="Hild", class_id="fighter", alignment=Alignment.LAWFUL, ruleset=Ruleset(), stream=rng) +session = GameSession.new(Party(members=[hero.character]), adventure, seed=7) + +# The session's effective catalog resolves the bundled id — the very object the +# adventure carries — and spawning embeds it in each instance. +assert session.effective_monsters.get("bone_warden") is BONE_WARDEN +guards = session.spawn("bone_warden", 2) +assert [guard.template.id for guard in guards] == ["bone_warden", "bone_warden"] +assert all(guard.max_hp >= 3 for guard in guards) # 2d8+1 rolls at least 3 +``` + +Bundled equipment, classes, and spells have no adventure-document seam yet — monsters earned theirs +first, and the same shape is the template if a future need is demonstrated. For classes and spells the +catalog-extension pattern above is the supported path. + ## What's not supported There is no merge path into the shipped content. `load_classes` and `load_spells` are cached loaders that read the generated `classes.json`/`spells.json` shipped inside the package; there is no append or register call, so an extended catalog is always a value your own code builds and holds — `classes` and -`spells` above, never something fed back into the loaders themselves. +`spells` above, never something fed back into the loaders themselves. `load_monsters` is just as +closed: [bundling](#bundling-custom-monsters-with-an-adventure) unions per session through the +adventure document that carries the templates, and the shipped catalog object never changes. [`create_character`][osrlib.core.character.create_character], the one-call convenience wrapper used in [the quickstart](../getting-started/quickstart.md), resolves its `class_id` argument straight through diff --git a/docs/spec.md b/docs/spec.md index 853c8f5..c19680d 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -162,7 +162,7 @@ A session runs an adventure, not a dungeon: the party, clock, active effects, an - An **adventure** is one or more dungeons plus a base town and scenario metadata (name, description, hooks). The base town anchors the XP rule's "survive and return to safety" and safe day-level rest — the rules source is the XP-awarding procedure; the SRD's base-town page is a referee checklist, not mechanics. In 1.0 the town is a marker offering the services the SRD names — selling treasure (recovered gp becomes XP) and healing — plus the equipment lists; it is not a simulated town. - A **dungeon** is one or more **levels** — the SRD's term for the deeper and deeper floors joined by stairs, trapdoors, and chutes. The level number is rules-visible: it keys the wandering encounter tables, scales unguarded treasure, and sets the danger expectation (1 HD monsters on level 1, 2 HD on level 2, and so on). - A **level** is a grid of 10' cells — the SRD's typical mapping scale, fixed as the cell size here — with wall and door edges. -- A **keyed area** (a room or cave) is a named region over cells, matching the SRD's numbered-area convention. Areas carry content bindings — monsters, treasure, traps, specials, description IDs — and area-oriented procedures (searching, room vs. treasure traps, keyed encounters) resolve against them. Areas annotate the grid; cells remain the single source of spatial truth. +- A **keyed area** (a room or cave) is a named region over cells, matching the SRD's numbered-area convention. Areas carry content bindings — monsters, treasure, traps, specials, description IDs — and area-oriented procedures (searching, room vs. treasure traps, keyed encounters) resolve against them; an adventure may also carry custom `MonsterTemplate`s of its own, which join the shipped catalog for its sessions everywhere the engine resolves template ids, with colliding ids rejected at validation, never overridden. Areas annotate the grid; cells remain the single source of spatial truth. Movement between levels and dungeons happens through commands like any other movement; the session persists across all of it. @@ -205,6 +205,7 @@ Pipeline rules: - Output is deterministic and diff-reviewable; regeneration is a one-command `uv run` task, and CI regenerates and fails on any diff so `srd/`, the compiler, and `osrlib/data/` cannot silently drift. - Bad or ambiguous parses are corrected by patch files in `tools/srd_compile/overrides/`, merged after parsing with provenance recorded in the output (e.g. the dungeon encounter table's "Basic Adventures" typo for Basic Adventurers). `osrlib/data/` is never hand-edited. +- Adventure-bundled monster templates are adventure data, not SRD data: the shipped catalog stays frozen and generated, and bundled templates never merge into it — they join per-session through the adventure document that carries them. - Every generated file validates against the typed models at build time; tests assert entry counts and spot-check known values (e.g. Troll is AC 4 [15], HD 6+3*). - Where prose can't be mechanized (e.g. referee-judgment abilities), the data keeps the prose and a `manual` tag so games and narrators can still present it. @@ -321,3 +322,5 @@ Each phase ends with working, tested, documented code. **Phase 7 — documentation.** The docstring overhaul to shippable new-user quality: development-history language and reviewer-directed rationale purged, runnable examples on the entry points and one cross-seam quickstart (character → adventure → session → events → save), every command documenting its modes, rejection codes, and emitted events, named types with cross-references replacing duck-typed `object` prose, and module docstrings that orient. On top of it, the documentation site (mkdocs-material + mkdocstrings, strict builds) with guides and walk-throughs for both example front ends, the command/event JSON Schema reference, and the README rewrite. **Phase 8 — release.** Release engineering — version 1.0.0, the changelog, packaging audits, the tag-driven trusted-publishing workflow — and publication to PyPI as `osrlib`. + +**Phase 9 — adventure-bundled monsters.** Adventure documents carry custom monster templates, resolved everywhere the engine resolves template ids; released as 1.2.0. diff --git a/mkdocs.yml b/mkdocs.yml index 9d95b72..416526d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -79,7 +79,7 @@ nav: - Determinism, saves, and replay: guides/determinism-saves-replay.md - The kernel à la carte: guides/kernel-a-la-carte.md - Listeners and flags: guides/listeners-and-flags.md - - Authoring custom classes and spells: guides/authoring-custom-content.md + - Authoring custom classes, spells, and monsters: guides/authoring-custom-content.md - Ruleset options: guides/ruleset-options.md - Front ends: - The TUI crawler: front-ends/tui-crawler.md From fc115b768b9c6ecb1d52d1a570dc211fb92f5c51 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Fri, 17 Jul 2026 20:33:41 -0700 Subject: [PATCH 3/3] address rubber-duck review findings The default-field test now validates the dumped document back into an Adventure, closing the letter of the plan's round-trip pin. Claude-Session: https://claude.ai/code/session_018e1XMwtEpnzA4zuuPZhzaS --- tests/test_adventure_monsters.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_adventure_monsters.py b/tests/test_adventure_monsters.py index aad0c41..a2902d2 100644 --- a/tests/test_adventure_monsters.py +++ b/tests/test_adventure_monsters.py @@ -135,7 +135,9 @@ class TestTheField: def test_the_default_is_the_empty_tuple(self): adventure = build_adventure() assert adventure.monsters == () - assert adventure.model_dump(mode="json")["monsters"] == [] + dumped = adventure.model_dump(mode="json") + assert dumped["monsters"] == [] + assert Adventure.model_validate(dumped).monsters == () def test_a_bundled_template_round_trips_the_document(self): adventure = bundled_adventure(monsters=(wretch(),))