From 6531351a8732d11862e4d6fca6ee059365747fb9 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Tue, 28 Jul 2026 19:20:06 -0700 Subject: [PATCH 1/3] add the content_pack document kind: models, validator, and stamped-document round-trip (pre-review draft) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ContentPack/PackSection/ContentPackEntry are the portable, geometry-free form of finished keyed content: AreaSpec minus its cells, sections as level groupings with the wandering slot, and the bundled monster closure. Identity (section ids, pack-wide entry ids, monster ids) is enforced at construction; dangling references stay legal and validate_content_pack reports them as structured PackFindings rather than raising — the first consumer is a panel that lists findings, not a gate that wants pass/fail. The content_pack kind follows the character/party document pattern, with the acceptance rules documented as the pack's own: older schema versions load, newer fail with SaveVersionError, any write re-stamps current. Also repairs the changelog's missing [1.3.0] link line and stale [Unreleased] compare range. Claude-Session: https://claude.ai/code/session_01NcgHzqFDSVSmyX3MzroSQn --- CHANGELOG.md | 4 +- src/osrlib/crawl/content_pack.py | 296 +++++++++++++++++++++++++++++++ tests/test_content_pack.py | 185 +++++++++++++++++++ 3 files changed, 484 insertions(+), 1 deletion(-) create mode 100644 src/osrlib/crawl/content_pack.py create mode 100644 tests/test_content_pack.py diff --git a/CHANGELOG.md b/CHANGELOG.md index eb53d39..98ab0cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- `osrlib.crawl.content_pack` — content packs, the portable form of finished keyed content. A `ContentPack` holds sections of geometry-free entries (`AreaSpec` minus its cells: prose, keyed encounter, room trap, treasure declaration, features), each section optionally carrying a level's `WanderingSpec`, plus the bundled `MonsterTemplate`s the entries reference — the pack's closure. Section ids, pack-wide entry ids, and bundled monster ids are unique by construction; dangling references stay legal, and `validate_content_pack` reports them as structured `PackFinding`s (dotted snake_case codes, the `Rejection` discipline) instead of raising. Packs serialize as stamped `"content_pack"` documents via `ContentPack.to_document`/`from_document` with the documented acceptance rules: older schema versions load, newer ones fail with `SaveVersionError`, and any write re-stamps at current versions. - `TakeTreasure.recipient_id` (optional) — names the single party member who fills their pack from the cache or pile, in place of the default spread. The named member is also the one who reaches in, so they are the character an unresolved treasure trap springs on. Goods beyond that member's own maximum load stay on the cell. - `ItemsLeftBehindEvent` (`exploration.item.left_behind`, player visibility) — reports the goods and coin a haul left in the drop pile on the party's cell because the carriers had no capacity for them. @@ -62,7 +63,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - The documentation site: quickstart, guides, front-end walk-throughs, and a full reference for every command, event, rejection code, message code, RNG stream, and content id. - The typed surface: complete type hints under `py.typed`, checked in CI. -[Unreleased]: https://github.com/mmacy/osrlib-python/compare/v1.2.1...HEAD +[Unreleased]: https://github.com/mmacy/osrlib-python/compare/v1.3.0...HEAD +[1.3.0]: https://github.com/mmacy/osrlib-python/compare/v1.2.1...v1.3.0 [1.2.1]: https://github.com/mmacy/osrlib-python/compare/v1.2.0...v1.2.1 [1.2.0]: https://github.com/mmacy/osrlib-python/compare/v1.1.0...v1.2.0 [1.1.0]: https://github.com/mmacy/osrlib-python/compare/v1.0.0...v1.1.0 diff --git a/src/osrlib/crawl/content_pack.py b/src/osrlib/crawl/content_pack.py new file mode 100644 index 0000000..585ff60 --- /dev/null +++ b/src/osrlib/crawl/content_pack.py @@ -0,0 +1,296 @@ +"""Content packs: portable, geometry-free keyed content an editor can carry between adventures. + +A [`ContentPack`][osrlib.crawl.content_pack.ContentPack] is finished room content +with the geometry left behind: sections of entries that mirror +[`AreaSpec`][osrlib.crawl.dungeon.AreaSpec] minus its cells, each section +optionally carrying a level's [`WanderingSpec`][osrlib.crawl.dungeon.WanderingSpec], +plus the bundled [`MonsterTemplate`][osrlib.core.monsters.MonsterTemplate]s the +entries' encounters reference — the pack's closure. Packs are how an authoring +tool moves stocked rooms from one adventure to another: the consumer writes an +entry's content into a target area it already has, so a pack never places cells, +transitions, or any other geometry. + +Identity is the contract consumers stand on: section ids, entry ids (pack-wide), +and bundled monster ids are each unique, enforced at construction — a pack that +breaks them refuses to load rather than lingering as a diagnostic. Dangling +references are legal by the same posture as +[`Adventure`][osrlib.crawl.adventure.Adventure]: an encounter may name a template +neither the pack nor the shipped catalog carries, and +[`validate_content_pack`][osrlib.crawl.content_pack.validate_content_pack] +reports such gaps as structured +[`PackFinding`][osrlib.crawl.content_pack.PackFinding]s instead of raising — the +first consumer is a panel that lists findings, not a gate. + +Packs serialize as stamped `"content_pack"` documents +([`CONTENT_PACK_KIND`][osrlib.crawl.content_pack.CONTENT_PACK_KIND]), the +longest-lived artifacts in the document family, and own their acceptance rules: +a document stamped by an older schema version is accepted on load, one stamped +by a newer version fails with [`SaveVersionError`][osrlib.errors.SaveVersionError], +and any write re-stamps at the current schema and engine versions — a loaded +older pack saves as a current one. + +```python +from osrlib.crawl.content_pack import ContentPack, ContentPackEntry, PackSection, validate_content_pack +from osrlib.crawl.dungeon import KeyedEncounter, KeyedMonster +from osrlib.data import load_equipment, load_monsters + +pack = ContentPack( + name="The gnawing dark", + sections=( + PackSection( + id="level-1", + label="Level 1", + entries=( + ContentPackEntry( + id="guard-post", + name="Guard post", + encounter=KeyedEncounter(monsters=(KeyedMonster(template_id="orc", count_fixed=4),)), + ), + ), + ), + ), +) +document = pack.to_document() +assert document["kind"] == "content_pack" +assert ContentPack.from_document(document) == pack +assert validate_content_pack(pack, load_monsters(), load_equipment()) == () +``` +""" + +import re +from collections.abc import Mapping + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from osrlib.core.items import EquipmentCatalog +from osrlib.core.monsters import MonsterCatalog, MonsterTemplate +from osrlib.crawl.dungeon import AreaTreasureSpec, FeatureSpec, KeyedEncounter, TrapSpec, WanderingSpec +from osrlib.errors import ContentValidationError +from osrlib.versioning import check_document, stamp_document + +__all__ = [ + "CONTENT_PACK_KIND", + "ContentPack", + "ContentPackEntry", + "PackFinding", + "PackSection", + "validate_content_pack", +] + +CONTENT_PACK_KIND = "content_pack" +"""The stamped-document kind for serialized content packs.""" + +_CODE_PATTERN = re.compile(r"[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+") + + +class ContentPackEntry(BaseModel): + """One portable room: [`AreaSpec`][osrlib.crawl.dungeon.AreaSpec] minus its geometry. + + The entry carries the content slots an area has — prose, encounter, trap, + treasure, features — and nothing that binds to a grid: there are no cells, + and a consumer writes the carried slots into a target area it already has. + An entry trap must be a room trap, the same rule `AreaSpec` enforces; the + treasure-trap coupling needs no restatement because it lives on + [`FeatureSpec`][osrlib.crawl.dungeon.FeatureSpec] itself. + """ + + model_config = ConfigDict(frozen=True) + + id: str = Field(min_length=1) + name: str = "" + description: str = "" + encounter: KeyedEncounter | None = None + trap: TrapSpec | None = None + treasure: AreaTreasureSpec | None = None + features: tuple[FeatureSpec, ...] = () + + @model_validator(mode="after") + def _trap_kind_matches(self) -> ContentPackEntry: + if self.trap is not None and self.trap.kind != "room": + raise ValueError(f"entry {self.id!r} carries a non-room trap") + return self + + +class PackSection(BaseModel): + """A pack's level grouping: entries plus the level's optional wandering table. + + Sections are structural because three consumers operate on a level grouping — + a panel's level rows, wandering's level scope, and a level-scoped capture. + Entry ids are unique pack-wide, so consumers address entries by id alone; + the section contributes presentation grouping and the wandering slot. + """ + + model_config = ConfigDict(frozen=True) + + id: str = Field(min_length=1) + label: str = "" + entries: tuple[ContentPackEntry, ...] = () + wandering: WanderingSpec | None = None + + +class ContentPack(BaseModel): + """A content pack: sections of geometry-free entries plus their monster closure. + + `monsters` bundles the [`MonsterTemplate`][osrlib.core.monsters.MonsterTemplate]s + the pack's encounters and wandering tables reference beyond the shipped + catalog. `id` defaults empty: a pack derived on the fly takes its identity + from its source, and only a persisted pack mints an id of its own. + """ + + model_config = ConfigDict(frozen=True) + + id: str = "" + name: str = "" + description: str = "" + author: str = "" + sections: tuple[PackSection, ...] = () + monsters: tuple[MonsterTemplate, ...] = () + + @model_validator(mode="after") + def _identities_are_unique(self) -> ContentPack: + section_ids = [section.id for section in self.sections] + if len(set(section_ids)) != len(section_ids): + raise ValueError("section ids must be unique") + entry_ids = [entry.id for section in self.sections for entry in section.entries] + if len(set(entry_ids)) != len(entry_ids): + raise ValueError("entry ids must be unique pack-wide") + monster_ids = [template.id for template in self.monsters] + if len(set(monster_ids)) != len(monster_ids): + raise ValueError("monster ids must be unique") + return self + + def to_document(self) -> dict[str, object]: + """Serialize to a stamped document with schema and engine versions. + + A write always stamps the current versions: re-serializing a pack loaded + from an older document re-stamps it as a current one. + + Returns: + The stamped document envelope wrapping the serialized pack. + """ + return stamp_document(CONTENT_PACK_KIND, self.model_dump(mode="json")) + + @classmethod + def from_document(cls, document: Mapping[str, object]) -> ContentPack: + """Load a content pack from a stamped document. + + A `schema_version` older than the current one is accepted — pack payload + changes are additive within a version, so an older document validates + directly. Unknown payload fields are ignored, per the additive-schema + contract. + + Args: + document: A document produced by + [`to_document`][osrlib.crawl.content_pack.ContentPack.to_document]. + + Returns: + The reconstructed pack. + + Raises: + ContentValidationError: If the envelope or payload is malformed or of + the wrong kind. + SaveVersionError: If the document's schema version is newer than this + library understands. + """ + payload = check_document(document, CONTENT_PACK_KIND) + try: + return cls.model_validate(payload) + except ValueError as error: + raise ContentValidationError(f"content pack document payload failed validation: {error}") from error + + +class PackFinding(BaseModel): + """A structured self-containment gap in a content pack. + + `code` is dotted snake_case namespaced by subsystem, the + [`Rejection`][osrlib.core.validation.Rejection] discipline. `entry_id` names + the entry the gap sits on, or `None` for a section-scoped gap (a wandering + table's); `message` carries the specifics either way. + """ + + model_config = ConfigDict(frozen=True) + + code: str + message: str + entry_id: str | None = None + + @field_validator("code") + @classmethod + def _code_must_be_dotted_snake_case(cls, value: str) -> str: + if _CODE_PATTERN.fullmatch(value) is None: + raise ValueError( + "finding code must be two or more dot-separated snake_case segments " + f"(like 'pack.encounter.unknown_monster'), got {value!r}" + ) + return value + + +def validate_content_pack( + pack: ContentPack, monsters: MonsterCatalog, equipment: EquipmentCatalog +) -> tuple[PackFinding, ...]: + """Report a pack's self-containment gaps — references its closure does not cover. + + Checks every monster reference (keyed-encounter lines and wandering-table + rows) against the union of the shipped catalog and the pack's bundled + monsters, and every feature's `item_ids` against the equipment catalog. + Findings are data, not errors: a dangling reference is legal in a pack + exactly as it is while editing an adventure, and a caller wanting a gate + checks for a non-empty result. + + Args: + pack: The pack to check. + monsters: The *base* monster catalog — the check unions it with + `pack.monsters` internally. + equipment: The equipment catalog feature contents resolve against. + + Returns: + One finding per gap, in section and entry order; empty means the pack is + self-contained. + """ + known_monsters = {template.id for template in monsters.monsters} + known_monsters.update(template.id for template in pack.monsters) + findings: list[PackFinding] = [] + + def check_items(features: tuple[FeatureSpec, ...], entry_id: str) -> None: + for feature in features: + for item_id in feature.item_ids: + try: + equipment.get(item_id) + except ValueError: + findings.append( + PackFinding( + code="pack.feature.unknown_item", + message=f"entry {entry_id!r} feature {feature.id!r} references unknown item {item_id!r}", + entry_id=entry_id, + ) + ) + + for section in pack.sections: + for entry in section.entries: + if entry.encounter is not None: + for keyed in entry.encounter.monsters: + if keyed.template_id not in known_monsters: + findings.append( + PackFinding( + code="pack.encounter.unknown_monster", + message=f"entry {entry.id!r} references unknown monster {keyed.template_id!r}", + entry_id=entry.id, + ) + ) + check_items(entry.features, entry.id) + if section.wandering is not None and section.wandering.table is not None: + for row in section.wandering.table.rows: + if row.entry.kind != "monster": + continue + for monster_id in row.entry.monster_ids: + if monster_id not in known_monsters: + findings.append( + PackFinding( + code="pack.wandering.unknown_monster", + message=( + f"section {section.id!r} wandering row {row.name!r} " + f"references unknown monster {monster_id!r}" + ), + ) + ) + return tuple(findings) diff --git a/tests/test_content_pack.py b/tests/test_content_pack.py new file mode 100644 index 0000000..4d98eff --- /dev/null +++ b/tests/test_content_pack.py @@ -0,0 +1,185 @@ +"""Tests for content packs: identity validators, document round-trips, and the findings matrix.""" + +import json + +import pytest + +from osrlib.core.tables import EncounterTable, EncounterTableRow, MonsterEncounterEntry +from osrlib.crawl.content_pack import ( + CONTENT_PACK_KIND, + ContentPack, + ContentPackEntry, + PackFinding, + PackSection, + validate_content_pack, +) +from osrlib.crawl.dungeon import FeatureSpec, KeyedEncounter, KeyedMonster, TrapEffect, TrapSpec, WanderingSpec +from osrlib.data import load_equipment, load_monsters +from osrlib.errors import ContentValidationError, SaveVersionError +from osrlib.versioning import SCHEMA_VERSION, engine_version + +MONSTERS = load_monsters() +EQUIPMENT = load_equipment() + + +def make_entry(entry_id: str = "guard-post", template_id: str = "orc") -> ContentPackEntry: + return ContentPackEntry( + id=entry_id, + name="Guard post", + description="Four orcs dice by torchlight.", + encounter=KeyedEncounter(monsters=(KeyedMonster(template_id=template_id, count_fixed=4),)), + ) + + +def make_pack(**overrides: object) -> ContentPack: + fields: dict[str, object] = { + "name": "The gnawing dark", + "sections": (PackSection(id="level-1", label="Level 1", entries=(make_entry(),)),), + } + fields.update(overrides) + return ContentPack.model_validate(fields) + + +def make_wandering_table(bad_id: str | None = None) -> EncounterTable: + rows = tuple( + EncounterTableRow( + roll=roll, + name="Orc", + entry=MonsterEncounterEntry(monster_ids=(bad_id if bad_id is not None and roll == 1 else "orc",)), + count_fixed=1, + ) + for roll in range(1, 21) + ) + return EncounterTable(id="pack-test", label="Pack test", min_level=1, rows=rows) + + +class TestPackModels: + def test_entry_rejects_a_non_room_trap(self): + treasure_trap = TrapSpec(kind="treasure", trigger="open", effect=TrapEffect(damage_dice="1d6")) + with pytest.raises(ValueError, match="non-room trap"): + ContentPackEntry(id="cache", trap=treasure_trap) + + def test_entry_accepts_a_room_trap(self): + room_trap = TrapSpec(kind="room", trigger="enter", effect=TrapEffect(damage_dice="1d6")) + assert ContentPackEntry(id="pit", trap=room_trap).trap == room_trap + + def test_entry_and_section_ids_must_be_non_empty(self): + with pytest.raises(ValueError): + ContentPackEntry(id="") + with pytest.raises(ValueError): + PackSection(id="") + + def test_entry_ids_must_be_unique_pack_wide(self): + sections = ( + PackSection(id="level-1", entries=(make_entry("dup"),)), + PackSection(id="level-2", entries=(make_entry("dup"),)), + ) + with pytest.raises(ValueError, match="entry ids must be unique pack-wide"): + ContentPack(sections=sections) + + def test_section_ids_must_be_unique(self): + sections = (PackSection(id="dup"), PackSection(id="dup")) + with pytest.raises(ValueError, match="section ids must be unique"): + ContentPack(sections=sections) + + def test_monster_ids_must_be_unique(self): + template = MONSTERS.get("orc").model_copy(update={"id": "pack-orc"}) + with pytest.raises(ValueError, match="monster ids must be unique"): + ContentPack(monsters=(template, template)) + + def test_finding_code_must_be_dotted_snake_case(self): + with pytest.raises(ValueError, match="dotted"): + PackFinding(code="notdotted", message="x") + + +class TestPackDocuments: + def test_round_trip_survives_json(self): + pack = make_pack(monsters=(MONSTERS.get("orc").model_copy(update={"id": "pack-orc"}),)) + document = json.loads(json.dumps(pack.to_document())) + assert document["kind"] == CONTENT_PACK_KIND + assert ContentPack.from_document(document) == pack + + def test_kind_mismatch_raises(self): + document = make_pack().to_document() + document["kind"] = "save" + with pytest.raises(ContentValidationError): + ContentPack.from_document(document) + + def test_older_schema_version_is_accepted(self): + document = make_pack().to_document() + document["schema_version"] = 1 + assert ContentPack.from_document(document) == make_pack() + + def test_newer_schema_version_raises_save_version_error(self): + document = make_pack().to_document() + document["schema_version"] = SCHEMA_VERSION + 1 + with pytest.raises(SaveVersionError): + ContentPack.from_document(document) + + def test_a_write_re_stamps_at_current_versions(self): + document = make_pack().to_document() + document["schema_version"] = 1 + document["engine_version"] = "0.0.1" + re_stamped = ContentPack.from_document(document).to_document() + assert re_stamped["schema_version"] == SCHEMA_VERSION + assert re_stamped["engine_version"] == engine_version() + + def test_unknown_payload_fields_are_ignored(self): + document = make_pack().to_document() + document["payload"]["future_field"] = "ignored" + assert ContentPack.from_document(document) == make_pack() + + def test_malformed_payload_raises_content_error(self): + document = make_pack().to_document() + document["payload"]["sections"] = [{"id": ""}] + with pytest.raises(ContentValidationError): + ContentPack.from_document(document) + + +class TestValidateContentPack: + def test_clean_pack_reports_nothing(self): + pack = make_pack( + sections=( + PackSection( + id="level-1", + entries=(make_entry(),), + wandering=WanderingSpec(chance_in_six=2, table=make_wandering_table()), + ), + ) + ) + assert validate_content_pack(pack, MONSTERS, EQUIPMENT) == () + + def test_bundled_monster_resolves(self): + template = MONSTERS.get("orc").model_copy(update={"id": "pack-orc"}) + pack = make_pack( + sections=(PackSection(id="level-1", entries=(make_entry(template_id="pack-orc"),)),), + monsters=(template,), + ) + assert validate_content_pack(pack, MONSTERS, EQUIPMENT) == () + + def test_dangling_encounter_monster_is_a_finding(self): + pack = make_pack(sections=(PackSection(id="level-1", entries=(make_entry(template_id="gone"),)),)) + findings = validate_content_pack(pack, MONSTERS, EQUIPMENT) + assert [finding.code for finding in findings] == ["pack.encounter.unknown_monster"] + assert findings[0].entry_id == "guard-post" + assert "'gone'" in findings[0].message + + def test_dangling_wandering_monster_is_a_section_finding(self): + pack = make_pack( + sections=(PackSection(id="level-1", wandering=WanderingSpec(table=make_wandering_table(bad_id="gone"))),) + ) + findings = validate_content_pack(pack, MONSTERS, EQUIPMENT) + assert [finding.code for finding in findings] == ["pack.wandering.unknown_monster"] + assert findings[0].entry_id is None + assert "'level-1'" in findings[0].message + + def test_unknown_feature_item_is_a_finding(self): + entry = ContentPackEntry( + id="cache", + features=(FeatureSpec(id="chest", kind="treasure_cache", item_ids=("torch", "no-such-item")),), + ) + pack = make_pack(sections=(PackSection(id="level-1", entries=(entry,)),)) + findings = validate_content_pack(pack, MONSTERS, EQUIPMENT) + assert [finding.code for finding in findings] == ["pack.feature.unknown_item"] + assert findings[0].entry_id == "cache" + assert "'no-such-item'" in findings[0].message From 5bc6ca1a983bbd3108e134df873651436de5e535 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Tue, 28 Jul 2026 19:28:28 -0700 Subject: [PATCH 2/3] Address rubber-duck review: name the wandering rows in the closure prose The module docstring and the changelog bullet narrowed the closure to the entries' encounters; the validator (and the ContentPack class docstring) union the sections' wandering-table rows too. Prose now matches the code. Claude-Session: https://claude.ai/code/session_01NcgHzqFDSVSmyX3MzroSQn --- CHANGELOG.md | 2 +- src/osrlib/crawl/content_pack.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98ab0cb..48379b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added -- `osrlib.crawl.content_pack` — content packs, the portable form of finished keyed content. A `ContentPack` holds sections of geometry-free entries (`AreaSpec` minus its cells: prose, keyed encounter, room trap, treasure declaration, features), each section optionally carrying a level's `WanderingSpec`, plus the bundled `MonsterTemplate`s the entries reference — the pack's closure. Section ids, pack-wide entry ids, and bundled monster ids are unique by construction; dangling references stay legal, and `validate_content_pack` reports them as structured `PackFinding`s (dotted snake_case codes, the `Rejection` discipline) instead of raising. Packs serialize as stamped `"content_pack"` documents via `ContentPack.to_document`/`from_document` with the documented acceptance rules: older schema versions load, newer ones fail with `SaveVersionError`, and any write re-stamps at current versions. +- `osrlib.crawl.content_pack` — content packs, the portable form of finished keyed content. A `ContentPack` holds sections of geometry-free entries (`AreaSpec` minus its cells: prose, keyed encounter, room trap, treasure declaration, features), each section optionally carrying a level's `WanderingSpec`, plus the bundled `MonsterTemplate`s the entries' encounters and the sections' wandering tables reference — the pack's closure. Section ids, pack-wide entry ids, and bundled monster ids are unique by construction; dangling references stay legal, and `validate_content_pack` reports them as structured `PackFinding`s (dotted snake_case codes, the `Rejection` discipline) instead of raising. Packs serialize as stamped `"content_pack"` documents via `ContentPack.to_document`/`from_document` with the documented acceptance rules: older schema versions load, newer ones fail with `SaveVersionError`, and any write re-stamps at current versions. - `TakeTreasure.recipient_id` (optional) — names the single party member who fills their pack from the cache or pile, in place of the default spread. The named member is also the one who reaches in, so they are the character an unresolved treasure trap springs on. Goods beyond that member's own maximum load stay on the cell. - `ItemsLeftBehindEvent` (`exploration.item.left_behind`, player visibility) — reports the goods and coin a haul left in the drop pile on the party's cell because the carriers had no capacity for them. diff --git a/src/osrlib/crawl/content_pack.py b/src/osrlib/crawl/content_pack.py index 585ff60..09c2735 100644 --- a/src/osrlib/crawl/content_pack.py +++ b/src/osrlib/crawl/content_pack.py @@ -5,7 +5,8 @@ [`AreaSpec`][osrlib.crawl.dungeon.AreaSpec] minus its cells, each section optionally carrying a level's [`WanderingSpec`][osrlib.crawl.dungeon.WanderingSpec], plus the bundled [`MonsterTemplate`][osrlib.core.monsters.MonsterTemplate]s the -entries' encounters reference — the pack's closure. Packs are how an authoring +entries' encounters and the sections' wandering tables reference — the pack's +closure. Packs are how an authoring tool moves stocked rooms from one adventure to another: the consumer writes an entry's content into a target area it already has, so a pack never places cells, transitions, or any other geometry. From 13157bdc75948017d4146491da8ff42d9f6e6d28 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Tue, 28 Jul 2026 19:30:12 -0700 Subject: [PATCH 3/3] Release 1.4.0 Claude-Session: https://claude.ai/code/session_01NcgHzqFDSVSmyX3MzroSQn --- CHANGELOG.md | 5 ++++- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48379b3..bfa9878 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.4.0] - 2026-07-28 + ### Added - `osrlib.crawl.content_pack` — content packs, the portable form of finished keyed content. A `ContentPack` holds sections of geometry-free entries (`AreaSpec` minus its cells: prose, keyed encounter, room trap, treasure declaration, features), each section optionally carrying a level's `WanderingSpec`, plus the bundled `MonsterTemplate`s the entries' encounters and the sections' wandering tables reference — the pack's closure. Section ids, pack-wide entry ids, and bundled monster ids are unique by construction; dangling references stay legal, and `validate_content_pack` reports them as structured `PackFinding`s (dotted snake_case codes, the `Rejection` discipline) instead of raising. Packs serialize as stamped `"content_pack"` documents via `ContentPack.to_document`/`from_document` with the documented acceptance rules: older schema versions load, newer ones fail with `SaveVersionError`, and any write re-stamps at current versions. @@ -63,7 +65,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - The documentation site: quickstart, guides, front-end walk-throughs, and a full reference for every command, event, rejection code, message code, RNG stream, and content id. - The typed surface: complete type hints under `py.typed`, checked in CI. -[Unreleased]: https://github.com/mmacy/osrlib-python/compare/v1.3.0...HEAD +[Unreleased]: https://github.com/mmacy/osrlib-python/compare/v1.4.0...HEAD +[1.4.0]: https://github.com/mmacy/osrlib-python/compare/v1.3.0...v1.4.0 [1.3.0]: https://github.com/mmacy/osrlib-python/compare/v1.2.1...v1.3.0 [1.2.1]: https://github.com/mmacy/osrlib-python/compare/v1.2.0...v1.2.1 [1.2.0]: https://github.com/mmacy/osrlib-python/compare/v1.1.0...v1.2.0 diff --git a/pyproject.toml b/pyproject.toml index 525bc22..f783455 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "osrlib" -version = "1.3.0" +version = "1.4.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 7892610..4a8aa3e 100644 --- a/uv.lock +++ b/uv.lock @@ -487,7 +487,7 @@ wheels = [ [[package]] name = "osrlib" -version = "1.3.0" +version = "1.4.0" source = { editable = "." } dependencies = [ { name = "pydantic" },