diff --git a/src/osrlib/__init__.py b/src/osrlib/__init__.py index 9066cac..ba27d94 100644 --- a/src/osrlib/__init__.py +++ b/src/osrlib/__init__.py @@ -1,21 +1,69 @@ """B/X (1981 Basic/Expert) rules engine for turn-based dungeon crawlers. -osrlib is the rules authority and game-state engine; the game supplies presentation, -input, and content. The library is headless and sans-I/O: it never renders, prompts, -sleeps, or touches the network, and all randomness flows through named deterministic -streams (see [`osrlib.core.rng`][osrlib.core.rng]). +osrlib applies the rules and keeps the state of a game. It draws nothing, asks a player +nothing, waits for nothing, and makes no network calls. You hand it a command, it resolves +the rules, and it returns typed events saying what happened. Turning those into something a +player reads is your program's job. Every random draw comes from a named, seeded stream, so +the same seed and the same commands replay the same game. -Every symbol has exactly one import home: the kernel under `osrlib.core`, the crawl -framework under `osrlib.crawl`, and the shared services at the top level — -[`osrlib.data`][osrlib.data] (compiled SRD catalogs), [`osrlib.errors`][osrlib.errors] -(the typed exception hierarchy), [`osrlib.messages`][osrlib.messages] (message-code -formatting), [`osrlib.persistence`][osrlib.persistence] (saves and replay), and -[`osrlib.versioning`][osrlib.versioning] (schema and engine version stamping). The -package root re-exports nothing. +Every name has one import home and the package root re-exports nothing, so you import from +the module that defines the symbol. The modules fall into three layers. -The quickstart below crosses the whole loop — characters, party, adventure, session, -commands, events, save, and load. Full documentation, including a stepwise version of -this example: https://mmacy.github.io/osrlib-python/ +The kernel, under `osrlib.core`, is the rules on their own: no session, no dungeon, no +adventure. Use it directly to roll a character, resolve an attack, or price a sword with no +game running. + +- [`osrlib.core.rng`][osrlib.core.rng]: a master seed in, one named stream per subsystem out. +- [`osrlib.core.ruleset`][osrlib.core.ruleset]: the optional rules you switch on, in one model the kernel reads. +- [`osrlib.core.dice`][osrlib.core.dice]: a dice expression in, a parsed expression or a roll and its own dice out. +- [`osrlib.core.alignment`][osrlib.core.alignment]: the three alignments, shared by characters and monsters. +- [`osrlib.core.abilities`][osrlib.core.abilities]: an ability score in, the modifier or chance the tables grant it out. +- [`osrlib.core.classes`][osrlib.core.classes]: a class definition and a character in, titles, XP, advancement out. +- [`osrlib.core.character`][osrlib.core.character]: creation choices and a stream in, a character or refusals out. +- [`osrlib.core.items`][osrlib.core.items]: templates and an inventory in, purchases, equipment, and encumbrance out. +- [`osrlib.core.spells`][osrlib.core.spells]: a caster and a spell in, memorization, casting, and turning undead out. +- [`osrlib.core.monsters`][osrlib.core.monsters]: a monster template in, a spawned instance with its own hit points out. +- [`osrlib.core.combat`][osrlib.core.combat]: combatants and a stream in, initiative, attacks, damage, and saves out. +- [`osrlib.core.effects`][osrlib.core.effects]: a condition or effect in, a ledger that ticks and expires it out. +- [`osrlib.core.treasure`][osrlib.core.treasure]: a treasure type and a stream in, coins, valuables, magic items out. +- [`osrlib.core.tables`][osrlib.core.tables]: hit dice or an armour class in, the printed row for it out. +- [`osrlib.core.npc`][osrlib.core.npc]: a party level and a stream in, a generated NPC adventuring party out. +- [`osrlib.core.clock`][osrlib.core.clock]: rounds in, turns and days out, with the boundaries each crossing reports. +- [`osrlib.core.events`][osrlib.core.events]: the base class every event inherits, and the contract its code follows. +- [`osrlib.core.validation`][osrlib.core.validation]: the refusal value a rules check hands back instead of raising. + +The crawl framework, under `osrlib.crawl`, is the game around those rules: a party in a +mapped dungeon, driven by commands. Start at the session and work outwards. + +- [`osrlib.crawl.dungeon`][osrlib.crawl.dungeon]: cells, edges, doors, areas, and traps in, a mapped dungeon out. +- [`osrlib.crawl.adventure`][osrlib.crawl.adventure]: dungeons and a town in, one adventure a session can play out. +- [`osrlib.crawl.party`][osrlib.crawl.party]: characters in, marching order, group movement, and combat ranks out. +- [`osrlib.crawl.session`][osrlib.crawl.session]: a party, an adventure, and a seed in, a game taking commands out. +- [`osrlib.crawl.commands`][osrlib.crawl.commands]: every command you can execute, each with the modes it is legal in. +- [`osrlib.crawl.events`][osrlib.crawl.events]: every event a command can emit, and the parser that reads one back. +- [`osrlib.crawl.views`][osrlib.crawl.views]: a session in, what a player may see or what a referee may see out. +- [`osrlib.crawl.exploration`][osrlib.crawl.exploration]: movement, doors, searching, light, rest, and wandering checks. +- [`osrlib.crawl.encounter`][osrlib.crawl.encounter]: a meeting in, surprise, distance, reaction, evasion, pursuit out. +- [`osrlib.crawl.battle`][osrlib.crawl.battle]: an encounter that came to blows in, a round-by-round battle out. +- [`osrlib.crawl.stocking`][osrlib.crawl.stocking]: an empty area and a stream in, its monsters and treasure out. +- [`osrlib.crawl.gates`][osrlib.crawl.gates]: a condition and a session in, whether the way opens out. +- [`osrlib.crawl.triggers`][osrlib.crawl.triggers]: an event pattern in, a match against what just happened out. +- [`osrlib.crawl.quests`][osrlib.crawl.quests]: objectives and the clauses that complete them, as authored content. +- [`osrlib.crawl.narrative`][osrlib.crawl.narrative]: the authored text on a mechanical object, one block per audience. +- [`osrlib.crawl.interpreter`][osrlib.crawl.interpreter]: a listener you register in, an adventure playing itself out. +- [`osrlib.crawl.content_pack`][osrlib.crawl.content_pack]: keyed room content out of one adventure and into another. + +The shared services sit at the top level and serve both layers. + +- [`osrlib.data`][osrlib.data]: a content id in, the frozen rules entry behind it out. +- [`osrlib.errors`][osrlib.errors]: the exceptions the library raises, and which failure each one stands for. +- [`osrlib.messages`][osrlib.messages]: an event in, a line of default English out. +- [`osrlib.persistence`][osrlib.persistence]: a session in, a save document out, and back again by loading or replaying. +- [`osrlib.versioning`][osrlib.versioning]: the two version stamps on every document, and the envelope for them. + +The quickstart below runs the whole loop: characters, party, adventure, session, commands, +events, save, and load. For the documentation, including a stepwise walk through this +example, see https://mmacy.github.io/osrlib-python/ ```python from osrlib.core.alignment import Alignment diff --git a/src/osrlib/data/__init__.py b/src/osrlib/data/__init__.py index 70f8ea4..171dc49 100644 --- a/src/osrlib/data/__init__.py +++ b/src/osrlib/data/__init__.py @@ -1,18 +1,57 @@ -"""Generated SRD data and its typed loaders. - -The JSON files in this package are generated by `tools/srd_compile/` from the SRD -markdown in `srd/` and are never hand-edited — CI regenerates them and fails on any -diff. Regenerate with `uv run python -m tools.srd_compile`. Each file carries a `_meta` -block naming its source SRD pages; entries corrected by compiler overrides record the -touched field paths in `overrides_applied`. - -Loaders read package resources, validate into frozen models, and cache: everything a -loader returns is immutable shared data — play spawns mutable instances from these -templates. Generated data that fails validation is malformed content and raises -[`ContentValidationError`][osrlib.errors.ContentValidationError]. - -The compiled data is Open Game Content under the Open Game License 1.0a; the license -text and Section 15 notice ship in this package as `LICENSE-OGL.md`. +"""The compiled rules content, and the loaders that hand it to you. + +Every id-typed argument in osrlib names an entry in one of these catalogs: a class id, a +spell id, a monster id, an equipment or magic item id, a language id, a treasure-type +letter. Each `load_*` function returns one catalog, and the content-id pages list every id +that ships: [class ids][classes-index], [spell ids][spells-index], +[monster ids][monsters-index], [equipment ids][equipment-index], +[magic item ids][magic-items-index], [language ids][languages-index], and +[treasure types][treasure-types-index]. + +The path is the same for all of them. Call the loader, look one entry up by id, then hand +that entry to the kernel function that takes it: `load_classes().get("fighter")` gives you +the [`ClassDefinition`][osrlib.core.classes.ClassDefinition] that +[`level_up`][osrlib.core.classes.level_up] wants, and `load_monsters().get("goblin")` the +[`MonsterTemplate`][osrlib.core.monsters.MonsterTemplate] that +[`spawn_monster`][osrlib.core.monsters.spawn_monster] wants. A +[`GameSession`][osrlib.crawl.session.GameSession] loads what it needs on its own, so you +call these loaders yourself when you drive the rules without a session, or when you want +to show a player what content exists before play starts. + +Each loader caches. The first call reads the data and validates it, and every later call in +the process returns that same catalog object. What comes back is frozen and shared with +every other caller, so you **cannot** edit a catalog in place. Play spawns mutable instances +from these templates instead, the way +[`spawn_monster`][osrlib.core.monsters.spawn_monster] spawns a +[`MonsterInstance`][osrlib.core.monsters.MonsterInstance] from a template. + +The data files ship inside this package, and a loader reads only what the installed package +includes. The project's compiler generates them from the Old-School Essentials SRD before +each release, and nobody edits them by hand, so a patch you apply to a JSON file in an +installed copy is gone at the next upgrade. They aren't the extension point. To add content +of your own, construct the frozen model yourself and pass it to the same kernel functions the +shipped entries go to, and bundle a custom monster or item template with the +[`Adventure`][osrlib.crawl.adventure.Adventure] that uses it. A file that's missing, or that +fails model validation, raises +[`ContentValidationError`][osrlib.errors.ContentValidationError] rather than returning a +half-built catalog. + +The compiled data is Open Game Content under the Open Game License 1.0a. The license text +and Section 15 notice ship in this package as `LICENSE-OGL.md`. + +Typical usage: + +```python +from osrlib.data import load_classes, load_monsters + +fighter = load_classes().get("fighter") +print(fighter.name, fighter.hit_die) +# Fighter 8 + +goblin = load_monsters().get("goblin") +print(goblin.name, goblin.ac, goblin.morale) +# Goblin 6 7 +``` """ import json @@ -47,26 +86,59 @@ class Language(BaseModel): - """A language a character can speak. + """One spoken language from the shipped catalog. + + You get one from [`LanguageCatalog.get`][osrlib.data.LanguageCatalog.get], or by + reading [`LanguageCatalog.languages`][osrlib.data.LanguageCatalog.languages] when you + want to offer a player the whole list. Its `id` is what + [`create_character`][osrlib.core.character.create_character] and + [`validate_extra_languages`][osrlib.core.character.validate_extra_languages] accept in + `extra_languages`. - `choosable` marks the SRD's twenty Other Languages, available to characters with - high INT; Common is not choosable (every class already speaks it natively). - Alignment tongues are not data entries — they derive from the alignment enum. + An alignment tongue isn't an entry here. Each one follows from + [`Alignment`][osrlib.core.alignment.Alignment] on its own, and a character speaks the + tongue of the alignment it has. + + The model is frozen: assigning to a field raises pydantic's `ValidationError`. """ model_config = ConfigDict(frozen=True) id: str + """The id to pass wherever a language id is taken, like `"gnoll"` or `"common"`.""" + name: str + """The display name to show a player, like `"Gnoll"`.""" + choosable: bool + """Whether a character with a high enough INT may take this language as an extra. + + True for the SRD's Other Languages, the pool a high-INT character chooses from. False + for Common, which every character speaks already and so can never be chosen again. + """ class LanguageCatalog(BaseModel): - """The loaded language list: Common plus the twenty Other Languages.""" + """The whole language list, with lookup by id. + + [`load_languages`][osrlib.data.load_languages] returns this catalog, and + [`get`][osrlib.data.LanguageCatalog.get] pulls one + [`Language`][osrlib.data.Language] out of it by id. Ids are unique across the catalog, + which the model checks when it validates. + + The model is frozen, and the loader shares one instance with every caller, so read it + and don't try to add to it. + """ model_config = ConfigDict(frozen=True) languages: tuple[Language, ...] + """Every language in the catalog, in the order the data file lists them. + + The shipped file is in alphabetical order by id, and Common sits among the rest. Filter + on [`Language.choosable`][osrlib.data.Language.choosable] for the ones a high-INT + character may take. + """ @model_validator(mode="after") def _ids_must_be_unique(self) -> LanguageCatalog: @@ -78,14 +150,32 @@ def _ids_must_be_unique(self) -> LanguageCatalog: def get(self, language_id: str) -> Language: """Return the language with `language_id`. + Use this to turn a stored id back into a display name, or to check that a language + a player picked exists before you pass it to + [`create_character`][osrlib.core.character.create_character]. To validate a whole + set of picks against a class and an INT score at once, call + [`validate_extra_languages`][osrlib.core.character.validate_extra_languages] + instead: it returns structured refusals rather than raising on the first bad id. + Args: - language_id: The language id, e.g. `"gnoll"`. + language_id: A language id from [the language id index][languages-index], such + as `"gnoll"`. Returns: The language. Raises: - ValueError: If no language has that id. + ValueError: If no language has that id. An unknown id is programmer misuse, + not a player's choice, so it raises rather than refusing. + + Examples: + ```python + from osrlib.data import load_languages + + gnoll = load_languages().get("gnoll") + print(gnoll.name, gnoll.choosable) + # Gnoll True + ``` """ for language in self.languages: if language.id == language_id: @@ -110,11 +200,32 @@ def _read(filename: str) -> dict[str, object]: def load_ability_tables() -> AbilityTables: """Load the six ability modifier tables and the prime requisite XP table. + The returned [`AbilityTables`][osrlib.core.abilities.AbilityTables] answers what a + score is worth: the STR melee modifier, the STR open-doors chance, the extra languages + and literacy INT grants, the DEX missile and initiative modifiers, the CON hit point + modifier, the CHA reaction modifier and retainer limits, and the prime requisite XP + percentage. Call it when you draw a character sheet, or when you resolve the rules + without a session: [`create_character`][osrlib.core.character.create_character] and + [`level_up`][osrlib.core.classes.level_up] read these tables for you. + + Every accessor takes a score in 3 to 18 and raises stdlib `ValueError` outside it. No + content-id page covers this catalog, because a score is the only key it has. + Returns: - The frozen tables. + The frozen tables, cached: the first call validates the data and every later call + returns the same object. Raises: ContentValidationError: If the generated data is missing or fails validation. + + Examples: + ```python + from osrlib.data import load_ability_tables + + tables = load_ability_tables() + print(tables.melee_modifier(13), tables.open_doors_chance(13)) + # 1 3 + ``` """ data = _read("abilities.json") try: @@ -125,14 +236,39 @@ def load_ability_tables() -> AbilityTables: @cache def load_classes() -> ClassCatalog: - """Load the seven Classic class definitions. + """Load the character class catalog. + + Call `ClassCatalog.get` on the result for the + [`ClassDefinition`][osrlib.core.classes.ClassDefinition] that the class-level functions + take: [`level_up`][osrlib.core.classes.level_up], + [`xp_modifier_pct`][osrlib.core.classes.xp_modifier_pct], + [`level_title`][osrlib.core.classes.level_title], + [`thief_skill_check`][osrlib.core.classes.thief_skill_check], and + [`caster_profile`][osrlib.core.spells.caster_profile] all want the definition, not the + id. [`create_character`][osrlib.core.character.create_character] is the exception: it + takes `class_id` and looks the definition up itself, so you need this only to show a + player the classes on offer. + + The catalog contains the classes this package ships. A class you write yourself is a + `ClassDefinition` you build and pass to those same functions. You **cannot** add it + here. Returns: - The frozen class catalog — see [the class id index][classes-index] for the - class ids it defines. + The frozen class catalog, cached: the first call validates the data and every later + call returns the same object. [The class id index][classes-index] lists every id + it defines. Raises: ContentValidationError: If the generated data is missing or fails validation. + + Examples: + ```python + from osrlib.data import load_classes + + fighter = load_classes().get("fighter") + print(fighter.name, fighter.hit_die) + # Fighter 8 + ``` """ data = _read("classes.json") try: @@ -143,14 +279,36 @@ class ids it defines. @cache def load_equipment() -> EquipmentCatalog: - """Load the weapon, armour, gear, ammunition, and treasure-weight lists. + """Load the mundane equipment catalog: weapons, armour, gear, ammunition, and treasure weights. + + `EquipmentCatalog.get` looks an id up across all four item lists and returns the + template that [`purchase`][osrlib.core.items.purchase], + [`validate_purchase`][osrlib.core.items.validate_purchase], + [`equip`][osrlib.core.items.equip], and + [`validate_equip`][osrlib.core.items.validate_equip] take. `treasure_weights` is the + separate list that [`treasure_weight_coins`][osrlib.core.items.treasure_weight_coins] + reads to weigh coins and gems, which a character picks up rather than buys. + + Magic items live in their own catalog, [`load_magic_items`][osrlib.data.load_magic_items]. + An adventure that ships item templates of its own keeps them in its own content, and a + session answers those ids too. This catalog contains only what the package ships. Returns: - The frozen equipment catalog — see [the equipment id index][equipment-index] - for the item ids it defines. + The frozen equipment catalog, cached: the first call validates the data and every + later call returns the same object. [The equipment id index][equipment-index] + lists every id it defines. Raises: ContentValidationError: If the generated data is missing or fails validation. + + Examples: + ```python + from osrlib.data import load_equipment + + sword = load_equipment().get("sword") + print(sword.name, sword.cost_gp) + # Sword 10 + ``` """ data = _read("equipment.json") try: @@ -161,14 +319,36 @@ def load_equipment() -> EquipmentCatalog: @cache def load_monsters() -> MonsterCatalog: - """Load the monster templates compiled from the SRD's monster pages. + """Load the monster catalog. + + `MonsterCatalog.get` returns the frozen + [`MonsterTemplate`][osrlib.core.monsters.MonsterTemplate] that + [`spawn_monster`][osrlib.core.monsters.spawn_monster] turns into a mutable + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] with rolled hit points and a + session-unique id. The instance is what you fight. The template stays shared and + unchanged however many you spawn from it. + + A dungeon that keys a monster by id, and an encounter table that names one, both resolve + against this catalog plus whatever templates the adventure bundles. Read the template + directly when you want to show a statistic block, or to plan an encounter before any + monster exists. Returns: - The frozen monster catalog — see [the monster id index][monsters-index] for - the template ids it defines. + The frozen monster catalog, cached: the first call validates the data and every + later call returns the same object. [The monster id index][monsters-index] lists + every id it defines. Raises: ContentValidationError: If the generated data is missing or fails validation. + + Examples: + ```python + from osrlib.data import load_monsters + + goblin = load_monsters().get("goblin") + print(goblin.name, goblin.ac, goblin.morale) + # Goblin 6 7 + ``` """ data = _read("monsters.json") try: @@ -179,13 +359,36 @@ def load_monsters() -> MonsterCatalog: @cache def load_combat_tables() -> CombatTables: - """Load the attack matrix, monster saving-throw bands, and XP-awards table. + """Load the combat tables: the attack matrix, monster saves, XP awards, turning, and reactions. + + [`monster_xp`][osrlib.core.tables.monster_xp] takes these tables and a monster's hit + dice and returns the award. `CombatTables.save_band` and `CombatTables.xp_row` take the + labels [`monster_save_band_label`][osrlib.core.tables.monster_save_band_label] and + [`xp_band_label`][osrlib.core.tables.xp_band_label] compute from hit dice, so you look a + band up by asking for its label first rather than by matching hit dice yourself. + + Most of combat needs no table in hand: [`attack_roll`][osrlib.core.combat.attack_roll] + and [`saving_throw`][osrlib.core.combat.saving_throw] read what they need themselves. + Load the tables when you want to show the numbers, or to award XP outside a session. + No content-id page covers this catalog, because its rows are keyed by hit dice and armour + class rather than by id. Returns: - The frozen combat tables. + The frozen combat tables, cached: the first call validates the data and every later + call returns the same object. Raises: ContentValidationError: If the generated data is missing or fails validation. + + Examples: + ```python + from osrlib.core.tables import monster_xp + from osrlib.data import load_combat_tables, load_monsters + + goblin = load_monsters().get("goblin") + print(monster_xp(load_combat_tables(), goblin.hit_dice)) + # 5 + ``` """ data = _read("combat_tables.json") try: @@ -196,13 +399,35 @@ def load_combat_tables() -> CombatTables: @cache def load_encounter_tables() -> EncounterTables: - """Load the dungeon encounter tables by level. + """Load the dungeon encounter tables, with the NPC adventuring party tables. + + `EncounterTables.for_level` takes a dungeon level number and returns the table the SRD + prints for it, clamping anything deeper than the last printed band onto that band. Roll + on the table for an entry, then call + [`select_encounter_individuals`][osrlib.core.tables.select_encounter_individuals] to turn + a monster entry and a count into the template ids that appear. The NPC party rows feed + [`generate_npc_party`][osrlib.core.npc.generate_npc_party]. + + A session rolls its own wandering monsters through + [`wandering_check`][osrlib.crawl.exploration.wandering_check], so you load these tables + when you stock a dungeon yourself or want to show what a level can throw at a party. No + content-id page covers this catalog, because its rows are keyed by level and die roll. Returns: - The frozen encounter tables. + The frozen encounter tables, cached: the first call validates the data and every + later call returns the same object. Raises: ContentValidationError: If the generated data is missing or fails validation. + + Examples: + ```python + from osrlib.data import load_encounter_tables + + table = load_encounter_tables().for_level(1) + print(table.min_level, table.max_level) + # 1 1 + ``` """ data = _read("encounter_tables.json") try: @@ -213,14 +438,35 @@ def load_encounter_tables() -> EncounterTables: @cache def load_spells() -> SpellCatalog: - """Load the spell templates compiled from the SRD's spell pages. + """Load the spell catalog. + + [`memorize_spells`][osrlib.core.spells.memorize_spells] and + [`add_spell_to_book`][osrlib.core.spells.add_spell_to_book] take this catalog whole, so + they can check a chosen id against the caster's list and level. `SpellCatalog.get` + returns one [`SpellTemplate`][osrlib.core.spells.SpellTemplate] by id, and + `SpellCatalog.by_list` returns every spell on a class's list, optionally at one spell + level, which is what you show a player choosing spells to memorize. + + A reversed spell has its own id. The catalog contains both forms, and the template says + which is which. A spell you write yourself is a `SpellTemplate` you build and pass to the + same functions. Returns: - The frozen spell catalog — see [the spell id index][spells-index] for the - spell ids it defines. + The frozen spell catalog, cached: the first call validates the data and every later + call returns the same object. [The spell id index][spells-index] lists every id it + defines. Raises: ContentValidationError: If the generated data is missing or fails validation. + + Examples: + ```python + from osrlib.data import load_spells + + magic_missile = load_spells().get("magic_missile") + print(magic_missile.name, magic_missile.spell_list, magic_missile.level) + # Magic Missile magic_user 1 + ``` """ data = _read("spells.json") try: @@ -231,14 +477,36 @@ def load_spells() -> SpellCatalog: @cache def load_magic_items() -> MagicItemCatalog: - """Load the magic item catalog: templates, sub-tables, and the sword tables. + """Load the magic item catalog: the templates, the generation sub-tables, and the sword tables. + + `MagicItemCatalog.get` returns the frozen + [`MagicItemTemplate`][osrlib.core.items.MagicItemTemplate] behind an id, and + [`magic_item_template`][osrlib.core.items.magic_item_template] does the same lookup for a + [`MagicItemInstance`][osrlib.core.items.MagicItemInstance] you already have. The + sub-tables, the armour-type table, the scroll spell-level table, and the sentient sword + tables are what [`generate_magic_item`][osrlib.core.treasure.generate_magic_item] rolls + on, so you rarely read them yourself. + + An id names a kind of item, not a particular one. Two potions of healing share a template + and differ as instances, each with its own instance id, charges, and identification + state. Mundane gear lives in [`load_equipment`][osrlib.data.load_equipment]. Returns: - The frozen magic item catalog — see [the magic item id index][magic-items-index] - for the item ids it defines. + The frozen magic item catalog, cached: the first call validates the data and every + later call returns the same object. + [The magic item id index][magic-items-index] lists every id it defines. Raises: ContentValidationError: If the generated data is missing or fails validation. + + Examples: + ```python + from osrlib.data import load_magic_items + + potion = load_magic_items().get("potion_of_healing") + print(potion.name, potion.category) + # Potion of Healing potion + ``` """ data = _read("magic_items.json") try: @@ -249,14 +517,36 @@ def load_magic_items() -> MagicItemCatalog: @cache def load_treasure_tables() -> TreasureTables: - """Load the treasure types, gem values, magic item type, stocking, and unguarded tables. + """Load the treasure tables: the treasure types, gem values, magic item types, stocking, and unguarded hoards. + + `TreasureTables.treasure_type` returns the table behind a letter, which is what a + monster's `treasure` field names and what you read to show a player, or a referee, what a + hoard can contain before anything is rolled. + + To roll an actual hoard, call [`generate_treasure`][osrlib.core.treasure.generate_treasure] + with the letter instead: it loads these tables itself and returns a + [`GeneratedTreasure`][osrlib.core.items.GeneratedTreasure] with coins, valuables, and + magic items already drawn from a named stream. + [`roll_room_contents`][osrlib.core.treasure.roll_room_contents] and + [`generate_unguarded_treasure`][osrlib.core.treasure.generate_unguarded_treasure] do the + same for the stocking and unguarded tables. Returns: - The frozen treasure tables — see [the treasure type index][treasure-types-index] - for the treasure type letters they key on. + The frozen treasure tables, cached: the first call validates the data and every later + call returns the same object. [The treasure type index][treasure-types-index] + lists every letter they key on. Raises: ContentValidationError: If the generated data is missing or fails validation. + + Examples: + ```python + from osrlib.data import load_treasure_tables + + hoard = load_treasure_tables().treasure_type("A") + print(hoard.letter, hoard.kind) + # A hoard + ``` """ data = _read("treasure.json") try: @@ -267,14 +557,34 @@ def load_treasure_tables() -> TreasureTables: @cache def load_languages() -> LanguageCatalog: - """Load the language list: Common plus the twenty Other Languages. + """Load the language catalog. + + Read [`LanguageCatalog.languages`][osrlib.data.LanguageCatalog.languages] to offer a + player the languages a high-INT character may add, keeping the entries whose + [`choosable`][osrlib.data.Language.choosable] is True, and + [`LanguageCatalog.get`][osrlib.data.LanguageCatalog.get] to turn one id back into a + display name. Pass the chosen ids to + [`create_character`][osrlib.core.character.create_character] as `extra_languages`, or + check them first with + [`validate_extra_languages`][osrlib.core.character.validate_extra_languages], which + counts them against what the character's INT allows. Returns: - The frozen language catalog — see [the language id index][languages-index] - for the language ids it defines. + The frozen language catalog, cached: the first call validates the data and every + later call returns the same object. [The language id index][languages-index] + lists every id it defines. Raises: ContentValidationError: If the generated data is missing or fails validation. + + Examples: + ```python + from osrlib.data import load_languages + + catalog = load_languages() + print(catalog.get("common").name, catalog.get("common").choosable) + # Common False + ``` """ data = _read("languages.json") try: diff --git a/src/osrlib/errors.py b/src/osrlib/errors.py index 113f73f..ee71b6e 100644 --- a/src/osrlib/errors.py +++ b/src/osrlib/errors.py @@ -1,18 +1,49 @@ -"""Exception hierarchy for out-of-fiction failures. - -The typed hierarchy rooted at [`OsrlibError`][osrlib.errors.OsrlibError] is reserved for -out-of-fiction failures: corrupt saves, unknown schema versions, malformed content. An -exception means the caller broke the API contract, or the content itself is malformed — -never that a player's in-fiction choice was illegal. An invalid in-fiction command -(moving through a wall) or an illegal creation choice is refused as a -[`Rejection`][osrlib.core.validation.Rejection] result, not raised. Programmer misuse -(bad argument types, out-of-range seeds) raises stdlib `ValueError` or `TypeError` -instead. - -A front end maps this hierarchy to its own error surface — HTTP status codes, process -exit codes, dialog text — however suits its platform. The hierarchy grows additively -over time: new exception types may be added, but existing ones are never removed or -repurposed. +"""The exceptions osrlib raises, and the failures they stand for. + +Three different things can go wrong when you call this library, and each has its own +answer. Something a player tried that the rules forbid, like walking into a wall or choosing +a class their scores don't qualify for, isn't an exception at all. The call returns a +refusal, a [`Rejection`][osrlib.core.validation.Rejection], which gives you a code and the +facts behind it so you can tell the player why. Something you got wrong in your own code, +like an ability score outside 3 to 18 or a seed out of range, raises the stdlib `ValueError` +or `TypeError`, because that's a bug to fix rather than a state to handle. Everything else +raises from the hierarchy here. + +That leaves these exceptions for the failures that come from outside the running game: a +save file someone truncated or hand-edited, a document written by a newer version of the +library than the one reading it, a dice expression that doesn't parse, a command log +replayed under different rules. +[`OsrlibError`][osrlib.errors.OsrlibError] is the base class, so a single `except +OsrlibError` catches all of them, and the three subclasses let you separate the cases that +deserve different answers. + +Which one you see depends on where you are. Reading a document raises +[`ContentValidationError`][osrlib.errors.ContentValidationError] when the document is +malformed and [`SaveVersionError`][osrlib.errors.SaveVersionError] when it's only too new. +Replaying a command log raises +[`ReplayVersionError`][osrlib.errors.ReplayVersionError] when the engine underneath has +changed. The difference matters: you can't recover from the first, the second means telling +the player to upgrade, and the third means loading the save instead of replaying it. + +How you report a failure is yours to choose: an HTTP status code, a process exit code, a +dialog. The hierarchy grows by addition, so a later version can add an exception type but +won't remove or repurpose one, and an `except OsrlibError` you write today keeps catching +everything. + +Examples: + ```python + from osrlib.errors import OsrlibError, SaveVersionError + from osrlib.versioning import check_document + + future = {"kind": "save", "schema_version": 999, "payload": {}} + try: + check_document(future, "save") + except SaveVersionError as error: + print(f"too new: {error}") + except OsrlibError: + print("unreadable") + # too new: document schema_version 999 is newer than the supported 3 + ``` """ __all__ = [ @@ -24,34 +55,90 @@ class OsrlibError(Exception): - """Base class for all osrlib exceptions.""" + """The base class every osrlib exception inherits from. + + Catch this when you want one handler for anything the library refuses to do, at the edge + of a web request or a command-line program, and you don't need to tell the cases apart. + Catch a subclass instead when you do. A caller can offer a repair for + [`SaveVersionError`][osrlib.errors.SaveVersionError] and for + [`ReplayVersionError`][osrlib.errors.ReplayVersionError], and none for + [`ContentValidationError`][osrlib.errors.ContentValidationError]. + + Nothing raises `OsrlibError` itself, and don't raise it from your own code. It exists to + be caught, and a bare instance tells a handler nothing about what happened. + + It doesn't cover a player choice the rules refuse, which comes back as a + [`Rejection`][osrlib.core.validation.Rejection] rather than being raised, nor a mistake + in your own call, which raises the stdlib `ValueError` or `TypeError`. Catching + `OsrlibError` alone leaves both of those to travel on, which is what you want. + """ class ContentValidationError(OsrlibError): - """Raised when rules content is malformed. + """Raised when content handed to the library is malformed. + + This is the failure with no repair. Whatever was read can't be understood, so there's + nothing to fall back to. Show the message, which names what was wrong, and go no further + with that input. + + It comes from the boundaries where osrlib accepts something from outside itself: + + - [`parse`][osrlib.core.dice.parse], on a dice expression that doesn't match the grammar. + - [`load_game`][osrlib.persistence.load_game], + [`check_document`][osrlib.versioning.check_document], and + [`party_from_document`][osrlib.core.character.party_from_document], on a document whose + envelope, kind, or payload isn't what they expect. + - [`parse_command`][osrlib.crawl.commands.parse_command] and + [`parse_any_event`][osrlib.crawl.events.parse_any_event], by way of the loaders that + call them. + - [`validate_adventure`][osrlib.crawl.adventure.validate_adventure], on an adventure whose + own structure doesn't hold together. + - The loaders in [`osrlib.data`][osrlib.data], when the data shipped in the package fails + model validation. + - [`replay_game`][osrlib.persistence.replay_game], when a logged command is refused the + second time. That means the replay has diverged from the game it was meant to reproduce. - Covers content that fails validation at a library boundary, such as a dice - expression that doesn't match the grammar in [`parse`][osrlib.core.dice.parse], - compiled SRD data that fails model validation, or a serialized document whose - structure or kind is not what the loader expects. + A document that's well formed but stamped with a newer schema raises + [`SaveVersionError`][osrlib.errors.SaveVersionError] instead, and you can recover from + that one by telling the player to upgrade. """ class SaveVersionError(OsrlibError): - """Raised when a serialized document's `schema_version` is newer than the library understands. + """Raised when a document was written by a newer version of osrlib than the one reading it. - Loading a document written by a newer library fails fast with this error rather - than silently misreading it; see - [`check_document`][osrlib.versioning.check_document]. + Every document osrlib writes includes a `schema_version`, and + [`check_document`][osrlib.versioning.check_document] compares it against + [`SCHEMA_VERSION`][osrlib.versioning.SCHEMA_VERSION] before any field is read. A number + higher than the running library's means the document can contain shapes this code has + never seen, so the read stops there rather than guessing and misreading it. + + Catch this separately from + [`ContentValidationError`][osrlib.errors.ContentValidationError] where a player is + watching, because it has an answer: the file is fine, the library is behind. Tell them to + upgrade osrlib and try again. An older document needs no handling from you, because + [`load_game`][osrlib.persistence.load_game] migrates it forward. + + It reaches you from [`load_game`][osrlib.persistence.load_game], + [`party_from_document`][osrlib.core.character.party_from_document], + [`ContentPack.from_document`][osrlib.crawl.content_pack.ContentPack.from_document], and + any other reader that calls `check_document` first. """ class ReplayVersionError(OsrlibError): - """Raised when a command log is replayed under a different engine version. + """Raised when a command log is replayed under an engine version other than the one that recorded it. + + Only [`replay_game`][osrlib.persistence.replay_game] raises it, and **only** when you + pass `recorded_engine_version`, so you decide whether the check happens at all. + + Replay runs the recorded commands again from the same seed and relies on the rules + resolving them the same way. A change to those rules can move an outcome, which would + make the replayed game differ from the one that was played. Comparing the versions turns + that into a failure you can see instead of a difference you can't. - Any rules change may legitimately alter outcomes, so replaying under a - different engine version is an explicit, detectable error rather than a - silent divergence: replay reproduces the original outcomes only when run - under the identical engine version. Loading a *save* across engine versions - remains legal; replay is the guarantee that breaks. + When you catch it, load the save rather than replaying its log. + [`load_game`][osrlib.persistence.load_game] restores the recorded state directly and + works across engine versions. Replay is the stricter path, and this is what it trades for + that strictness. """ diff --git a/src/osrlib/messages.py b/src/osrlib/messages.py index f923424..3849ba8 100644 --- a/src/osrlib/messages.py +++ b/src/osrlib/messages.py @@ -1,15 +1,42 @@ -"""The default English message formatter. - -[`format_message`][osrlib.messages.format_message] renders any [`Event`][osrlib.core.events.Event] -to a plain English line — pure string templating keyed by the event's outcome-bearing -`code`, no I/O. It is a total function: an event whose code has no template formats to -the code string itself rather than raising, so a transcript stays printable even when -it holds event types this version of the library doesn't recognize. - -Templates reference what the event carries: entity IDs, and — where an event resolves -one at emission, as the quest and objective events do — an authored display name. A -front end or narrator that wants richer prose resolves IDs itself and localizes -freely; this formatter exists so a bare kernel transcript is readable without one. +"""Turn an event into a line of English. + +osrlib never puts prose in an event. A [`GameSession`][osrlib.crawl.session.GameSession] +answers a command with typed events made of structured facts, like who attacked, what they +rolled, and how much damage landed, and your front end decides what a player reads. This +module is the answer that comes in the box. +[`format_message`][osrlib.messages.format_message] takes an event and returns a plain +English sentence, so you can print a session's output before you've written any rendering of +your own. + +The event's `code` decides what you get. It's a dotted name like `combat.attack.hit` that +says which outcome the event records. Every event class declares the codes it can use, and +this module keeps one template per code. +[The message-code reference][message-codes] lists every code that ships, the event class +behind it, the event's default visibility, and the template itself. Read that page when +you're deciding which codes your front end handles differently. + +Use this formatter for a log, a transcript, a debugging view, or the first working version +of a game. Replace it when you want wording of your own, and you replace the whole of it: +read the event's fields and write your own line, or hand the event to a narrator. There's +nothing here to configure, no way to register a template, and no translation, because a +front end that cares about wording already has the structured fields it needs to write any +wording it likes. Entity ids come out as ids, since only your game knows the name behind +`orc-1`. The quest and objective events are the exception, and include a display name the +adventure's author wrote. + +Formatting is string work. It reads no files, makes no calls, and changes nothing, so it's +safe on any event from any source. + +Typical usage: + +```python +from osrlib.core.events import DamageDealtEvent +from osrlib.messages import format_message + +event = DamageDealtEvent(target_id="orc-1", attacker_id="hild", amount=5) +print(format_message(event)) +# orc-1 takes 5 damage from hild. +``` """ from collections.abc import Callable @@ -101,8 +128,8 @@ def _turning(event: UndeadTurnedEvent, outcome: str) -> str: return f"{event.caster_id} presents the holy symbol (rolled {event.roll}{pool}) — {outcome}." -# The registry dispatches on the event's `code`, so each template statically knows -# its concrete event class — `Any` is the honest typing of code-keyed dispatch. +# The registry dispatches on the event's `code`, so each template already knows its concrete +# event class. `Any` is the accurate annotation for dispatch keyed on a string. _TEMPLATES: dict[str, Callable[[Any], str]] = { "combat.initiative.rolled": _initiative, "combat.attack.hit": _attack_hit, @@ -351,8 +378,8 @@ def _turning(event: UndeadTurnedEvent, outcome: str) -> str: "session.journal.entry_added": lambda event: f"Journal: {event.text}", "session.note.recorded": lambda event: f"Referee note: {event.text}", "session.quest.activated": lambda event: f"A new quest: {event.name}.", - # The quest and objective names fall back to the ids so an event logged before - # the name fields existed still formats — the engine always fills them. + # The quest and objective names fall back to the ids, so an event that doesn't include + # them still formats. The session fills them in when it emits these events. "session.quest.objective_revealed": lambda event: ( f"Quest {event.quest_name or event.quest_id}: a new objective, {event.name or event.objective_id}." ), @@ -365,30 +392,49 @@ def _turning(event: UndeadTurnedEvent, outcome: str) -> str: def format_message(event: Event) -> str: - """Format an event as a default English message. + """Format an event as a line of default English. - Total: an event whose code has no template formats to the code string itself — - never raises — so logs carrying event types this function doesn't recognize stay - printable. + Call it on each event in a [`CommandResult`][osrlib.crawl.commands.CommandResult]'s + `events`, or on each entry in a session's event log, to get a transcript a person can + read. Filter on the event's `visibility` first if you're showing the result to a player, + so referee-only events stay hidden. - An event carrying a non-empty `narrative` field — the authored beat a gate's - success rides — has that text appended verbatim after the templated line. This - formatter is the library's deterministic renderer, and authored text is shown - exactly as written. + On an event osrlib built, it doesn't raise and it doesn't return an empty string. An + event whose code has no template comes back as the code itself, so a log written by a + newer osrlib than the one reading it still prints, one plain line per event, instead of + failing part way through. An event you assemble by hand can still raise `AttributeError`, + because a template reads the fields its own event class declares: give an event a code + from another class and the field that template wants isn't there. + + Some events include a `narrative`, the sentence an adventure's author wrote for that + moment. When one is there it's appended to the templated line, word for word, because the + wording of authored text belongs to the author. + + Write your own formatter as soon as you want control of the wording. This one prints + entity ids as ids, writes only English, and has no hook for changing a template. Every + fact it uses is a typed field on the event, so your version reads the same fields and + uses your own game's names. Args: - event: The event to format. + event: Any event, from a command result, a session's event log, or a save you loaded. Returns: - The formatted English line, or the event's code when no template exists. + One line, with no trailing newline: the template for the event's code, followed by + the event's `narrative` when it has one, or the bare code when no template + matches. Examples: ```python - from osrlib.core.events import DamageDealtEvent + from osrlib.core.events import DamageDealtEvent, Event, Visibility from osrlib.messages import format_message event = DamageDealtEvent(target_id="orc-1", attacker_id="hild", amount=5) - assert format_message(event) == "orc-1 takes 5 damage from hild." + print(format_message(event)) + # orc-1 takes 5 damage from hild. + + unknown = Event(code="future.thing.happened", visibility=Visibility.PLAYER) + print(format_message(unknown)) + # future.thing.happened ``` """ template = _TEMPLATES.get(event.code) diff --git a/src/osrlib/persistence.py b/src/osrlib/persistence.py index 9c57300..727dadd 100644 --- a/src/osrlib/persistence.py +++ b/src/osrlib/persistence.py @@ -1,23 +1,93 @@ -"""Save/load and replay: two paths to a running session, guaranteed to agree. - -A save, produced by [`save_game`][osrlib.persistence.save_game], is a -[`stamp_document`][osrlib.versioning.stamp_document] envelope of kind `"save"` -carrying the full session state: party, the adventure's own content (a save is -self-contained and needs no other files to load), dungeon state, clock, ledger, -allocator, registry monsters, flags, trigger fired-marks, the journal, quest state, -listener state, mode, crawl counters, exported RNG stream states, the master seed, -the accepted-command log always, and the event log optionally. A session restores -from that state alone — the command and event logs are records of what happened, -not dependencies of the restore. - -[`load_game`][osrlib.persistence.load_game] rebuilds a session directly from a save's -state, migrating older schema versions on the way in. -[`replay_game`][osrlib.persistence.replay_game] rebuilds a session the other way, by -re-executing the master seed and the accepted-command log from scratch; it is valid -only under the identical engine version that recorded the log, and raises -[`ReplayVersionError`][osrlib.errors.ReplayVersionError] on a mismatch. Loading a save -and replaying its command log from the same seed always reach the identical session -state. +"""Save a game, load it back, or rebuild it by replaying what the player did. + +Two ways lead from a stored game to a running +[`GameSession`][osrlib.crawl.session.GameSession], and they reach the same place. + +The one you want most of the time is save and load. +[`save_game`][osrlib.persistence.save_game] turns a live session into a plain dictionary you +can write as JSON, and [`load_game`][osrlib.persistence.load_game] turns that dictionary +back into a session that continues where it left off. A save is self-contained: it includes +the adventure's own content, so loading needs no other file, and you can hand a player a +save without handing them the adventure it came from. + +The other way is replay. [`replay_game`][osrlib.persistence.replay_game] starts from +nothing but the master seed, the party as it stood before play began, the adventure, the +ruleset, and the list of commands the player issued, and runs the whole game again from the +first command. Because every random draw in osrlib comes from a seeded stream, the second +run lands on the same rolls as the first, and the session it produces matches the one a load +of the same game produces, field for field. That match depends on the party document being +the one you took before any session touched the party, because +[`GameSession.new`][osrlib.crawl.session.GameSession.new] assigns the member ids itself. +Replay is for auditing a game, reproducing a bug report, or checking that a rules change +moved nothing it shouldn't have. + +A save contains the session's whole state: the party, the adventure content, the explored +dungeon, the clock, the active effects, the spawned monsters and NPCs, flags, fired +triggers, the journal, quest progress, listener state, the session mode, the exploration +counters, any encounter or battle in progress, every RNG stream's position, and the master +seed. Beside that state sit two records of what happened: the log of accepted commands, and +the log of events unless you ask for it to be left out. The records are history, not +ingredients. A load rebuilds the session from the state and re-derives nothing from the +logs, which is why loading costs the same however long the game has run. + +The two logs do different jobs. The command log is what +[`replay_game`][osrlib.persistence.replay_game] consumes, so a save without it can be loaded +but not replayed. The event log is the transcript a front end shows, and it's the part you +can drop, with `include_event_log=False`, when the save is only meant to be resumed. + +A save is a stamped document of kind `"save"`, the envelope described in +[`osrlib.versioning`][osrlib.versioning]. Its `schema_version` is what lets an older save +still load: [`load_game`][osrlib.persistence.load_game] runs the payload through +[`MIGRATIONS`][osrlib.persistence.MIGRATIONS] on the way in, step by step, until it reaches +the shape this library reads. Its `engine_version` is what guards replay, because the same +commands under different rules can produce a different game. +[`replay_game`][osrlib.persistence.replay_game] refuses that with +[`ReplayVersionError`][osrlib.errors.ReplayVersionError] when you give it the recorded +version to compare. Loading a save across engine versions stays fine, since a load reads +state rather than re-deriving it. + +Typical usage: + +```python +import json + +from osrlib.core.alignment import Alignment +from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character, party_to_document +from osrlib.core.rng import RngStreams +from osrlib.core.ruleset import Ruleset +from osrlib.crawl.adventure import Adventure, TownSpec +from osrlib.crawl.commands import EnterDungeon +from osrlib.crawl.dungeon import DungeonSpec, LevelSpec +from osrlib.crawl.party import Party +from osrlib.crawl.session import GameSession +from osrlib.persistence import load_game, replay_game, save_game, session_state + +rules = Ruleset() +roll = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM) +pc = create_character(name="Hild", class_id="fighter", alignment=Alignment.LAWFUL, ruleset=rules, stream=roll) + +# Keep the party as it stands before any session touches it: that is what a replay starts from. +starting_party = party_to_document([pc.character]) + +level = LevelSpec(number=1, width=1, height=1, entrance=(0, 0)) +crypt = DungeonSpec(id="crypt", name="The Old Crypt", levels=(level,)) +town = TownSpec(name="Threshold", travel_turns={"crypt": 1}) +adventure = Adventure(name="A First Delve", town=town, dungeons=(crypt,)) + +session = GameSession.new(Party(members=[pc.character]), adventure, seed=7, ruleset=rules) +session.execute(EnterDungeon(dungeon_id="crypt")) + +# Save, write it out, read it back, and continue from where the party stood. +document = save_game(session) +restored = load_game(json.loads(json.dumps(document))) +print(restored.mode.value) +# exploring + +# Replay reaches the same session from the seed and the commands alone. +replayed = replay_game(7, starting_party, adventure, rules, session.command_log) +print(session_state(replayed) == session_state(session)) +# True +``` """ from collections.abc import Callable, Mapping, Sequence @@ -57,27 +127,27 @@ def _migrate_1_to_2(payload: dict) -> dict: - """Schema 1 → 2: drop the recovered-treasure ledger. + """Migrate a schema 1 payload to schema 2 by dropping the recovered-treasure ledger. - The departure-snapshot valuation delta replaced the ledger as the award's - honest input, so version-1 saves simply shed the field. NPC adventurers - arrived with version 2; a version-1 save has none. + The end-of-adventure award is worked out from the valuation taken when the party left + town, so a version-1 save drops the ledger field. A version-1 save has no NPC + adventurers, which arrived with version 2, so the NPC list starts empty. """ - # A ledger kept only "as a log," with no code ever reading it back, is dead state; - # dropping the field is preferable to migrating a shape nothing consumes. + # Nothing read the ledger back, so the field is dropped rather than migrated into a + # shape no code consumes. payload.pop("recovered_treasure", None) payload["npcs"] = [] return payload def _migrate_2_to_3(payload: dict) -> dict: - """Schema 2 → 3: a treasure trap's trigger is always `"open"`. + """Migrate a schema 2 payload to schema 3 by rewriting a treasure trap's trigger to `"open"`. - Version 3 made [`TrapSpec`][osrlib.crawl.dungeon.TrapSpec] reject - `trigger="enter"` on `kind="treasure"` — a value the cache path never read, - so rewriting it to the one springing action a cache has is lossless. The - embedded adventure is the only save surface that carries trap specs, and a - treasure trap can sit only on a feature (area-level or level-level). + In schema 3, [`TrapSpec`][osrlib.crawl.dungeon.TrapSpec] rejects `trigger="enter"` on + `kind="treasure"`. Nothing on the cache path ever read that value, so rewriting it to + `"open"`, the one action that springs a cache, loses nothing. The embedded adventure is + the only part of a save with trap specs in it, and a treasure trap sits on a feature, + either on an area or on a level. """ for dungeon in payload.get("adventure", {}).get("dungeons", ()): for level in dungeon.get("levels", ()): @@ -92,18 +162,78 @@ def _migrate_2_to_3(payload: dict) -> dict: MIGRATIONS: dict[int, Callable[[dict], dict]] = {1: _migrate_1_to_2, 2: _migrate_2_to_3} -"""Ordered save migrations: `MIGRATIONS[n]` rewrites a version-`n` payload to `n+1`.""" +"""The steps that bring an old save payload forward, one schema version at a time. + +`MIGRATIONS[n]` rewrites a payload written at schema version `n` into the shape version +`n + 1` expects. [`load_game`][osrlib.persistence.load_game] walks the chain for you, from +whatever version the document was stamped with up to +[`SCHEMA_VERSION`][osrlib.versioning.SCHEMA_VERSION], so a save from an older release loads +without any code of yours. + +Read it when you want to know what an old save loses or gains on the way in, or to check +that a version you still have stored can be loaded at all: a version with no step in this +chain can't, and `load_game` raises +[`ContentValidationError`][osrlib.errors.ContentValidationError] naming the missing step. +Nothing here is a hook. Adding an entry doesn't extend the library, since the chain only +ever runs as far as the schema versions this release knows about. +""" def session_state(session: GameSession, *, include_event_log: bool = True) -> dict: - """Serialize a session's full state (the save payload, sans envelope). + """Serialize a session's whole state, without the document envelope around it. + + This is the payload [`save_game`][osrlib.persistence.save_game] stamps, offered on its + own for when the envelope is in your way: embedding a session inside a larger document of + your own, comparing two sessions field by field, or inspecting what a session contains. + Call `save_game` instead whenever you mean to store the result, because a payload with no + envelope has no version stamps, and nothing can tell later which osrlib wrote it. + + Nothing on the session changes, and the dicts and lists the session's own models produce + are fresh, so you can keep the result, edit it, and serialize it whenever you like. One + part is shared: an event log entry that arrived as a raw dict, which + [`load_game`][osrlib.persistence.load_game] keeps for an event type this osrlib doesn't + recognize, goes into the payload by reference, and editing it edits the session's entry + too. Args: - session: The session to serialize. - include_event_log: False compacts the save to state plus the command log. + session: The session to serialize. It may be in any mode, mid-encounter or + mid-battle included. + include_event_log: Whether to include the transcript. Pass False to leave the event + log out, which makes a long game's save much smaller. The accepted-command log is + included either way, because a replay needs it. Returns: - The JSON-ready state dict. + A new dict of JSON-compatible values, ready for `json.dumps`, with the session's + state, the accepted-command log under `command_log`, and the transcript under + `event_log` when `include_event_log` is True. + + Examples: + ```python + from osrlib.core.alignment import Alignment + from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character + from osrlib.core.rng import RngStreams + from osrlib.core.ruleset import Ruleset + from osrlib.crawl.adventure import Adventure, TownSpec + from osrlib.crawl.dungeon import DungeonSpec, LevelSpec + from osrlib.crawl.party import Party + from osrlib.crawl.session import GameSession + from osrlib.persistence import session_state + + rules = Ruleset() + roll = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM) + pc = create_character(name="Hild", class_id="fighter", alignment=Alignment.LAWFUL, ruleset=rules, stream=roll) + + level = LevelSpec(number=1, width=1, height=1, entrance=(0, 0)) + crypt = DungeonSpec(id="crypt", name="The Old Crypt", levels=(level,)) + town = TownSpec(name="Threshold", travel_turns={"crypt": 1}) + adventure = Adventure(name="A First Delve", town=town, dungeons=(crypt,)) + + session = GameSession.new(Party(members=[pc.character]), adventure, seed=7, ruleset=rules) + + state = session_state(session, include_event_log=False) + print(state["master_seed"], state["mode"], "event_log" in state) + # 7 town False + ``` """ payload: dict = { "master_seed": session.master_seed, @@ -150,14 +280,56 @@ def session_state(session: GameSession, *, include_event_log: bool = True) -> di def save_game(session: GameSession, *, include_event_log: bool = True) -> dict: - """Serialize a session to a stamped save document. + """Serialize a session to a save document you can store. + + This is how you write a game to disk: take the result, hand it to `json.dumps`, and put + it wherever you keep saves. Call it as often as you like. It reads the session and + changes nothing, so saving mid-encounter or mid-battle is as safe as saving in town. + + The result is a stamped document of kind `"save"`, described in + [`osrlib.versioning`][osrlib.versioning]. The session state sits under `payload`, wrapped + in the schema and engine versions that tell a later + [`load_game`][osrlib.persistence.load_game] what it's reading. + [`session_state`][osrlib.persistence.session_state] gives you the payload without the + envelope, for when you're embedding it in a document of your own rather than storing it. Args: session: The session to save. - include_event_log: False compacts the save (state plus command log only). + include_event_log: Whether to include the transcript. Pass False to leave the event + log out and keep only the state and the accepted-command log, which is all a + resume or a replay needs. Returns: - The stamped `"save"` document. + A new dict of JSON-compatible values with `kind`, `schema_version`, `engine_version`, + and `payload` keys. + + Examples: + ```python + from osrlib.core.alignment import Alignment + from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character + from osrlib.core.rng import RngStreams + from osrlib.core.ruleset import Ruleset + from osrlib.crawl.adventure import Adventure, TownSpec + from osrlib.crawl.dungeon import DungeonSpec, LevelSpec + from osrlib.crawl.party import Party + from osrlib.crawl.session import GameSession + from osrlib.persistence import save_game + + rules = Ruleset() + roll = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM) + pc = create_character(name="Hild", class_id="fighter", alignment=Alignment.LAWFUL, ruleset=rules, stream=roll) + + level = LevelSpec(number=1, width=1, height=1, entrance=(0, 0)) + crypt = DungeonSpec(id="crypt", name="The Old Crypt", levels=(level,)) + town = TownSpec(name="Threshold", travel_turns={"crypt": 1}) + adventure = Adventure(name="A First Delve", town=town, dungeons=(crypt,)) + + session = GameSession.new(Party(members=[pc.character]), adventure, seed=7, ruleset=rules) + + document = save_game(session) + print(document["kind"], sorted(document)) + # save ['engine_version', 'kind', 'payload', 'schema_version'] + ``` """ return stamp_document("save", session_state(session, include_event_log=include_event_log)) @@ -170,7 +342,7 @@ def _migrate( Args: payload: The save payload at `from_version`. from_version: The document's recorded schema version. - migrations: The chain to apply; defaults to + migrations: The chain to apply. Defaults to [`MIGRATIONS`][osrlib.persistence.MIGRATIONS] (tests inject synthetic chains here). @@ -192,22 +364,37 @@ def _migrate( def load_game(document: Mapping[str, object]) -> GameSession: """Restore a session from a save document. - Runs [`check_document`][osrlib.versioning.check_document], then the ordered - migration chain, then rebuilds the session and restores the RNG streams via - [`RngStream.restore`][osrlib.core.rng.RngStream.restore]. Event-log entries - whose types this process doesn't know are preserved as raw records that - reserialize losslessly — the log is a record, never re-derived, never lossy. + Hand it what you read back from storage and you get a live + [`GameSession`][osrlib.crawl.session.GameSession], standing where it stood when + [`save_game`][osrlib.persistence.save_game] wrote it: same position, same clock, same hit + points, same RNG streams, so the next roll is the roll the saved game was about to make. + + One thing doesn't come back. Listeners are your code, and a save can't store code, so the + restored session has none registered. Call + [`register_listener`][osrlib.crawl.session.GameSession.register_listener] again for each + one, including the [`Interpreter`][osrlib.crawl.interpreter.Interpreter] if your game + uses it, before you execute another command. Each listener's own state was saved and is + waiting under its key. + + An older save needs nothing from you. The document is checked, then walked forward + through [`MIGRATIONS`][osrlib.persistence.MIGRATIONS] one schema version at a time before + anything is rebuilt. An event in the transcript whose type this version of osrlib doesn't + recognize is kept as it was found and written back out unchanged on the next save, so a + log loses no entries by passing through an older library. Args: - document: A document produced by [`save_game`][osrlib.persistence.save_game]. + document: A document produced by [`save_game`][osrlib.persistence.save_game], usually + parsed back from JSON. Returns: - The restored session (listeners must be re-registered by the game). + The restored session, with no listeners registered. Raises: - ContentValidationError: If the envelope or payload is malformed. - SaveVersionError: If the document's schema version is newer than this - library understands. + ContentValidationError: If the envelope or the payload is malformed, if a logged + command is of a type this version doesn't know, or if no migration step exists + for the document's schema version. + SaveVersionError: If a newer osrlib wrote the document. Tell the player to upgrade. + There's nothing to repair in the file. Examples: ```python @@ -270,8 +457,8 @@ def load_game(document: Mapping[str, object]) -> GameSession: session.journal = [JournalEntry.model_validate(entry) for entry in payload.get("journal", [])] if "quests" in payload: # A payload without the block keeps the seed the constructor built from - # the save's own adventure — exactly right for a save written before the - # adventure could carry a quest, whose seed is the empty block anyway. + # the save's own adventure, which is right for a save from a release whose + # adventures had no quests: that seed is the empty block anyway. session.quests = {key: QuestState.model_validate(value) for key, value in payload["quests"].items()} session.listener_state = {key: dict(value) for key, value in payload["listener_state"].items()} session.death_records = { @@ -328,27 +515,89 @@ def replay_game( *, recorded_engine_version: str | None = None, ) -> GameSession: - """Re-execute a command log from the seed — the determinism contract exercised. + """Rebuild a session by running its recorded commands again from the seed. + + Where [`load_game`][osrlib.persistence.load_game] restores a stored state, this plays the + game a second time: a fresh session on the same master seed, then every command in the + log, in order. Each random draw comes from a seeded stream, so the rolls fall the same + way, and the session you get back matches the one a load of the same save produces. + + Use it to audit a game, to reproduce a player's bug report from their save, or to check + that a rules change you made moved nothing it shouldn't have. Use `load_game` for + everything else, including resuming play, because a replay costs the whole game again and + gives you nothing a load doesn't. + + Four of the five inputs come straight out of a save document's payload, under + `master_seed`, `adventure`, `ruleset`, and `command_log`. The fifth, `party_document`, is + the one you have to plan for. It must be the party as it stood before any session touched + it, because [`GameSession.new`][osrlib.crawl.session.GameSession.new] assigns member ids + itself, in party order, the same way both times. Take that document with + [`party_to_document`][osrlib.core.character.party_to_document] when you roll the party, + and keep it beside your saves. + + The replayed session gets no listeners, and needs none. Everything a listener did during + the original game, whether an interpreter firing a trigger's consequences or your own + code awarding a prize, it did by issuing a command the session accepted and logged. Those + commands are in the log already, and re-executing them rebuilds every effect. A listener + registered on a replay would issue them a second time and pull the game off course. Args: - seed: The master seed the session ran under. - party_document: The starting party as a stamped `"party"` document (the - pre-session party; the session re-assigns the same ids). - adventure: The frozen adventure content. - ruleset: The ruleset the session ran under. - commands: The accepted-command log, as commands or their serialized forms. - recorded_engine_version: The engine version the log was recorded under, - when known (a save's stamp); a mismatch raises. + seed: The master seed the original session ran under, from the save's `master_seed`. + party_document: The starting party, stamped by + [`party_to_document`][osrlib.core.character.party_to_document] before the party + joined any session. + adventure: The adventure the game was played in. + ruleset: The ruleset the game was played under. A different one can change outcomes, + and nothing here detects that. + commands: The accepted commands, in order, either as + [`Command`][osrlib.crawl.commands.Command] objects or as the dicts a save stores + under `command_log`. + recorded_engine_version: The `engine_version` from the save this log came from. Pass + it to have the replay refuse to run under different rules. Leave it out and the + replay runs unchecked. Returns: - The replayed session, in the exact state the original reached. + The replayed session, in the state the original reached, with no listeners registered. Raises: - ReplayVersionError: If the log was recorded under a different engine - version — replays are valid only under the identical engine. - ContentValidationError: If a command fails to parse, or a logged command - is rejected on replay (divergence — the log holds accepted commands - only). + ReplayVersionError: If `recorded_engine_version` is given and doesn't match the + running [`engine_version`][osrlib.versioning.engine_version]. Load the save + instead, which works across engine versions. + ContentValidationError: If a logged command is of a type this version doesn't know, + or if a logged command is refused this time. The log contains only commands that + were accepted the first time, so a refusal means the replay has diverged from the + game it was meant to reproduce. + + Examples: + ```python + from osrlib.core.alignment import Alignment + from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character, party_to_document + from osrlib.core.rng import RngStreams + from osrlib.core.ruleset import Ruleset + from osrlib.crawl.adventure import Adventure, TownSpec + from osrlib.crawl.commands import EnterDungeon + from osrlib.crawl.dungeon import DungeonSpec, LevelSpec + from osrlib.crawl.party import Party + from osrlib.crawl.session import GameSession + from osrlib.persistence import replay_game, session_state + + rules = Ruleset() + roll = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM) + pc = create_character(name="Hild", class_id="fighter", alignment=Alignment.LAWFUL, ruleset=rules, stream=roll) + starting_party = party_to_document([pc.character]) + + level = LevelSpec(number=1, width=1, height=1, entrance=(0, 0)) + crypt = DungeonSpec(id="crypt", name="The Old Crypt", levels=(level,)) + town = TownSpec(name="Threshold", travel_turns={"crypt": 1}) + adventure = Adventure(name="A First Delve", town=town, dungeons=(crypt,)) + + session = GameSession.new(Party(members=[pc.character]), adventure, seed=7, ruleset=rules) + session.execute(EnterDungeon(dungeon_id="crypt")) + + replayed = replay_game(7, starting_party, adventure, rules, session.command_log) + print(session_state(replayed) == session_state(session)) + # True + ``` """ if recorded_engine_version is not None and recorded_engine_version != engine_version(): raise ReplayVersionError( diff --git a/src/osrlib/versioning.py b/src/osrlib/versioning.py index c2eb5e6..dd88234 100644 --- a/src/osrlib/versioning.py +++ b/src/osrlib/versioning.py @@ -1,18 +1,47 @@ -"""Schema and engine version stamping. - -Every serialized model (saves, commands, events) stamps itself with these values from -birth. `SCHEMA_VERSION` is the single monotonically increasing integer shared by saves, -commands, and events — independent of the package version. Within a schema version, -changes are additive only (new event types, new optional fields); renames, removals, and -semantic changes bump it, and a loader migrates an older document forward (see -[`osrlib.persistence`][osrlib.persistence]). The engine version identifies exact rules -behavior: identical replay outcomes are guaranteed only when the recorded and running -engine versions match. - -The stamped-document helpers wrap a payload with both versions and a `kind` string, so -serialized artifacts (characters, parties, saves) share one envelope -that [`check_document`][osrlib.versioning.check_document] can vet before any payload -field is trusted. +"""The two version stamps on every osrlib document, and the envelope that contains them. + +Anything osrlib writes for you to keep goes out as a stamped document: a save, a character, +a party, a content pack. The envelope is the same shape every time. It names the `kind` of +thing inside, the two version numbers, and the `payload`, which is the serialized content. +[`stamp_document`][osrlib.versioning.stamp_document] builds one and +[`check_document`][osrlib.versioning.check_document] checks one, and you rarely call either +yourself: [`save_game`][osrlib.persistence.save_game] and +[`load_game`][osrlib.persistence.load_game] wrap them for saves, and +[`party_to_document`][osrlib.core.character.party_to_document] does for parties. + +The two stamps answer two different questions. + +[`SCHEMA_VERSION`][osrlib.versioning.SCHEMA_VERSION] answers whether a document can still be +read. It's one integer shared by every document kind, and it moves only when the shape of +stored data changes in a way a reader would trip over. A document stamped lower than the +running library's number is read and brought forward. One stamped higher is refused with +[`SaveVersionError`][osrlib.errors.SaveVersionError], because the reader can't know what a +later version put in there. + +[`engine_version`][osrlib.versioning.engine_version] answers whether a game can still be +reproduced. It's the installed package version, and it moves with every release, including +releases that change no document shape. Rules can change between releases, so a recorded +command log rerun under a different engine can resolve differently. +[`replay_game`][osrlib.persistence.replay_game] refuses the mismatch rather than producing a +game that differs without saying so. Loading a save across engine versions is fine, because +a save contains the state instead of re-deriving it. + +If you store documents, keep both numbers with them. `schema_version` tells you whether the +library in front of you can open the file, and `engine_version` tells you whether a replay +of it still holds. + +Typical usage: + +```python +from osrlib.versioning import SCHEMA_VERSION, check_document, stamp_document + +document = stamp_document("note", {"text": "found the crypt"}) +print(document["kind"], document["schema_version"] == SCHEMA_VERSION) +# note True + +print(check_document(document, "note")) +# {'text': 'found the crypt'} +``` """ from collections.abc import Mapping @@ -28,43 +57,101 @@ ] SCHEMA_VERSION = 3 -"""The current serialization schema version shared by saves, commands, and events. - -Version 3 narrowed `TrapSpec`: a treasure trap's `trigger` must be `"open"`, the -one springing action a cache has. Earlier versions accepted `"enter"` and the -engine ignored it, so the 2 → 3 step in -[`MIGRATIONS`][osrlib.persistence.MIGRATIONS] rewrites the dead value to `"open"` -losslessly; content packs apply the same rewrite on load. - -Version 2 dropped the recovered-treasure ledger from the save payload: the -end-of-adventure award is computed from the departure-snapshot valuation delta -instead, so a save no longer needs to carry the ledger field. See -[`MIGRATIONS`][osrlib.persistence.MIGRATIONS] for the 1 → 2 step that drops it from -older saves on load. +"""The schema version this library writes, and the highest it can read. + +Every document [`stamp_document`][osrlib.versioning.stamp_document] produces includes this +number, and saves, commands, events, characters, parties, and content packs all share it. +There's one schema version for the whole library, not one per kind. + +Compare a stored document's `schema_version` against this to know what you can do with it. +Lower means the document still loads, and the reader brings it forward through +[`MIGRATIONS`][osrlib.persistence.MIGRATIONS] on the way in, so you need no code of your own +for old files. Equal means it loads as written. Higher means a later osrlib wrote it, and +reading it raises [`SaveVersionError`][osrlib.errors.SaveVersionError]. + +The number moves only when a change would break a reader: a field renamed, a field removed, +a value that now means something different. Additions don't move it, so a document from an +earlier release of the same schema version can be missing fields that newer documents +include, and readers fill those with their defaults. + +Two changes are behind the current number, and each is worth knowing if you keep old saves. +Version 2 dropped the recovered-treasure ledger from the save payload, since the +end-of-adventure award is worked out from the valuation taken when the party left town. +Version 3 narrowed a treasure trap's `trigger` to `"open"`, the one action that springs a +cache. Earlier documents could say `"enter"`, which nothing ever read, and the migration +rewrites it. Neither step loses anything, and a content pack gets the same trigger rewrite +when it loads. + +This is a fact about the library, not a setting. Assigning to it changes what your documents +claim to be without changing what's in them. """ def engine_version() -> str: - """Return the exact osrlib package version, for stamping into saves and replays. + """Return the version of the installed osrlib package. + + This is the second stamp on every document, and it's what makes a replay trustworthy. + [`stamp_document`][osrlib.versioning.stamp_document] calls it for you, so usually you + read this value out of a document rather than calling the function, then pass it to + [`replay_game`][osrlib.persistence.replay_game] as `recorded_engine_version` when you + want the replay refused if the rules underneath have moved. + + Call it directly to label a bug report, or to compare against a stamp you stored + elsewhere. It isn't the schema version. This number changes with every release, including + releases that change no document shape, so it tells you nothing about whether a document + still parses. [`SCHEMA_VERSION`][osrlib.versioning.SCHEMA_VERSION] answers that. Returns: - The installed package version as reported by package metadata. + The installed package version, as the packaging metadata reports it, like `"0.9.1"`. + + Examples: + ```python + from osrlib.versioning import engine_version, stamp_document + + document = stamp_document("note", {"text": "found the crypt"}) + assert document["engine_version"] == engine_version() + ``` """ return metadata.version("osrlib") def stamp_document(kind: str, payload: Mapping[str, object]) -> dict[str, object]: - """Wrap a payload in the stamped-document envelope. + """Wrap a serialized payload in the stamped-document envelope. + + Use this when you serialize something of your own and want it to travel the way osrlib's + own documents do, so [`check_document`][osrlib.versioning.check_document] can check it on + the way back in and a later reader can tell what's inside. For a save, a party, or a + content pack, call [`save_game`][osrlib.persistence.save_game], + [`party_to_document`][osrlib.core.character.party_to_document], or the pack's own writer + instead. Each one stamps its own kind and fills the payload correctly. + + The result is plain data, ready for `json.dumps`, as long as what you put in it is. Pass + a payload you already turned into JSON-compatible values, which for a pydantic model + means `model_dump(mode="json")`. This function copies the mapping one level deep and + converts nothing inside it. Args: - kind: The document kind, e.g. `"character"` or `"party"`. Non-empty. - payload: The serialized model content. + kind: What the document contains, like `"character"` or `"party"`. The reader passes + the same string to `check_document`, which refuses a document of any other kind, + so pick one name per document type and keep it. + payload: The serialized content, in JSON-compatible values. Returns: - A dict with `kind`, `schema_version`, `engine_version`, and `payload` keys. + A new dict with `kind`, `schema_version`, `engine_version`, and `payload` keys. The + payload is a shallow copy, so later edits to the mapping you passed do not reach + the document, though edits to objects nested inside it do. Raises: ValueError: If `kind` is empty. + + Examples: + ```python + from osrlib.versioning import stamp_document + + document = stamp_document("note", {"text": "found the crypt"}) + print(sorted(document)) + # ['engine_version', 'kind', 'payload', 'schema_version'] + ``` """ if not kind: raise ValueError("document kind must be non-empty") @@ -77,25 +164,59 @@ def stamp_document(kind: str, payload: Mapping[str, object]) -> dict[str, object def check_document(document: Mapping[str, object], expected_kind: str) -> dict[str, object]: - """Vet a stamped document's envelope and return its payload. + """Check a stamped document's envelope and return the payload inside it. + + Call this first thing when you read a document back, before you touch a single field of + the payload. It confirms the envelope is there and well formed, that the document + contains what you think it does, and that a later osrlib didn't write it. What you get + back is a payload you can trust to be the right kind of thing. + + It checks the envelope, not the contents. A payload that passes here can still fail when + you validate it into a model, which is where [`load_game`][osrlib.persistence.load_game] + and [`party_from_document`][osrlib.core.character.party_from_document] take it next, and + both of those call this function for you. Call it directly only for a document kind you + stamped yourself with [`stamp_document`][osrlib.versioning.stamp_document]. - Unknown extra keys in the envelope are ignored, per the additive-schema contract. - A `schema_version` older than the current one is accepted and migrated in order - (see [`osrlib.persistence`][osrlib.persistence]); schema version 1 is the floor. + A document older than the current schema passes. The envelope is accepted and the caller + migrates the payload forward, as `load_game` does through + [`MIGRATIONS`][osrlib.persistence.MIGRATIONS]. Extra keys in the envelope are ignored, so + a document written by a later release of the same schema version still reads. Args: - document: A mapping previously produced by - [`stamp_document`][osrlib.versioning.stamp_document]. - expected_kind: The kind the caller expects, e.g. `"character"`. + document: A mapping produced by + [`stamp_document`][osrlib.versioning.stamp_document], usually parsed back from + JSON. + expected_kind: The `kind` string you expect, like `"character"`. A document of any + other kind is refused, which is what stops a party document from being read as a + save. Returns: - The document's payload. + A new dict with the document's payload, copied one level deep, still at whatever + schema version the document was written under. Raises: - ContentValidationError: If the envelope is malformed (missing keys, wrong - types) or the document's kind is not `expected_kind`. - SaveVersionError: If the document's `schema_version` is newer than this - library understands. + ContentValidationError: If the document is not a mapping, is missing `kind`, + `schema_version`, or `payload`, has a non-integer `schema_version` or a + non-mapping payload, or is of a kind other than `expected_kind`. + SaveVersionError: If the document's `schema_version` is higher than + [`SCHEMA_VERSION`][osrlib.versioning.SCHEMA_VERSION], meaning it was written by a + newer osrlib. + + Examples: + ```python + from osrlib.errors import ContentValidationError + from osrlib.versioning import check_document, stamp_document + + document = stamp_document("note", {"text": "found the crypt"}) + print(check_document(document, "note")) + # {'text': 'found the crypt'} + + try: + check_document(document, "save") + except ContentValidationError as error: + print(error) + # expected a 'save' document, got kind 'note' + ``` """ if not isinstance(document, Mapping): raise ContentValidationError(f"document must be a mapping, got {type(document).__name__}")