From 980f61742eef15a71b363bf7e396474e412a97d9 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Sun, 13 Sep 2026 21:55:16 -0700 Subject: [PATCH 1/2] Docstrings: core creatures Rewrite the reference prose for the four modules that define the creatures a game plays with: player characters and their creation, class definitions and level tables, NPC adventuring parties, and monster templates and instances. Every public member answers what it does, why you'd reach for it, what you need first and where to get it, what to call next, what to use instead, and where it stops. Every pydantic field and enum member gets its own attribute docstring, so the rendered field rows carry descriptions instead of bare names. Entry points get runnable examples with the printed output copied from a real run. Claude-Session: https://claude.ai/code/session_01GL26QnA6dCrvUc3WmhzFSa --- src/osrlib/core/character.py | 970 +++++++++++++++++++++++++++------- src/osrlib/core/classes.py | 999 +++++++++++++++++++++++++++-------- src/osrlib/core/monsters.py | 761 ++++++++++++++++++++------ src/osrlib/core/npc.py | 248 ++++++--- 4 files changed, 2339 insertions(+), 639 deletions(-) diff --git a/src/osrlib/core/character.py b/src/osrlib/core/character.py index 4c74235..6bb32ca 100644 --- a/src/osrlib/core/character.py +++ b/src/osrlib/core/character.py @@ -1,37 +1,75 @@ -"""The player character: model, stepwise creation, and stamped serialization. - -[`Character`][osrlib.core.character.Character] is the player character model. Its -derived values — modifiers, AC, movement rate, literacy, languages — are properties -computed from stored state, never stored themselves, so they can never desync from it. -Model validation is structural only (score ranges, level within class bounds, HP -bounds); whether a creation step itself was legal (was the adjustment legal, were -requirements met) is enforced by the creation functions at the time of the step, -because a finished character cannot re-derive its own history. - -Creation follows the OSE SRD's Creating a Character steps as a sequence of pure -functions a caller drives one at a time — [`roll_ability_scores`] -[osrlib.core.character.roll_ability_scores], [`validate_class_choice`] -[osrlib.core.character.validate_class_choice], +"""The player character: the model, creating one step by step, and saving one to a document. + +Start here when you need a party. [`create_character`][osrlib.core.character.create_character] is +the entry point: hand it a name, a class id, an alignment, a +[`Ruleset`][osrlib.core.ruleset.Ruleset], and a seeded random-number stream from +[`osrlib.core.rng`][osrlib.core.rng], and it returns a first-level +[`Character`][osrlib.core.character.Character] together with the raw dice it rolled. Put the +characters you get into a [`Party`][osrlib.crawl.party.Party] to play them, or use them on their +own with the combat, magic, and item functions of the core kernel. + +[`Character`][osrlib.core.character.Character] is a mutable pydantic model. Its derived values, +which are the ability modifiers, both armour classes, movement rate, literacy, and the language +list, are properties computed from stored state rather than stored fields, so they cannot fall out +of step with the state they come from. Validation on the model is structural: score ranges, a level +within the class's bounds, current hit points no higher than maximum. Whether a creation step was +legal is checked by the creation functions when the step happens, because a finished character +keeps no record of the choices that made it. + +Creation follows the OSE SRD's Creating a Character steps as pure functions you drive one at a time +when a player is making the choices: +[`roll_ability_scores`][osrlib.core.character.roll_ability_scores], +[`validate_class_choice`][osrlib.core.character.validate_class_choice], [`apply_adjustment`][osrlib.core.abilities.apply_adjustment], +[`validate_starting_spells`][osrlib.core.character.validate_starting_spells] with +[`choose_starting_spells`][osrlib.core.character.choose_starting_spells], [`roll_hit_points`][osrlib.core.character.roll_hit_points], [`validate_extra_languages`][osrlib.core.character.validate_extra_languages], -[`roll_starting_gold`][osrlib.core.character.roll_starting_gold], and equipment -purchase — each taking the caller's choice and returning a structured result -(including the raw rolls, for display) or a list of rejections rather than raising. -Character creation is out-of-fiction and pre-session: it emits no events; the first -events belong to play. [`create_character`][osrlib.core.character.create_character] -drives the whole sequence in one call for scripts and tests that already know every -choice upfront. - -Advancement — leveling up, energy drain, and XP awards — lives in -[`osrlib.core.classes`][osrlib.core.classes], which also defines the played class. - -Two module-level RNG stream keys are the convention every session adopts: -[`CHARACTER_CREATION_STREAM`][osrlib.core.character.CHARACTER_CREATION_STREAM] for -creation draws (ability scores, first-level hit points, starting gold) and -[`ADVANCEMENT_STREAM`][osrlib.core.character.ADVANCEMENT_STREAM] for in-play level-up -hit point rolls, kept separate so a creation-rules change never shifts advancement -draws already recorded in a save. +[`roll_starting_gold`][osrlib.core.character.roll_starting_gold], and then buying and equipping +gear through [`osrlib.core.items`][osrlib.core.items]. Each validating step returns a list of +[`Rejection`][osrlib.core.validation.Rejection] records rather than raising, so you can show the +player what went wrong and let them choose again. Creation emits no events: it happens before a +session starts, and the first events belong to play. +[`create_character`][osrlib.core.character.create_character] runs the whole sequence in one call +when every choice is known upfront, and raises instead of returning rejections. + +Advancement, which is leveling up, energy drain, and experience awards, lives in +[`osrlib.core.classes`][osrlib.core.classes], the module that also defines the class a character +plays. Saving a character to disk goes through +[`to_document`][osrlib.core.character.Character.to_document] and +[`party_to_document`][osrlib.core.character.party_to_document], whose output +[`osrlib.persistence`][osrlib.persistence] writes as part of a whole-game save. + +Two stream keys are the naming convention every session adopts: +[`CHARACTER_CREATION_STREAM`][osrlib.core.character.CHARACTER_CREATION_STREAM] for creation draws +and [`ADVANCEMENT_STREAM`][osrlib.core.character.ADVANCEMENT_STREAM] for in-play level-up hit point +rolls. They stay separate so that a change to the creation rules never shifts advancement draws +already recorded in a save. + +Typical usage: + +```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.party import Party + +streams = RngStreams(master_seed=2) +stream = streams.get(CHARACTER_CREATION_STREAM) +result = create_character( + name="Rurik", + class_id="fighter", + alignment=Alignment.LAWFUL, + ruleset=Ruleset(), + stream=stream, + purchases=[("sword", 1), ("leather", 1)], + equip_ids=["sword", "leather"], +) +party = Party(members=[result.character]) +print(party.members[0].name, party.members[0].max_hp, party.members[0].armour_class) +# Rurik 8 6 +``` """ from collections.abc import Mapping, Sequence @@ -81,10 +119,33 @@ ] CHARACTER_CREATION_STREAM = "character_creation" -"""Stream key convention for creation draws: ability scores, first-level hp, starting gold.""" +"""The stream key every session uses for creation draws. + +A stream key names one independent random-number sequence inside an +[`RngStreams`][osrlib.core.rng.RngStreams] set. Pass +`streams.get(CHARACTER_CREATION_STREAM)` as the `stream` argument of +[`create_character`][osrlib.core.character.create_character] and of the stepwise creation +functions, which draw ability scores, the first-level hit die, and starting gold from it in that +order. + +Use a different key only when you want creation draws kept apart from the ones a session already +records, for example when you roll throwaway characters beside a live game. Pass your own key to +`streams.get`; do not change this constant, because a save replays every stream by the key that +produced it. +""" ADVANCEMENT_STREAM = "advancement" -"""Stream key convention for in-play advancement draws: level-up hit point rolls.""" +"""The stream key every session uses for in-play advancement draws. + +Pass `streams.get(ADVANCEMENT_STREAM)` as the `stream` argument of +[`level_up`][osrlib.core.classes.level_up], [`apply_xp`][osrlib.core.classes.apply_xp], and +[`drain_levels`][osrlib.core.classes.drain_levels], which roll hit dice on it. + +It is separate from +[`CHARACTER_CREATION_STREAM`][osrlib.core.character.CHARACTER_CREATION_STREAM] so that a change to +the creation rules, which would consume a different number of draws, never shifts advancement rolls +a save has already recorded. +""" ABILITY_ROLL_ORDER = ( AbilityScore.STR, @@ -94,41 +155,148 @@ AbilityScore.CON, AbilityScore.CHA, ) -"""The draw order for rolling ability scores: the SRD's listing order, always.""" +"""The order [`roll_ability_scores`][osrlib.core.character.roll_ability_scores] draws the six abilities. + +This is the SRD's listing order, and it is fixed. Read it when you are displaying rolled scores in +the order the dice came up, or when you are reproducing a draw sequence by hand from a recorded +seed. The order is part of what makes a seeded creation reproducible, so a caller cannot change it. +""" class Character(BaseModel): - """A player character. + """A player character: the stored state of one played person, and the values derived from it. + + Get one from [`create_character`][osrlib.core.character.create_character], from the stepwise + creation functions in this module, or from + [`from_document`][osrlib.core.character.Character.from_document] when reloading a save. + Construct one directly only when you already have every value, as in a test fixture. + + Put characters in a [`Party`][osrlib.crawl.party.Party] to explore with them, hand one to the + combat functions in [`osrlib.core.combat`][osrlib.core.combat] as an attacker or a target, to + [`osrlib.core.spells`][osrlib.core.spells] as a caster, and to + [`level_up`][osrlib.core.classes.level_up] or [`apply_xp`][osrlib.core.classes.apply_xp] to + advance it. A [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] exposes the same + combatant surface, so those functions take either. + + The model is mutable and validates on assignment: setting a field that breaks a rule raises + rather than storing the bad value. Validation is structural only. It checks that the six scores + are present and in range, that the level is within the class's maximum, and that current hit + points do not exceed maximum hit points. It does not check that the character was created + legally, because a finished character keeps no record of its own creation. + + Nothing derived is stored. THAC0, attack bonus, saving throws, ability modifiers, both armour + classes, literacy, and the language list are properties recomputed from the stored fields every + time you read them, so a level change or a swapped piece of armour shows up immediately and + nothing can fall out of step. - `id` defaults to `None`: entity IDs are session-scoped, assigned when the - character joins a session. `carrying_treasure` is basic encumbrance's referee judgment, - set by the game. `spell_book` holds spell ids (arcane casters only; tuple order - is acquisition order) and `memorized_spells` the prepared copies (tuple order is - memorization order and is load-bearing: casting consumes the first matching copy - and drain forgets newest-first). Spell slots stay derived — - `definition.row(level).spell_slots`, never stored — so leveling and drain - recompute them for free. + 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 + + stream = RngStreams(master_seed=2).get(CHARACTER_CREATION_STREAM) + character = create_character( + name="Rurik", + class_id="fighter", + alignment=Alignment.LAWFUL, + ruleset=Ruleset(), + stream=stream, + ).character + print(character.level, character.thac0, character.saves.death) + # 1 19 12 + character.current_hp -= 3 + print(character.current_hp, character.max_hp) + # 5 8 + ``` """ model_config = ConfigDict(validate_assignment=True, extra="ignore") id: str | None = None + """The entity id, which a [`GameSession`][osrlib.crawl.session.GameSession] assigns from its + [`IdAllocator`][osrlib.core.monsters.IdAllocator] when the character joins. `None` until then, and events fall back + to the name while it is unset. + """ + name: str = Field(min_length=1) + """The character's name. At least one character long.""" + class_id: str + """The class this character plays, like `"fighter"`. Valid ids come from [`load_classes`][osrlib.data.load_classes]; + see [the class id index][classes-index]. Read [`definition`][osrlib.core.character.Character.definition] to get the + class itself. + """ + race: str = Field(pattern=r"^[a-z][a-z0-9_]*$") + """The character's people, like `"human"` or `"dwarf"`, as a lowercase identifier. Creation copies it from the + class. No rules procedure reads it: racial abilities resolve through the class's ability tags instead, so a new race + needs no code. + """ + level: int = Field(ge=1) + """The experience level, 1 through the class's maximum.""" + xp: int = Field(ge=0) + """Experience points accumulated. Advancement compares this against the thresholds in the class's progression table; + award XP with [`apply_xp`][osrlib.core.classes.apply_xp] rather than by assigning here. + """ + scores: dict[AbilityScore, int] + """The six ability scores, each 3 through 18, keyed by [`AbilityScore`][osrlib.core.abilities.AbilityScore]. All six + must be present. + """ + alignment: Alignment + """Lawful, neutral, or chaotic. It also fixes + [`alignment_tongue`][osrlib.core.character.Character.alignment_tongue]. + """ + extra_languages: tuple[str, ...] = () + """The extra language ids a high INT granted at creation, validated by + [`validate_extra_languages`][osrlib.core.character.validate_extra_languages]. + """ + max_hp: int = Field(ge=1) + """Maximum hit points, at least 1.""" + current_hp: int = Field(ge=0) + """Current hit points, from 0 up to `max_hp`. Reaching 0 means the character has dropped. Death itself is the + `dead` condition, applied by [`kill`][osrlib.core.effects.kill], rather than a hit point value. + """ + inventory: Inventory = Field(default_factory=Inventory) + """Everything carried, worn, and wielded, plus the purse. See [`Inventory`][osrlib.core.items.Inventory] and the + buying and equipping functions in [`osrlib.core.items`][osrlib.core.items]. + """ + carrying_treasure: bool = False + """Whether the character is carrying enough treasure to slow them down. Basic encumbrance leaves the threshold to + the referee, so osrlib leaves it to you: set this flag and + [`movement_rate`][osrlib.core.character.Character.movement_rate] drops the rate a step. Ignored under the other + encumbrance modes. + """ + conditions: tuple[ActiveCondition, ...] = () + """The conditions in effect, like poisoned or paralyzed. Apply and clear them through + [`osrlib.core.effects`][osrlib.core.effects]; test one with [`has_condition`][osrlib.core.effects.has_condition]. + """ + stat_modifiers: tuple[ActiveModifier, ...] = () + """Timed bonuses and penalties from spells and items, applied by [`osrlib.core.effects`][osrlib.core.effects].""" + spell_book: tuple[str, ...] = () + """The spell ids an arcane caster can memorize from, in the order they were learned. Empty for clerics, whose spells + come from their deity, and for non-casters. + """ + memorized_spells: tuple[MemorizedSpell, ...] = () + """The prepared copies a caster may cast, as [`MemorizedSpell`][osrlib.core.spells.MemorizedSpell] records in + memorization order. The order matters: casting spends the first matching copy, and energy drain forgets the newest + first. How many of each level fit is derived from the class's progression row, not stored, so leveling up and being + drained both recompute it. + """ @field_validator("scores") @classmethod @@ -152,22 +320,41 @@ def _structurally_consistent(self) -> Character: @property def definition(self) -> ClassDefinition: - """The character's class definition, from the loaded class catalog.""" + """The class this character plays, looked up from the loaded catalog by `class_id`. + + Read it for anything that depends on the class: the progression table, the armour and weapon + policies, the class abilities, the level titles. The catalog is loaded once and cached, so + reading this property repeatedly costs nothing. + """ return load_classes().get(self.class_id) @property def thac0(self) -> int: - """THAC0 from the progression row for the current level — derived, never stored.""" + """The number the character must roll to hit armour class 0, from the current level's row. + + Descending armour class is the SRD's default presentation. Use + [`attack_bonus`][osrlib.core.character.Character.attack_bonus] instead if your front end + shows ascending armour class. The two describe the same attack. + """ return self.definition.row(self.level).thac0 @property def attack_bonus(self) -> int: - """Ascending-AC attack bonus from the progression row — derived, never stored.""" + """The bonus added to an attack roll under ascending armour class, from the current level's row. + + The ascending presentation of [`thac0`][osrlib.core.character.Character.thac0]. Both come + from the same progression row, so they always agree. + """ return self.definition.row(self.level).attack_bonus @property def saves(self) -> SavingThrows: - """Saving throws from the progression row for the current level — derived, never stored.""" + """The five saving throw targets for the current level. + + Roll a d20 against the relevant field and succeed on that number or higher. Leveling and + energy drain both change this the moment they change the level, because it is read from + the progression row rather than stored. + """ return self.definition.row(self.level).saves def _tables(self) -> AbilityTables: @@ -175,69 +362,98 @@ def _tables(self) -> AbilityTables: @property def melee_modifier(self) -> int: - """STR modifier to melee attack and damage rolls.""" + """The STR modifier added to melee attack rolls and to melee damage, from −3 to +3.""" return self._tables().melee_modifier(self.scores[AbilityScore.STR]) @property def open_doors_chance(self) -> int: - """STR-derived X-in-6 chance to force a stuck door.""" + """The chance in 6 of forcing a stuck door open, from STR. + + Roll 1d6 and succeed on this number or less. Force-door attempts in a crawl go through + [`osrlib.crawl.exploration`][osrlib.crawl.exploration], which reads this for you. + """ return self._tables().open_doors_chance(self.scores[AbilityScore.STR]) @property def missile_modifier(self) -> int: - """DEX modifier to missile attack rolls.""" + """The DEX modifier added to missile attack rolls, from −3 to +3. It does not change damage.""" return self._tables().missile_modifier(self.scores[AbilityScore.DEX]) @property def initiative_modifier(self) -> int: - """DEX modifier to individual initiative (optional rule).""" + """The DEX modifier to an individual initiative roll, from −2 to +2. + + It applies only when the `individual_initiative` flag of the + [`Ruleset`][osrlib.core.ruleset.Ruleset] is on. Group initiative, the default, rolls once + per side and ignores it. + """ return self._tables().initiative_modifier(self.scores[AbilityScore.DEX]) @property def hit_point_modifier(self) -> int: - """CON modifier per Hit Die rolled.""" + """The CON modifier added to each Hit Die rolled, from −3 to +3. + + Creation and [`level_up`][osrlib.core.classes.level_up] add it per die and floor the gain + at 1 hit point, so a poor CON never costs a character hit points outright. + """ return self._tables().hit_point_modifier(self.scores[AbilityScore.CON]) @property def magic_save_modifier(self) -> int: - """WIS modifier to saving throws versus magical effects.""" + """The WIS modifier applied to saving throws against magical effects, from −3 to +3.""" return self._tables().magic_save_modifier(self.scores[AbilityScore.WIS]) @property def npc_reaction_modifier(self) -> int: - """CHA modifier to NPC reactions.""" + """The CHA modifier applied to NPC reaction rolls, from −2 to +2. + + Reaction rolls during a crawl read it for you; see + [`osrlib.crawl.encounter`][osrlib.crawl.encounter]. + """ return self._tables().npc_reaction_modifier(self.scores[AbilityScore.CHA]) @property def literacy(self) -> Literacy: - """INT-derived literacy in the character's native languages.""" + """How well the character reads and writes, from INT. + + The three levels are illiterate, basic literacy, and full literacy; see + [`Literacy`][osrlib.core.abilities.Literacy]. It applies to the languages in + [`languages`][osrlib.core.character.Character.languages]. No rule in osrlib reads it, so + what an illiterate character may not do is your game's decision. + """ return self._tables().literacy(self.scores[AbilityScore.INT]) @property def alignment_tongue(self) -> str: - """The alignment language, derived from alignment so it can never desync. + """The secret language shared by everyone of this alignment, as a language id. - Alignment tongues are not `languages.json` entries; the derived identifier is - `alignment_` plus the alignment wire value (`alignment_lawful`). + Every character speaks the tongue of their own alignment and no other. The id is + `alignment_` followed by the alignment's wire value, so a lawful character speaks + `"alignment_lawful"`. These are not entries in the language catalog that + [`load_languages`][osrlib.data.load_languages] returns. They are derived here, so changing + a character's alignment changes the tongue in the same moment. """ return f"alignment_{self.alignment.value}" @property def languages(self) -> tuple[str, ...]: - """Every language the character speaks. + """Every language the character speaks, as ids, in a fixed order. - The alignment tongue, then the class natives (Common first, per the class - pages), then INT-granted extras. + The alignment tongue comes first, then the languages the class grants with Common at the + front, then the extras a high INT bought at creation. Compare against another speaker's + list to decide whether two people can talk to each other. The ids other than the alignment + tongue are catalog ids from [`load_languages`][osrlib.data.load_languages]; see + [the language id index][languages-index]. """ return (self.alignment_tongue, *self.definition.languages, *self.extra_languages) def _armour_parts(self, *, ascending: bool) -> tuple[int, int]: - """Return `(base, bonus)`: worn armour base AC and total shield/item bonuses. + """Return the worn armour's base armour class and the total bonus from shields and items. - Magic armour overlays its base item's AC with the enchantment bonus (the - cursed `AC 9 [10]` forms set the base outright); magic shields add their - enchantment on top of the mundane shield's +1; always-active worn items - with an AC bonus (rings of protection) join the bonus. + Magic armour adds its enchantment to the base item's printed armour class, except for the + cursed forms, which set the base outright. A magic shield adds its enchantment on top of + the +1 an ordinary shield gives. A worn item with an armour class bonus that is always + active, like a ring of protection, adds to the bonus too. """ from osrlib.core.items import ArmourTemplate, MagicItemInstance, magic_item_template from osrlib.data import load_equipment @@ -279,55 +495,136 @@ def _armour_parts(self, *, ascending: bool) -> tuple[int, int]: @property def armour_class(self) -> int: - """Descending AC: armour base (9 unarmoured), −1 per bonus, minus the DEX modifier.""" + """The character's armour class in the descending presentation, where lower is better. + + Unarmoured is 9. Worn armour sets the base, shields and always-active magic items subtract + their bonus, and the DEX modifier subtracts on top. Equipping or removing armour changes + this at once, because it is computed from the inventory rather than stored. Use + [`armour_class_ascending`][osrlib.core.character.Character.armour_class_ascending] if your + front end shows ascending armour class. + """ dex_modifier = self._tables().ac_modifier(self.scores[AbilityScore.DEX]) base, bonus = self._armour_parts(ascending=False) return base - bonus - dex_modifier @property def armour_class_ascending(self) -> int: - """Ascending AC: armour base (10 unarmoured), +1 per bonus, plus the DEX modifier.""" + """The character's armour class in the ascending presentation, where higher is better. + + Unarmoured is 10, and every bonus adds. It describes the same defense as + [`armour_class`][osrlib.core.character.Character.armour_class]. The two presentations + always agree. + """ dex_modifier = self._tables().ac_modifier(self.scores[AbilityScore.DEX]) base, bonus = self._armour_parts(ascending=True) return base + bonus + dex_modifier def movement_rate(self, ruleset: Ruleset) -> int: - """Return the movement rate in feet per turn under the ruleset's encumbrance mode. + """Return how far this character moves in one exploration turn, in feet. + + What the rate depends on is the ruleset's encumbrance mode: nothing at all under `none`, + the worn armour category and the + [`carrying_treasure`][osrlib.core.character.Character] flag under `basic`, and the total + weight carried under `detailed`. A character loaded past the maximum cannot move and gets + 0. + + A party moves at its slowest living member's rate, so for a party call + [`Party.movement_rate`][osrlib.crawl.party.Party.movement_rate] instead of calling this + per member. Args: - ruleset: The ruleset in play. + ruleset: The ruleset in play, whose encumbrance mode governs what counts. Returns: - The movement rate: 120, 90, 60, 30, or 0. + The rate in feet per turn: 120, 90, 60, 30, or 0. + + 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 + + ruleset = Ruleset() + stream = RngStreams(master_seed=2).get(CHARACTER_CREATION_STREAM) + character = create_character( + name="Rurik", + class_id="fighter", + alignment=Alignment.LAWFUL, + ruleset=ruleset, + stream=stream, + purchases=[("plate_mail", 1)], + equip_ids=["plate_mail"], + ).character + print(character.movement_rate(ruleset)) + # 60 + ``` """ return movement_rate_feet(self.inventory, ruleset, self.carrying_treasure) def to_document(self) -> dict[str, object]: - """Serialize to a stamped document with schema and engine versions. + """Return this character as a JSON-ready document stamped with its schema and engine versions. + + The stamp is what lets a later version of osrlib decide whether it can read the document. + Use this to store one character on its own, and use + [`party_to_document`][osrlib.core.character.party_to_document] for a whole party, and the + save functions in [`osrlib.persistence`][osrlib.persistence] to store a session, which already includes + its characters. + + Read it back with + [`from_document`][osrlib.core.character.Character.from_document]. Returns: - The stamped document envelope wrapping the serialized character. + The stamped envelope, whose payload is the serialized character. Every value is a + JSON type, so you can hand the result straight to + [`json.dump`][json.dump]. + + Examples: + ```python + from osrlib.core.alignment import Alignment + from osrlib.core.character import CHARACTER_CREATION_STREAM, Character, create_character + from osrlib.core.rng import RngStreams + from osrlib.core.ruleset import Ruleset + + stream = RngStreams(master_seed=2).get(CHARACTER_CREATION_STREAM) + character = create_character( + name="Rurik", + class_id="fighter", + alignment=Alignment.LAWFUL, + ruleset=Ruleset(), + stream=stream, + ).character + document = character.to_document() + print(sorted(document)) + # ['engine_version', 'kind', 'payload', 'schema_version'] + print(Character.from_document(document).name) + # Rurik + ``` """ return stamp_document("character", self.model_dump(mode="json")) @classmethod def from_document(cls, document: Mapping[str, object]) -> Character: - """Load a character from a stamped document. + """Rebuild a character from a document written by [`to_document`][osrlib.core.character.Character.to_document]. - Unknown payload fields are ignored, per the additive-schema contract. + Fields in the payload that this version does not recognize are ignored, so a document written + by a later release that only added fields still loads. A document whose schema version is + newer than this release understands is refused instead, because the meaning of what it + does know may have changed. Args: - document: A document produced by - [`to_document`][osrlib.core.character.Character.to_document]. + document: The stamped envelope, as returned by + [`to_document`][osrlib.core.character.Character.to_document] or read back from + JSON. Returns: - The reconstructed character. + The character, with every derived value recomputed from the loaded state. 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. + ContentValidationError: If the envelope is not a character document, or the payload + does not validate as a character. + SaveVersionError: If the document's schema version is newer than this library + understands. """ payload = check_document(document, "character") try: @@ -337,35 +634,73 @@ def from_document(cls, document: Mapping[str, object]) -> Character: def party_to_document(characters: Sequence[Character]) -> dict[str, object]: - """Serialize a party — a stamped collection of characters. + """Return a group of characters as one JSON-ready document, stamped with its versions. + + Use this to store a roster you build once and reuse, like a set of pre-generated characters + a front end offers at the start of a game. It writes the characters and nothing else: marching + order, shared light sources, and the rest of a playing party's state belong to + [`Party`][osrlib.crawl.party.Party] and are saved with the session by + [`osrlib.persistence`][osrlib.persistence]. - The crawl-layer party model (marching order, shared resources) is - [`Party`][osrlib.crawl.party.Party]; this is the kernel's party-as-collection. + Read it back with + [`party_from_document`][osrlib.core.character.party_from_document], which returns the + characters in the order you passed them. Args: - characters: The party members, in order. + characters: The characters to store, in the order you want them back. Returns: - The stamped document envelope wrapping the serialized characters. + The stamped envelope, whose payload contains the serialized characters under + `"characters"`. Every value is a JSON type. + + Examples: + ```python + from osrlib.core.alignment import Alignment + from osrlib.core.character import ( + CHARACTER_CREATION_STREAM, + create_character, + party_from_document, + party_to_document, + ) + from osrlib.core.rng import RngStreams + from osrlib.core.ruleset import Ruleset + + stream = RngStreams(master_seed=2).get(CHARACTER_CREATION_STREAM) + roster = [ + create_character( + name=name, + class_id="fighter", + alignment=Alignment.LAWFUL, + ruleset=Ruleset(), + stream=stream, + ).character + for name in ("Rurik", "Alia") + ] + document = party_to_document(roster) + print([member.name for member in party_from_document(document)]) + # ['Rurik', 'Alia'] + ``` """ return stamp_document("party", {"characters": [character.model_dump(mode="json") for character in characters]}) def party_from_document(document: Mapping[str, object]) -> list[Character]: - """Load a party from a stamped document. + """Rebuild the characters in a document written by [`party_to_document`][osrlib.core.character.party_to_document]. + + Each character is validated on the way in, so a malformed member fails the whole load rather + than producing a half-built roster. Args: - document: A document produced by - [`party_to_document`][osrlib.core.character.party_to_document]. + document: The stamped envelope, as returned by + [`party_to_document`][osrlib.core.character.party_to_document] or read back from JSON. Returns: - The reconstructed characters, in order. + The characters, in the order they were stored. 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. + ContentValidationError: If the envelope is not a party document, if its payload contains no + `"characters"` list, or if a member does not validate as a character. + SaveVersionError: If the document's schema version is newer than this library understands. """ payload = check_document(document, "party") characters = payload.get("characters") @@ -381,46 +716,103 @@ def party_from_document(document: Mapping[str, object]) -> list[Character]: class AbilityScoreRolls(BaseModel): - """The rolled score set, with each score's raw 3d6 kept for display.""" + """The six rolled ability scores, with the individual dice kept so you can show them. + + [`roll_ability_scores`][osrlib.core.character.roll_ability_scores] returns this, and + [`create_character`][osrlib.core.character.create_character] includes it in its result. Frozen: + a roll is history and does not change. + """ model_config = ConfigDict(frozen=True) scores: dict[AbilityScore, int] + """The total of each ability's three dice, keyed by [`AbilityScore`][osrlib.core.abilities.AbilityScore]. All six + are present, each 3 through 18. + """ + rolls: dict[AbilityScore, tuple[int, int, int]] + """The three raw d6 results behind each score, in the order they were rolled, so a character sheet can show the dice + a player watched come up. + """ class HitPointRoll(BaseModel): - """A first-level hit point roll: every raw die (re-rolls included) and the final total.""" + """A first-level hit point roll: every die that was thrown, and the total it came to. + + [`roll_hit_points`][osrlib.core.character.roll_hit_points] returns this. Frozen. + """ model_config = ConfigDict(frozen=True) rolls: tuple[int, ...] + """Every raw die result, in the order thrown. With the `hp_reroll_at_first_level` option on, the rejected 1s and 2s + are here too, and the last entry is the die that stood. + """ + hit_points: int = Field(ge=1) + """The hit points the character starts with: the die that stood plus the CON modifier, floored at 1.""" class CharacterCreationResult(BaseModel): - """A created character plus the raw rolls creation consumed, for display.""" + """What [`create_character`][osrlib.core.character.create_character] returns: the character and its dice. + + The rolls are here so a front end can show a player how their character came out rather than + only the finished numbers. Frozen. + """ model_config = ConfigDict(frozen=True) character: Character + """The finished [`Character`][osrlib.core.character.Character], at first level with its purchases bought and its + equipment worn. + """ + ability_rolls: AbilityScoreRolls + """The ability scores as rolled, before any adjustment the caller asked for. Compare against `character.scores` to + show what the adjustment moved. + """ + hit_point_roll: HitPointRoll + """The first-level hit die, or dice if the re-roll option was on.""" + gold_roll: RollResult + """The 3d6 × 10 starting money roll. Its `total` is the gold the character began with, before the purchases were + paid for. + """ def roll_ability_scores(stream: RngStream) -> AbilityScoreRolls: - """Roll 3d6 for each ability, drawn in the SRD's order STR INT WIS DEX CON CHA. + """Roll 3d6 for each of the six abilities, in the SRD's order: STR, INT, WIS, DEX, CON, CHA. + + This is the first step of creating a character by hand. Show the result to the player, then + pass the scores to [`validate_class_choice`][osrlib.core.character.validate_class_choice] to + find out which classes they qualify for. If the player wants to trade points between abilities, + run [`apply_adjustment`][osrlib.core.abilities.apply_adjustment] after the class is chosen, the + order the SRD sets. - The draw order is fixed: it's part of the determinism contract for the - [`CHARACTER_CREATION_STREAM`][osrlib.core.character.CHARACTER_CREATION_STREAM] - convention. + Call [`create_character`][osrlib.core.character.create_character] instead when the choices are + already known and you only want the finished character. + + The draw order never changes. It is what makes a seeded creation reproducible, so the same + stream at the same position always yields the same six scores. Args: - stream: The RNG stream to draw from. + stream: The stream to draw from, conventionally + `streams.get(CHARACTER_CREATION_STREAM)`. Eighteen draws are consumed. Returns: - The rolled scores and each score's individual dice. + The six scores and the three dice behind each. + + Examples: + ```python + from osrlib.core.character import CHARACTER_CREATION_STREAM, roll_ability_scores + from osrlib.core.rng import RngStreams + + stream = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM) + rolled = roll_ability_scores(stream) + print({ability.value: score for ability, score in rolled.scores.items()}) + # {'str': 14, 'int': 6, 'wis': 9, 'dex': 10, 'con': 11, 'cha': 11} + ``` """ scores: dict[AbilityScore, int] = {} rolls: dict[AbilityScore, tuple[int, int, int]] = {} @@ -432,19 +824,46 @@ def roll_ability_scores(stream: RngStream) -> AbilityScoreRolls: def validate_class_choice(scores: dict[AbilityScore, int], definition: ClassDefinition) -> list[Rejection]: - """Validate a class choice against the class's minimum score requirements. + """Check whether a set of rolled scores meets a class's minimum requirements. + + Call it after [`roll_ability_scores`][osrlib.core.character.roll_ability_scores] and before the + ability adjustment, which is the order the SRD sets: a player picks a class they qualify for, + then trades points. Run it over every class in + [`load_classes`][osrlib.data.load_classes]`().classes` to build the list of classes to offer. + + It reports rather than raises, so you can show a player why a class is closed to them. Each + failure names the ability, the minimum the class requires, and the score that fell short. The + demi-human classes are the ones with requirements: the dwarf and the halfling require CON 9, the + elf requires INT 9, and the halfling also requires DEX 9. + + Checking before adjustment is safe for the Classic classes, because the adjustment step can + only lower STR, INT, and WIS and never below 9, which is every requirement's minimum. A class + added later with a higher minimum on a lowerable ability would need a second check after the + adjustment. Args: - scores: The rolled scores. Requirements are checked before adjustment, - mirroring the SRD's step order (choose class, then adjust). For Classic - data the adjustment step can never break them — every requirement minimum - is 9, only STR, INT, and WIS may be lowered, and never below 9 — but an - Advanced class with a higher minimum on a lowerable non-prime ability - would need a re-check after adjustment. - definition: The chosen class. + scores: The rolled scores, before any adjustment. + definition: The class the player chose, from + [`load_classes`][osrlib.data.load_classes]`().get(class_id)`. Returns: - Structured rejections; empty when the choice is legal. + One [`Rejection`][osrlib.core.validation.Rejection] per unmet requirement, empty when the + class is open to these scores. + + Examples: + ```python + from osrlib.core.abilities import AbilityScore + from osrlib.core.character import validate_class_choice + from osrlib.data import load_classes + + scores = dict.fromkeys(AbilityScore, 12) + scores[AbilityScore.CON] = 7 + rejections = validate_class_choice(scores, load_classes().get("dwarf")) + print([(rejection.code, rejection.params) for rejection in rejections]) + # [('creation.class.requirements_not_met', {'class': 'dwarf', 'ability': 'con', 'minimum': 9, 'score': 7})] + print(validate_class_choice(scores, load_classes().get("fighter"))) + # [] + ``` """ rejections: list[Rejection] = [] for ability, minimum in definition.requirements.items(): @@ -461,20 +880,49 @@ def validate_class_choice(scores: dict[AbilityScore, int], definition: ClassDefi def roll_hit_points( definition: ClassDefinition, con_modifier: int, ruleset: Ruleset, stream: RngStream ) -> HitPointRoll: - """Roll first-level hit points: the class hit die plus the CON modifier, minimum 1. + """Roll a first-level character's hit points: the class hit die plus the CON modifier, at least 1. + + Call it after the class is chosen and the scores are adjusted, so the CON modifier you pass is + the final one. Get that modifier from + [`load_ability_tables`][osrlib.data.load_ability_tables]`().hit_point_modifier(score)`, or read + [`hit_point_modifier`][osrlib.core.character.Character.hit_point_modifier] off a character that + already exists. - With the `hp_reroll_at_first_level` flag on, the die is re-rolled while the raw - die shows 1–2 (before the CON modifier), each re-roll consuming a draw — osrlib's - reading of the SRD's "re-rolling 1s and 2s". + Use [`level_up`][osrlib.core.classes.level_up] for hit points gained later. This function is + for first level only, and it is the only one that honours the re-roll option. + + With `hp_reroll_at_first_level` on in the ruleset, a die showing 1 or 2 is thrown again, and + again, until it shows 3 or more. The test is on the raw die, before the CON modifier, and every + throw is kept in the result. Args: - definition: The character's class. - con_modifier: The CON hit point modifier for the (adjusted) scores. - ruleset: The ruleset in play. - stream: The RNG stream to draw from. + definition: The character's class, whose progression row gives the hit die. + con_modifier: The CON hit point modifier for the final scores. It may be negative, and the + total is floored at 1 either way. + ruleset: The ruleset in play, read for the `hp_reroll_at_first_level` option. + stream: The stream to draw from, conventionally + `streams.get(CHARACTER_CREATION_STREAM)`. Returns: - Every raw die rolled and the final hit point total. + Every die thrown and the hit points to start with. + + Examples: + ```python + from osrlib.core.character import CHARACTER_CREATION_STREAM, roll_hit_points + from osrlib.core.rng import RngStreams + from osrlib.core.ruleset import Ruleset + from osrlib.data import load_classes + + fighter = load_classes().get("fighter") + stream = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM) + print(roll_hit_points(fighter, 0, Ruleset(), stream)) + # rolls=(2,) hit_points=2 + + rerolling = Ruleset(hp_reroll_at_first_level=True) + stream = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM) + print(roll_hit_points(fighter, 0, rerolling, stream)) + # rolls=(2, 8) hit_points=8 + ``` """ die = definition.row(1).hit_dice.die rolls = [stream.randbelow(die) + 1] @@ -485,21 +933,44 @@ def roll_hit_points( def validate_extra_languages(definition: ClassDefinition, int_score: int, choices: Sequence[str]) -> list[Rejection]: - """Validate INT-granted extra language choices. + """Check the extra languages a high INT lets a character pick. - Extras must come from the SRD's Other Languages table (the twenty choosable - languages), may not duplicate a class native, may not repeat, and may not exceed - the INT table's additional-languages allowance. + A character speaks their alignment tongue and whatever their class grants for free. An INT of + 13 or more buys extra languages on top, one to three of them. An INT of 12 or less buys none. + Ask + [`load_ability_tables`][osrlib.data.load_ability_tables]`().additional_languages(int_score)` + how many the player may take, offer the choosable entries from + [`load_languages`][osrlib.data.load_languages], then check the picks here before storing them + on [`extra_languages`][osrlib.core.character.Character]. + + A pick is refused when it is not a choosable language, when it repeats another pick, when the + class already grants it, or when the player took more than the score allows. Each refusal + names the language, so you can show the player which pick to change. Args: - definition: The chosen class, whose natives the choices may not duplicate. - int_score: The character's (adjusted) INT score. - choices: The chosen extra language ids, from - [`load_languages`][osrlib.data.load_languages] — see + definition: The chosen class. Its own languages may not be taken again as extras. + int_score: The final INT score, after any adjustment. + choices: The chosen language ids, from [`load_languages`][osrlib.data.load_languages]; see [the language id index][languages-index]. Returns: - Structured rejections; empty when the choices are legal. + One [`Rejection`][osrlib.core.validation.Rejection] per problem, empty when every pick + stands. + + Examples: + ```python + from osrlib.core.character import validate_extra_languages + from osrlib.data import load_ability_tables, load_classes + + fighter = load_classes().get("fighter") + print(load_ability_tables().additional_languages(13)) + # 1 + print(validate_extra_languages(fighter, 13, ["elvish"])) + # [] + rejections = validate_extra_languages(fighter, 9, ["elvish"]) + print([(rejection.code, rejection.params) for rejection in rejections]) + # [('creation.languages.too_many', {'allowed': 0, 'chosen': 1})] + ``` """ rejections: list[Rejection] = [] allowed = load_ability_tables().additional_languages(int_score) @@ -525,25 +996,46 @@ def validate_extra_languages(definition: ClassDefinition, int_score: int, choice def validate_starting_spells( definition: ClassDefinition, catalog: SpellCatalog, spell_ids: Sequence[str] ) -> list[Rejection]: - """Validate a starting spell-book choice against class and capacity rules. + """Check a starting spell book against what the class may have at first level. - Arcane casters "begin play with as many spells in their spell book as they are - able to memorize" (the OSE SRD's spell books rules) — the per-level counts must - equal the level-1 slot counts exactly, which for both magic-user and elf means one - first-level spell. The caller supplies the choice: "The referee may choose these - spells or may allow the player to select" — the game owns the decision, the - kernel validates it. Clerics (and non-casters) start with nothing: any selection - for them is rejected. + An arcane caster, which in the Classic classes means the magic-user and the elf, begins play + with as many spells written in the book as they can memorize, so at first level that is exactly + one first-level spell. Offer the player the first-level spells of their class's list from + [`load_spells`][osrlib.data.load_spells], check the pick here, then write it with + [`choose_starting_spells`][osrlib.core.character.choose_starting_spells]. Whether the player or + the referee picks is the game's decision. This function only says whether a pick is legal. + + A pick is refused when the spell id is unknown, when it repeats, when it belongs to the other + spell list, or when the number chosen at any level does not match the capacity exactly. Too few + is refused as well as too many, because the SRD gives a starting book a fixed size. A cleric + starts with no book at all, since clerical spells come from a deity rather than from writing, + so any pick for a cleric or for a non-caster is refused outright. Args: definition: The character's class. - catalog: The loaded spell catalog. - spell_ids: The chosen spell ids, from - [`load_spells`][osrlib.data.load_spells] — see - [the spell id index][spells-index]. + catalog: The spell catalog from [`load_spells`][osrlib.data.load_spells]. + spell_ids: The chosen spell ids; see [the spell id index][spells-index]. Returns: - Structured rejections; empty when the choice is legal. + One [`Rejection`][osrlib.core.validation.Rejection] per problem, empty when the book is + legal. + + Examples: + ```python + from osrlib.core.character import validate_starting_spells + from osrlib.data import load_classes, load_spells + + catalog = load_spells() + magic_user = load_classes().get("magic_user") + print(validate_starting_spells(magic_user, catalog, ["sleep"])) + # [] + rejections = validate_starting_spells(magic_user, catalog, ["sleep", "magic_missile"]) + print([(rejection.code, rejection.params) for rejection in rejections]) + # [('magic.book.capacity_mismatch', {'spell_level': 1, 'capacity': 1, 'chosen': 2})] + cleric = load_classes().get("cleric") + print([rejection.code for rejection in validate_starting_spells(cleric, catalog, ["cure_light_wounds"])]) + # ['magic.book.not_arcane'] + ``` """ profile = caster_profile(definition) if profile is None or profile.kind != "arcane": @@ -586,23 +1078,59 @@ def validate_starting_spells( def choose_starting_spells( character: Character, definition: ClassDefinition, catalog: SpellCatalog, spell_ids: Sequence[str] ) -> list[Rejection]: - """Fill an arcane caster's starting spell book — the last creation step. + """Write an arcane caster's starting spell book onto the character. - The stepwise creation surface: validates the choice (see - [`validate_starting_spells`][osrlib.core.character.validate_starting_spells]) and - a still-empty book, then writes it. Creation stays event-less like every other - creation step. + Use it when you are creating a character step by step and the player has picked their first + spell. It checks the pick with + [`validate_starting_spells`][osrlib.core.character.validate_starting_spells] and refuses a + character whose book is already written, then stores the ids on + [`spell_book`][osrlib.core.character.Character]. Nothing is written when anything is refused, + so the character is never left half-changed. + [`create_character`][osrlib.core.character.create_character] does this for you when you pass + `starting_spell_ids`. + + A written book is not yet a memorized spell. To prepare spells for casting, go through + [`osrlib.core.spells`][osrlib.core.spells], which fills the character's memorization slots from + the book. Args: - character: The created character; its `spell_book` is written. + character: The character to write to. Mutated in place when the pick is legal. definition: The character's class. - catalog: The loaded spell catalog. - spell_ids: The chosen spell ids, from - [`load_spells`][osrlib.data.load_spells] — see - [the spell id index][spells-index]. + catalog: The spell catalog from [`load_spells`][osrlib.data.load_spells]. + spell_ids: The chosen spell ids; see [the spell id index][spells-index]. Returns: - Structured rejections; empty when the book was written. + One [`Rejection`][osrlib.core.validation.Rejection] per problem, empty when the book was + written. + + Examples: + ```python + from osrlib.core.alignment import Alignment + from osrlib.core.character import ( + CHARACTER_CREATION_STREAM, + choose_starting_spells, + create_character, + ) + from osrlib.core.rng import RngStreams + from osrlib.core.ruleset import Ruleset + from osrlib.data import load_classes, load_spells + + magic_user = load_classes().get("magic_user") + stream = RngStreams(master_seed=3).get(CHARACTER_CREATION_STREAM) + character = create_character( + name="Miri", + class_id="magic_user", + alignment=Alignment.NEUTRAL, + ruleset=Ruleset(), + stream=stream, + starting_spell_ids=["sleep"], + ).character + rejections = choose_starting_spells(character, magic_user, load_spells(), ["magic_missile"]) + print([rejection.code for rejection in rejections]) + # ['magic.book.already_chosen'] + print(character.spell_book) + # ('sleep',) + ``` """ if character.spell_book: return [Rejection(code="magic.book.already_chosen", params={"character": character.name})] @@ -614,13 +1142,33 @@ def choose_starting_spells( def roll_starting_gold(stream: RngStream) -> RollResult: - """Roll starting money: 3d6 × 10 gold pieces, via the dice grammar. + """Roll a new character's starting money: 3d6 × 10 gold pieces. + + This is the last draw of creation. Put the total into the character's purse + (`character.inventory.purse.gp`), then spend it with + [`validate_purchase`][osrlib.core.items.validate_purchase] and + [`purchase`][osrlib.core.items.purchase] from the equipment catalog. + [`create_character`][osrlib.core.character.create_character] does all of that for you when you + pass `purchases`. Args: - stream: The RNG stream to draw from. + stream: The stream to draw from, conventionally + `streams.get(CHARACTER_CREATION_STREAM)`. Three draws are consumed. Returns: - The roll, whose total is the starting gold in gp. + The [`RollResult`][osrlib.core.dice.RollResult], whose `total` is the starting gold in gold + pieces, between 30 and 180. + + Examples: + ```python + from osrlib.core.character import CHARACTER_CREATION_STREAM, roll_starting_gold + from osrlib.core.rng import RngStreams + + stream = RngStreams(master_seed=11).get(CHARACTER_CREATION_STREAM) + rolled = roll_starting_gold(stream) + print(rolled.rolls, rolled.total) + # (3, 1, 3) 70 + ``` """ return roll("3d6×10", stream) @@ -638,46 +1186,69 @@ def create_character( purchases: Sequence[tuple[str, int]] = (), equip_ids: Sequence[str] = (), ) -> CharacterCreationResult: - """Create a 1st-level character with all decisions supplied upfront. - - A convenience for scripts and tests: it calls the same stepwise creation functions - used for interactive play, in the SRD's order — roll scores, validate the class - choice, adjust scores, choose the spell book (the SRD's step 6, before hit points; - it consumes no draws), roll hit points, validate languages, roll starting gold, buy - and equip — drawing scores, hit points, and gold from `stream` in that fixed order. + """Create a first-level character, making every choice you pass in one call. + + This is where a new caller starts. Give it a name, a class, an alignment, a ruleset, and a + seeded stream, and it rolls a whole character: ability scores, hit points, starting gold, and + the gear you asked it to buy. Put the characters you get into a + [`Party`][osrlib.crawl.party.Party] and you have something to play with. + + To get a stream, build an [`RngStreams`][osrlib.core.rng.RngStreams] set from a seed and ask it + for the creation stream: `RngStreams(master_seed=2).get(CHARACTER_CREATION_STREAM)`. The same + seed always produces the same character, which is what makes a game replayable and a test + repeatable. Pass the same stream to several calls to roll a whole party from one seed, and each + call continues where the last one left off. + + Every decision is yours to supply, because the same call has to serve a player picking from a + menu and a script rolling a hundred characters. Drive the stepwise functions in this module + instead when a person is choosing as they go: those hand back + [`Rejection`][osrlib.core.validation.Rejection] records you can show, where this function + raises on the first illegal choice and reports nothing more. + + The steps run in the SRD's order: roll the six scores, check them against the class + requirements, apply the ability adjustment, write the spell book, roll hit points, check the + extra languages, roll starting gold, then buy and equip. Draws come off the stream in that + order, so scores are always drawn first and gold last. Writing the spell book and checking + languages consume no draws. Args: name: The character's name. - class_id: The chosen class id, from [`load_classes`][osrlib.data.load_classes] - — see [the class id index][classes-index]. - alignment: The chosen alignment. - ruleset: The ruleset in play. - stream: The RNG stream for creation draws, conventionally - [`CHARACTER_CREATION_STREAM`][osrlib.core.character.CHARACTER_CREATION_STREAM]. - adjustment: The optional ability score adjustment. - starting_spell_ids: The arcane starting spell book: ids from - [`load_spells`][osrlib.data.load_spells] — see - [the spell id index][spells-index]. Must be exactly the level-1 - memorization capacity (one first-level spell for magic-user and elf). - extra_languages: INT-granted extra language choices: ids from - [`load_languages`][osrlib.data.load_languages] — see - [the language id index][languages-index]. - purchases: `(item_id, lots)` pairs bought in order from the starting gold. - `item_id` is from [`load_equipment`][osrlib.data.load_equipment] — see - [the equipment index][equipment-index]. `lots` is how many of the - catalog's unit of sale to buy: weapons and armour sell one at a time, so - `lots` is the quantity bought, while gear and ammunition sell in - fixed-size lots (one lot of torches is six torches). - equip_ids: Item ids to equip after purchase, in order. + class_id: The class to play, like `"fighter"`, from + [`load_classes`][osrlib.data.load_classes]; see [the class id index][classes-index]. + alignment: Lawful, neutral, or chaotic. + ruleset: The ruleset in play. Its `hp_reroll_at_first_level` flag governs whether a poor + hit die is thrown again. + stream: The stream for the creation draws, conventionally + `streams.get(`[`CHARACTER_CREATION_STREAM`][osrlib.core.character.CHARACTER_CREATION_STREAM]`)`. + adjustment: An optional trade of points between abilities, built as an + [`AbilityAdjustment`][osrlib.core.abilities.AbilityAdjustment]. It lowers one or more + of STR, INT, and WIS to raise a prime requisite, which is how a player buys a better + experience bonus. + starting_spell_ids: The spells written in an arcane caster's book, from + [`load_spells`][osrlib.data.load_spells]; see [the spell id index][spells-index]. It + must contain exactly what the class can memorize at first level, which is one first-level + spell for both the magic-user and the elf. Leave it empty for every other class. + extra_languages: The extra languages a high INT bought, from + [`load_languages`][osrlib.data.load_languages]; see + [the language id index][languages-index]. An INT of 12 or less allows none. + purchases: What to buy from the starting gold, as `(item_id, lots)` pairs bought in the + order given. `item_id` comes from [`load_equipment`][osrlib.data.load_equipment]; see + [the equipment index][equipment-index]. A lot is the catalog's unit of sale: weapons + and armour sell one at a time, so `lots` is how many, while gear and ammunition + sell in fixed bundles, and one lot of torches is six torches. + equip_ids: Which of the bought items to wear or wield, in order. An item must have been + bought first, and the class's armour and weapon policies must allow it. Returns: - The created character and the raw creation rolls. + The finished character together with the dice creation rolled, so you can show a player how + they came out. Raises: - ValueError: If any decision is illegal for the rolled scores — unknown ids, a - failed class requirement, an illegal adjustment, spell choices, language - choices, an unaffordable purchase, or a forbidden equip. Callers wanting - structured reasons drive the stepwise functions themselves. + ValueError: On the first illegal decision: an unknown class, spell, language, or item id; + scores that miss the class requirements; an adjustment the class forbids; a spell book + of the wrong size; more languages than the INT allows; a purchase the starting gold + cannot cover; or an item the class may not equip. Drive the stepwise functions yourself + when you need to know which choices failed and why. Examples: ```python @@ -685,6 +1256,7 @@ def create_character( 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.party import Party streams = RngStreams(master_seed=2) stream = streams.get(CHARACTER_CREATION_STREAM) @@ -698,12 +1270,16 @@ def create_character( equip_ids=["sword", "leather"], ) character = result.character - assert character.class_id == "fighter" - assert character.level == 1 - assert character.max_hp == character.current_hp == 8 - assert character.armour_class == 6 - assert character.inventory.worn_armour.template.id == "leather" - assert character.inventory.purse.gp == 30 + print(character.name, character.level, character.max_hp, character.armour_class) + # Rurik 1 8 6 + print(character.inventory.worn_armour.template.id, character.inventory.purse.gp) + # leather 30 + print(result.gold_roll.total, result.hit_point_roll.rolls) + # 60 (7,) + + party = Party(members=[character]) + print(party.movement_rate(Ruleset())) + # 90 ``` """ definition = load_classes().get(class_id) diff --git a/src/osrlib/core/classes.py b/src/osrlib/core/classes.py index a34b3f3..ec0d5e9 100644 --- a/src/osrlib/core/classes.py +++ b/src/osrlib/core/classes.py @@ -1,32 +1,61 @@ """Class definitions, level progression, XP awards, and leveling up. -The seven Classic classes compile from the OSE SRD's class pages into -[`ClassDefinition`][osrlib.core.classes.ClassDefinition] models, loaded frozen via -[`load_classes`][osrlib.data.load_classes]. A definition is pure data: requirements, -prime requisites, XP-modifier tiers, a per-level progression table (hit dice, THAC0, -saves, and spell-slot capacity), armour and weapon policies, and structured ability -tags — so an Advanced class is additive data rather than a code change. `race` is an -open, validated identity string, populated from the class in Classic play — no rules -procedure consumes it, so a new race is data too. - -XP-modifier tiers are one uniform representation for every class: ordered tiers of -`{modifier_pct, minimum scores}`, evaluated best-first — the first tier whose minimum -scores all hold wins. osrlib reads this as the standard single-prime-requisite table's -intended behavior: its penalty rows only make sense under first-match evaluation. Elf -and halfling carry exactly their stated bonus tiers, which per RAW include no penalty -rows, as a documented adaptation (see the adaptations register). - -Saves, THAC0, and spell slots are always read from the progression row for the -character's level, never stored as separate derived fields — -[`ClassDefinition.row`][osrlib.core.classes.ClassDefinition.row] is the pure -recompute-from-level lookup whose inverse is energy drain -([`drain_levels`][osrlib.core.classes.drain_levels]). - -Advancement lives here too: [`apply_xp`][osrlib.core.classes.apply_xp] awards XP and -levels a character up when a threshold is crossed, -[`level_up`][osrlib.core.classes.level_up] performs the level-up roll directly, and -[`drain_levels`][osrlib.core.classes.drain_levels] reverses it. Character creation -itself lives in [`osrlib.core.character`][osrlib.core.character]. +[`load_classes`][osrlib.data.load_classes] gives you the catalog of playable classes as frozen +[`ClassDefinition`][osrlib.core.classes.ClassDefinition] models, compiled from the OSE SRD's class +pages. Look one up by id with [`ClassCatalog.get`][osrlib.core.classes.ClassCatalog.get]; see +[the class id index][classes-index] for the ids. Hand the definition you get to +[`create_character`][osrlib.core.character.create_character], and afterwards read it back off a +character through [`definition`][osrlib.core.character.Character.definition]. + +A definition is a frozen template, and a character is the mutable state of one person playing it. +Nothing in play ever writes to a definition. It contains the ability requirements, the prime requisites, the +experience-modifier tiers, a row per level with hit dice, THAC0, saving throws, and spell capacity, +the armour and weapon policies, and the class's abilities as tags the rules procedures read. All of +it is data, so a class the SRD did not print is a data file rather than a code change. + +Nothing a character derives from its class is stored on the character. Read the progression row for +the current level with [`ClassDefinition.row`][osrlib.core.classes.ClassDefinition.row] and you +always get the values that match, which is why leveling up and being drained of levels both need +only change the level. + +Advancement lives here. [`apply_xp`][osrlib.core.classes.apply_xp] is the one you usually want: it +applies the class's experience modifier, adds the award, and levels the character up when a +threshold is crossed. [`level_up`][osrlib.core.classes.level_up] does the level gain on its own +when the game hands out a level directly, and [`drain_levels`][osrlib.core.classes.drain_levels] +reverses it for the undead that drain levels. Creating a character in the first place is +[`osrlib.core.character`][osrlib.core.character]. + +Two other procedures read class data, so they live here too: +[`thief_skill_check`][osrlib.core.classes.thief_skill_check] rolls the thief's skills, and +[`detection_check`][osrlib.core.classes.detection_check] with +[`detection_chance`][osrlib.core.classes.detection_chance] rolls the chance-in-6 checks for +listening at doors, finding secret doors, and spotting traps. + +Typical usage: + +```python +from osrlib.core.alignment import Alignment +from osrlib.core.character import ADVANCEMENT_STREAM, CHARACTER_CREATION_STREAM, create_character +from osrlib.core.classes import apply_xp, level_title +from osrlib.core.rng import RngStreams +from osrlib.core.ruleset import Ruleset +from osrlib.data import load_classes + +streams = RngStreams(master_seed=2) +fighter = load_classes().get("fighter") +character = create_character( + name="Rurik", + class_id="fighter", + alignment=Alignment.LAWFUL, + ruleset=Ruleset(), + stream=streams.get(CHARACTER_CREATION_STREAM), +).character +result = apply_xp(character, fighter, 2500, streams.get(ADVANCEMENT_STREAM)) +print(result.level_before, result.level_after, character.max_hp) +# 1 2 10 +print(level_title(fighter, character.level), character.thac0, character.saves.death) +# Warrior 19 12 +``` """ from enum import StrEnum @@ -80,22 +109,48 @@ "open_locks", "pick_pockets", ) -"""The six d% roll-under thief skills; `hear_noise` is the seventh, rolled on 1d6.""" +"""The names of the six thief skills rolled on percentile dice. + +Pass any of these as the `skill` argument of +[`thief_skill_check`][osrlib.core.classes.thief_skill_check], which rolls d% and succeeds on a +result at or under the level's chance. The thief's seventh skill, `"hear_noise"`, is not here +because it rolls 1d6 instead. That function takes it too. + +Iterate this tuple to show a thief's whole percentile skill list, reading each level's numbers off +[`ThiefSkillRow`][osrlib.core.classes.ThiefSkillRow] by the same names. +""" class HitDice(BaseModel): - """A progression row's Hit Dice: `count` dice of `die` sides plus a flat `bonus`. + """How many hit dice a class rolls at one level, and of what size. - Above name level the SRD notates flat bonuses with an asterisk (`9d8+2*`) meaning - CON modifiers no longer apply — the asterisk is data, carried as `con_applies`. + Read it off [`ProgressionRow.hit_dice`][osrlib.core.classes.ProgressionRow]. + [`level_up`][osrlib.core.classes.level_up] and + [`drain_levels`][osrlib.core.classes.drain_levels] compare this level's row against the next + one to decide whether a level change rolls a die or moves a flat bonus. Frozen. + + A class stops gaining dice at name level and gains a flat number of hit points per level after + that. The SRD marks those levels with an asterisk, as in `9d8+2*`, meaning the CON modifier no + longer applies to the gain. """ model_config = ConfigDict(frozen=True) count: int = Field(ge=1) + """How many dice are rolled. At least 1.""" + die: int + """The size of each die, which is the class's hit die: d4 for the magic-user and thief, d6 for the cleric, elf, and + halfling, d8 for the dwarf and fighter. + """ + bonus: int = Field(default=0, ge=0) + """Flat hit points added on top of the dice, which is how levels past name level grow. Never negative.""" + con_applies: bool = True + """Whether the CON modifier applies to a die gained at this level. False above name level, where the gain is the + flat bonus and CON has stopped counting. + """ @model_validator(mode="after") def _die_must_be_rollable(self) -> HitDice: @@ -105,43 +160,89 @@ def _die_must_be_rollable(self) -> HitDice: class SavingThrows(BaseModel): - """The five save values: death/poison, wands, paralysis/petrify, breath, spells/rods/staves.""" + """The five saving throw target numbers. + + Roll 1d20 against the field that matches the threat and succeed on that number or higher, so + lower is better. Read a character's current set from + [`Character.saves`][osrlib.core.character.Character] or a monster's from + [`MonsterInstance.saves`][osrlib.core.monsters.MonsterInstance]. The saving-throw procedures in + [`osrlib.core.combat`][osrlib.core.combat] read them for you. Frozen. + + """ model_config = ConfigDict(frozen=True) death: int = Field(ge=2, le=20) + """Against death rays and poison, the deadliest category.""" + wands: int = Field(ge=2, le=20) + """Against the effects of magic wands.""" + paralysis: int = Field(ge=2, le=20) + """Against paralysis and turning to stone.""" + breath: int = Field(ge=2, le=20) + """Against a dragon's or other creature's breath attack.""" + spells: int = Field(ge=2, le=20) + """Against spells, magic rods, and staves.""" class ProgressionRow(BaseModel): - """One level of a class progression table, exactly as the SRD prints it. + """Everything a class is at one level: the experience it costs, and what it grants. - THAC0 is dual-format in the SRD (`19 [0]`); both the descending value and the - bracketed attack bonus are carried. `spell_slots[i]` is the number of memorizable - spells of spell level `i + 1`; the tuple is empty for non-casters. + Get one from [`ClassDefinition.row`][osrlib.core.classes.ClassDefinition.row] for the level you + care about. This is where a character's THAC0, attack bonus, saving throws, and spell capacity + come from, recomputed from the level every time rather than stored, which is why + [`level_up`][osrlib.core.classes.level_up] and + [`drain_levels`][osrlib.core.classes.drain_levels] need only change the level. Frozen. """ model_config = ConfigDict(frozen=True) level: int = Field(ge=1) + """The level this row describes, counting from 1.""" + xp: int = Field(ge=0) + """The experience points needed to reach this level. Level 1 is 0, and the numbers rise from there.""" + hit_dice: HitDice + """The dice this level's hit points are rolled on; see [`HitDice`][osrlib.core.classes.HitDice].""" + thac0: int = Field(ge=2, le=20) + """The number needed to hit armour class 0 under descending armour class.""" + attack_bonus: int = Field(ge=0) + """The same attack, expressed as the bonus added to the roll under ascending armour class.""" + saves: SavingThrows + """The five saving throw targets at this level; see [`SavingThrows`][osrlib.core.classes.SavingThrows].""" + spell_slots: tuple[int, ...] = () + """How many spells of each level the class may memorize, with the first entry being first-level spells. Empty for a + class that casts nothing. + """ class XpTier(BaseModel): - """One XP-modifier tier: the modifier applies when every minimum holds.""" + """One band of the class's experience-modifier table: a percentage, and the scores that earn it. + + A class rewards a character whose prime requisite is high and penalizes one whose prime + requisite is low, by adjusting every experience award up or down. + [`xp_modifier_pct`][osrlib.core.classes.xp_modifier_pct] walks a class's tiers in order and + returns the first one whose minimums the character meets, so read the tiers rather than this + model on its own. Frozen. + """ model_config = ConfigDict(frozen=True) modifier_pct: int + """The adjustment as a signed percentage, like `10` for a tenth more experience or `-20` for a fifth less.""" + minimums: dict[AbilityScore, int] + """The lowest score in each named ability that earns this tier. Every entry must hold for the tier to apply. At + least one ability is named. + """ @model_validator(mode="after") def _minimums_must_be_scores(self) -> XpTier: @@ -154,20 +255,38 @@ def _minimums_must_be_scores(self) -> XpTier: class ArmourPolicyKind(StrEnum): - """What armour a class may wear.""" + """What armour a class is allowed to wear. + + Read it as [`ArmourPolicy.kind`][osrlib.core.classes.ArmourPolicy]. + [`validate_equip`][osrlib.core.items.validate_equip] enforces it when a character tries to put + something on. The wire values are `"any"`, `"leather_only"`, and `"none"`. + """ ANY = "any" + """Any armour, which is what the cleric, dwarf, elf, fighter, and halfling wear.""" + LEATHER_ONLY = "leather_only" + """Leather armour and nothing heavier, which is the thief's limit.""" + NONE = "none" + """No armour at all, which is the magic-user's limit. Shields are out too.""" class ArmourPolicy(BaseModel): - """A class's armour policy: the allowed kinds plus whether shields are allowed.""" + """What armour and shields a class may use. + + Read it as [`ClassDefinition.armour`][osrlib.core.classes.ClassDefinition]; + [`validate_equip`][osrlib.core.items.validate_equip] checks against it. The magic-user wears + nothing, the thief wears leather only, and everyone else wears anything. Frozen. + """ model_config = ConfigDict(frozen=True) kind: ArmourPolicyKind + """Which armour the class may wear; see [`ArmourPolicyKind`][osrlib.core.classes.ArmourPolicyKind].""" + shields_allowed: bool + """Whether the class may carry a shield. A class that can wear no armour cannot carry one either.""" @model_validator(mode="after") def _no_armour_means_no_shields(self) -> ArmourPolicy: @@ -177,29 +296,50 @@ def _no_armour_means_no_shields(self) -> ArmourPolicy: class WeaponPolicyKind(StrEnum): - """How a class's weapon list is expressed.""" + """Whether a class's weapon list names what it may use or what it may not. + + Read it as [`WeaponPolicy.kind`][osrlib.core.classes.WeaponPolicy]. `"any"` lists nothing and + permits everything, `"allowed"` lists the only weapons permitted, and `"forbidden"` lists the + only ones refused. The wire values are those three strings. + """ ANY = "any" + """Any weapon. The class lists none, because none are refused.""" + ALLOWED = "allowed" + """Only the listed weapons, which is how the cleric is limited to blunt weapons.""" + FORBIDDEN = "forbidden" + """Anything but the listed weapons, which is how the dwarf and halfling are kept off the long bow.""" class WeaponPolicy(BaseModel): - """A class's weapon policy. + """What weapons a class may wield. - `weapon_ids` is the explicit allow list (cleric: the five blunt weapons) or the - forbidden list (dwarf and halfling: `long_bow`, `two_handed_sword`), and is empty - for `any`. `manual_notes` keeps referee-judgment stature prose (the dwarf's "small - or normal sized", the halfling's "appropriate to stature") that cannot be - mechanized. The policy governs the weapons list only; gear combat facets are exempt - (see [`validate_equip`][osrlib.core.items.validate_equip]). + Read it as [`ClassDefinition.weapons`][osrlib.core.classes.ClassDefinition]; + [`validate_equip`][osrlib.core.items.validate_equip] checks against it. It governs weapons + only. A piece of gear a character swings in a pinch, like a torch, is not on the weapons + list and is not refused by it. Frozen. """ model_config = ConfigDict(frozen=True) kind: WeaponPolicyKind + """Whether `weapon_ids` is the permitted list, the refused list, or unused; see + [`WeaponPolicyKind`][osrlib.core.classes.WeaponPolicyKind]. + """ + weapon_ids: tuple[str, ...] = () + """The weapon ids the policy names, from [`load_equipment`][osrlib.data.load_equipment]; see [the equipment + index][equipment-index]. The cleric's five blunt weapons are an example of a permitted list, and the long bow and + two-handed sword the dwarf and halfling are refused are an example of the other. Empty when the class may use + anything. + """ + manual_notes: tuple[str, ...] = () + """The SRD's prose restrictions that no rule can settle, like the dwarf's weapons being "small or normal sized". + Show them to the referee. Nothing enforces them. + """ @model_validator(mode="after") def _ids_must_match_kind(self) -> WeaponPolicy: @@ -211,74 +351,164 @@ def _ids_must_match_kind(self) -> WeaponPolicy: class ThiefSkillRow(BaseModel): - """One level of the thief skill table. + """A thief's seven skill chances at one level. - Skills are d% roll-under percentages except `hear_noise`, an X-in-6 upper bound - (the SRD's `1–2` is stored as 2). Pick pockets can exceed 100 at high level; the - over-100 arithmetic belongs to the skill-check procedure. + Read the row for a thief's level out of + [`ClassDefinition.thief_skills`][osrlib.core.classes.ClassDefinition], or let + [`thief_skill_check`][osrlib.core.classes.thief_skill_check] find it and roll for you. Frozen. + + Six of the seven are percentages rolled on d%, succeeding at or under the number. + `hear_noise` is the odd one out: it is a chance in 6 rolled on 1d6, and the SRD's "1-2" is + stored here as 2. Pick pockets passes 100 at high level, and the check caps the effective + chance at 99 so a theft is never certain. """ model_config = ConfigDict(frozen=True) level: int = Field(ge=1) + """The thief level this row describes.""" + climb_sheer_surfaces: int = Field(ge=0) + """Percent chance to climb a sheer surface. It starts high, at 87 for a first-level thief, because a thief can climb + from the start. + """ + find_remove_treasure_traps: int = Field(ge=0) + """Percent chance to find or disarm a trap on a treasure container, which is not the same as spotting a trap in a + room. + """ + hear_noise: int = Field(ge=1, le=6) + """Chance in 6 of hearing something through a door, rolled on 1d6.""" + hide_in_shadows: int = Field(ge=0) + """Percent chance to go unseen while staying still in shadow.""" + move_silently: int = Field(ge=0) + """Percent chance to move without being heard.""" + open_locks: int = Field(ge=0) + """Percent chance to pick a lock, which needs thieves' tools.""" + pick_pockets: int = Field(ge=0) + """Percent chance to take something from a person unnoticed.""" class ClassAbility(BaseModel): - """A structured class-ability tag plus the SRD prose it came from. + """One thing a class can do, as a tag the rules read plus the SRD text it came from. + + Read them off [`ClassDefinition.abilities`][osrlib.core.classes.ClassDefinition]. The combat, + magic, and exploration procedures look for the tags they recognize and read the numbers out of + `params`, so a class ability is data rather than a branch in the code. Frozen. - `params` carries the mechanizable numbers (`{"range_feet": 60}` for infravision); - `manual` marks abilities that need referee judgment and stay prose. The combat, - magic, and crawl procedures consume these tags. + Some abilities cannot be reduced to a number. Those are marked manual, and a front end shows + the prose to the referee rather than acting on it. """ model_config = ConfigDict(frozen=True) tag: str + """The identifier the rules match on, like `"infravision"`, `"detect_secret_doors"`, or `"back_stab"`.""" + name: str + """The ability's name as the SRD prints it, for display.""" + prose: str + """The SRD's own description, which is what to show a player or referee.""" + manual: bool = False + """True when nothing in osrlib acts on this ability and the prose is the whole of it.""" + params: dict[str, int | str] = {} + """The numbers the rules read, like `{"range_feet": 60}` for infravision or `{"chance_in_six": 2}` for a detection + ability. Empty when there are none. + """ class ClassDefinition(BaseModel): - """A character class, compiled from its SRD page. + """A playable character class: the template a [`Character`][osrlib.core.character.Character] plays. + + Get one from [`load_classes`][osrlib.data.load_classes]`().get(class_id)`; see + [the class id index][classes-index] for the ids. A character stores only the id, and + [`Character.definition`][osrlib.core.character.Character.definition] looks the definition back + up, so read it from there rather than keeping a copy alongside. - Frozen SRD data: play never mutates a class definition. `race` is an open, - validated identity string — no rules procedure consumes it (racial mechanics - resolve through structured ability tags), so Advanced races are additive data, - not code. `requirements` are minimum scores checked at class choice; - `may_not_lower` carries adjustment-step restrictions (the thief's STR). - `level_titles[i]` is the title at level `i + 1`; the SRD's title lists run only - through name level, so they are shorter than the progression and levels beyond - them have no title entry. + It is frozen, and play never writes to it: a definition is shared by every character of that + class, while the mutable state of one played person lives on the + [`Character`][osrlib.core.character.Character]. Everything here is data compiled from the SRD's + class pages, which is why adding a class means adding data rather than code. """ model_config = ConfigDict(frozen=True) id: str + """The class id, like `"fighter"`, which is what a character stores.""" + name: str + """The class's name as the SRD prints it, for display.""" + race: str = Field(pattern=r"^[a-z][a-z0-9_]*$") + """The people this class belongs to, as a lowercase identifier, like `"human"` or `"dwarf"`. Creation copies it onto + the character. No rule reads it: what a people can do comes through `abilities` instead. + """ + requirements: dict[AbilityScore, int] = {} + """The lowest ability scores a character needs to take this class, checked by + [`validate_class_choice`][osrlib.core.character.validate_class_choice]. Empty for the human classes. The demi-human + classes each require a 9 in one or two abilities. + """ + prime_requisites: tuple[AbilityScore, ...] + """The abilities that set the experience modifier. One for most classes, two for the elf and the halfling.""" + xp_tiers: tuple[XpTier, ...] + """The experience-modifier bands, best first; see [`xp_modifier_pct`][osrlib.core.classes.xp_modifier_pct], which + reads them. + """ + hit_die: int + """The size of the class's hit die, which is 4, 6, or 8.""" + max_level: int = Field(ge=1) + """The highest level this class reaches. The human classes reach 14, and the demi-human classes stop lower.""" + armour: ArmourPolicy + """What armour and shields the class may use; see [`ArmourPolicy`][osrlib.core.classes.ArmourPolicy].""" + weapons: WeaponPolicy + """What weapons the class may wield; see [`WeaponPolicy`][osrlib.core.classes.WeaponPolicy].""" + languages: tuple[str, ...] + """The language ids the class speaks for free, Common first. A character's full list, including the alignment tongue + and any extras, is [`Character.languages`][osrlib.core.character.Character.languages]. + """ + may_not_lower: tuple[AbilityScore, ...] = () + """Abilities the creation-time adjustment may not take points from, which for the thief is STR.""" + abilities: tuple[ClassAbility, ...] = () + """What the class can do, as tags the rules read; see [`ClassAbility`][osrlib.core.classes.ClassAbility].""" + thief_skills: tuple[ThiefSkillRow, ...] = () + """A row per level of the thief's seven skills, empty for every class but the thief; see + [`ThiefSkillRow`][osrlib.core.classes.ThiefSkillRow]. + """ + level_titles: tuple[str, ...] = () + """The title a character has at each level, with the first entry being level 1. The SRD prints titles only up to + name level, so this is shorter than the progression, and + [`level_title`][osrlib.core.classes.level_title] returns `None` past the end rather than raising. + """ + progression: tuple[ProgressionRow, ...] + """A row per level from 1 to `max_level`, in order. Read one with + [`row`][osrlib.core.classes.ClassDefinition.row] rather than indexing. + """ + overrides_applied: tuple[str, ...] = () + """The names of the compile-time corrections applied to this class's SRD page. Provenance for anyone checking the + data against the SRD. Nothing in play reads it. + """ @model_validator(mode="after") def _progression_must_cover_levels(self) -> ClassDefinition: @@ -304,19 +534,36 @@ def _progression_must_cover_levels(self) -> ClassDefinition: return self def row(self, level: int) -> ProgressionRow: - """Return the progression row for `level` — the pure recompute-from-level lookup. + """Return what this class grants at `level`. + + This is where a character's THAC0, attack bonus, saving throws, hit dice, and spell + capacity come from. Nothing derived is stored on a character, so reading the row for the + current level always gives values that match it, and changing the level is all that + leveling up or being drained has to do. - Saves, THAC0, attack bonus, and spell slots are read from here, never stored: - energy drain is this function's inverse, not a redesign. + Read the convenience properties on + [`Character`][osrlib.core.character.Character] instead when you have a character in hand: + `character.thac0`, `character.saves`, and the rest call this for you. Call it directly to + look ahead, like asking what the next level costs in experience. Args: - level: The character level, 1 through the class maximum. + level: The level to read, from 1 through `max_level`. Returns: - The progression row. + The progression row for that level. Raises: - ValueError: If `level` is outside the class's range. + ValueError: If `level` is below 1 or above the class's maximum. + + Examples: + ```python + from osrlib.data import load_classes + + fighter = load_classes().get("fighter") + row = fighter.row(3) + print(row.xp, row.thac0, row.hit_dice.count, row.saves.death) + # 4000 19 3 12 + ``` """ if not 1 <= level <= self.max_level: raise ValueError(f"{self.id} levels are 1-{self.max_level}, got {level}") @@ -324,11 +571,17 @@ def row(self, level: int) -> ProgressionRow: class ClassCatalog(BaseModel): - """The loaded class list, with id lookup.""" + """Every playable class, as returned by [`load_classes`][osrlib.data.load_classes]. + + Look a class up by id with [`get`][osrlib.core.classes.ClassCatalog.get], or iterate `classes` + to build a menu of what a player may choose. The catalog is loaded once and cached, so calling + the loader again is free. Frozen. + """ model_config = ConfigDict(frozen=True) classes: tuple[ClassDefinition, ...] + """The class definitions, in the order the data file lists them. Ids are unique.""" @model_validator(mode="after") def _ids_must_be_unique(self) -> ClassCatalog: @@ -338,17 +591,28 @@ def _ids_must_be_unique(self) -> ClassCatalog: return self def get(self, class_id: str) -> ClassDefinition: - """Return the class definition for `class_id`. + """Return the class with `class_id`. Args: - class_id: The class id to look up, e.g. `"fighter"` — see - [the class id index][classes-index]. + class_id: The id to look up, like `"fighter"`; see + [the class id index][classes-index] for all of them. Returns: The class definition. Raises: - ValueError: If no class has that id. + ValueError: If no class has that id. The message names the id you passed. + + Examples: + ```python + from osrlib.data import load_classes + + catalog = load_classes() + print(catalog.get("halfling").max_level) + # 8 + print([definition.id for definition in catalog.classes]) + # ['cleric', 'dwarf', 'elf', 'fighter', 'halfling', 'magic_user', 'thief'] + ``` """ for definition in self.classes: if definition.id == class_id: @@ -357,57 +621,111 @@ def get(self, class_id: str) -> ClassDefinition: class LevelUpResult(BaseModel): - """The outcome of gaining one level. + """What happened when a character gained a level. - While HD count still grows, `hp_roll` is the raw die (CON applies when - `con_applied`, minimum 1 hp gained); above name level the gain is the flat-bonus - delta with no roll and no CON. + Returned by [`level_up`][osrlib.core.classes.level_up], and set on + [`XpAwardResult.level_up`][osrlib.core.classes.XpAwardResult] when an experience award caused + the gain. Show it to tell a player what their new level got them. Frozen. """ model_config = ConfigDict(frozen=True) new_level: int + """The level the character now has.""" + hp_roll: int | None + """The raw hit die that was thrown, or `None` above name level, where the class gains a flat number of hit points + and rolls nothing. + """ + hp_gained: int + """The hit points added to both maximum and current. At least 1 while dice are still being rolled, however poor the + die and the CON modifier were together. + """ + con_applied: bool + """Whether the CON modifier counted toward the gain. False above name level.""" class XpAwardResult(BaseModel): - """The outcome of applying an XP award. + """What happened when a character received an experience award. - `modified_award` is the award after the class XP-modifier percentage, floored. - `clamped` reports the one-level-per-award rule firing: XP that would reach two or - more levels above the starting level stops at 1 XP below the second level's - threshold. + Returned by [`apply_xp`][osrlib.core.classes.apply_xp]. It contains enough to show a player the + whole story: what the award was, what their class made of it, and whether it took them up a + level. Frozen. """ model_config = ConfigDict(frozen=True) award: int + """The award as it was handed in, before the class modifier.""" + modifier_pct: int + """The class's experience modifier for this character's scores, as a signed percentage; see + [`xp_modifier_pct`][osrlib.core.classes.xp_modifier_pct]. + """ + modified_award: int + """The award after the modifier, rounded down.""" + xp_before: int + """The character's experience before the award.""" + xp_after: int + """The character's experience after it, which is what is now stored.""" + level_before: int + """The level the character had before the award.""" + level_after: int + """The level they have after it. At most one higher, because a single award never grants two levels.""" + clamped: bool + """True when the award was cut back to keep the character below the level after next. An award big enough to jump + two levels stops 1 experience point short of the second threshold, and the rest is lost. + """ + level_up: LevelUpResult | None + """What the level gain granted, or `None` when no level was gained; see + [`LevelUpResult`][osrlib.core.classes.LevelUpResult]. + """ def xp_modifier_pct(definition: ClassDefinition, scores: dict[AbilityScore, int]) -> int: - """Return the class XP-modifier percentage for a score set. + """Return how much a class adjusts this character's experience awards, as a percentage. - Tiers are evaluated best-first; the first tier whose minimums all hold wins. - osrlib reads this as the standard table's intended behavior — its penalty rows - only make sense under first-match evaluation. With no matching tier the modifier - is zero, which is how the multi-prime-requisite classes carry no penalties per RAW. + A class rewards a high prime requisite and penalizes a low one by changing every experience + award. [`apply_xp`][osrlib.core.classes.apply_xp] calls this for you, so call it yourself only + to show a player the number on a character sheet, or to let them see what raising a score at + creation would buy them. + + The tiers are stored best first, and the first one whose minimums the character meets wins. + A character who meets none gets no adjustment, which is how the elf and the halfling end up + with a bonus band and no penalty band, as the SRD prints them. Args: - definition: The class definition. - scores: The character's final ability scores. + definition: The character's class. + scores: The character's final ability scores, after any creation-time adjustment. Returns: - The XP modifier as a signed percentage (`+10`, `-20`, `0`). + The adjustment as a signed percentage: `10` for a tenth more, `-20` for a fifth less, `0` + for no change. + + Examples: + ```python + from osrlib.core.abilities import AbilityScore + from osrlib.core.classes import xp_modifier_pct + from osrlib.data import load_classes + + fighter = load_classes().get("fighter") + scores = dict.fromkeys(AbilityScore, 12) + scores[AbilityScore.STR] = 16 + print(xp_modifier_pct(fighter, scores)) + # 10 + scores[AbilityScore.STR] = 5 + print(xp_modifier_pct(fighter, scores)) + # -20 + ``` """ for tier in definition.xp_tiers: if all(scores[ability] >= minimum for ability, minimum in tier.minimums.items()): @@ -416,17 +734,33 @@ def xp_modifier_pct(definition: ClassDefinition, scores: dict[AbilityScore, int] def level_title(definition: ClassDefinition, level: int) -> str | None: - """Return the class's level title at `level`, or `None` beyond the printed list. + """Return what a character of this class and level is called, like "Veteran". + + Use it wherever you show a character's standing: a sheet, a party roster, the line a front end + prints when someone levels up. - `level_titles[i]` is the title at level `i + 1`; the SRD's title lists run only - through name level, so levels past the list have no title. + The SRD prints titles only up to name level, the level at which a character may build a + stronghold, so a character past that has no title and this returns `None`. Show the class name + instead when it does. Args: - definition: The [`ClassDefinition`][osrlib.core.classes.ClassDefinition]. - level: The character level, 1 or greater. + definition: The character's class. + level: The level to name, 1 or higher. Returns: - The title, or `None` when the class's title list doesn't reach `level`. + The title, or `None` when the class's list does not reach that level. + + Examples: + ```python + from osrlib.core.classes import level_title + from osrlib.data import load_classes + + fighter = load_classes().get("fighter") + print(level_title(fighter, 1), level_title(fighter, 4)) + # Veteran Hero + print(level_title(fighter, 11)) + # None + ``` """ if 1 <= level <= len(definition.level_titles): return definition.level_titles[level - 1] @@ -434,27 +768,65 @@ def level_title(definition: ClassDefinition, level: int) -> str | None: def level_up(character: Character, definition: ClassDefinition, stream: RngStream) -> LevelUpResult: - """Advance a character one level, rolling hit points per the SRD. + """Raise a character one level and roll the hit points that come with it. - While the HD count still grows, the gain is a new hit die roll plus the CON - modifier, minimum 1. Above name level the gain is the flat-bonus delta between the - progression rows — no roll, no CON. Both max and current hit points increase by - the gain: leveling adds hit points but heals no damage already taken. Saves, - THAC0, and spell slots are never stored — read them from - [`ClassDefinition.row`][osrlib.core.classes.ClassDefinition.row]. + Use [`apply_xp`][osrlib.core.classes.apply_xp] for ordinary play, which awards experience and + calls this when a threshold is crossed. Call this directly when a level is granted outright + rather than earned: building a character above first level, a referee's ruling, restoring a + level a wight took. + + While the class is still gaining hit dice, the character rolls one and adds the CON modifier, + gaining at least 1 hit point however the dice fall. Past name level the class gains a fixed + number of hit points instead, with no roll and no CON. Either way both maximum and current hit + points rise by the gain, so a level heals nothing: a wounded character is still wounded, with + a higher ceiling. + + Nothing else needs updating. THAC0, saving throws, and spell capacity are read from the + progression row for the new level, so they change on their own. Args: - character: The character to advance; mutated in place. - definition: The character's class definition. - stream: The RNG stream for the hit die roll, conventionally - [`ADVANCEMENT_STREAM`][osrlib.core.character.ADVANCEMENT_STREAM]. + character: The character to advance. Mutated in place: its level, maximum hit points, and + current hit points all change. + definition: The character's class. It must be the character's own class. + stream: The stream for the hit die, conventionally + `streams.get(`[`ADVANCEMENT_STREAM`][osrlib.core.character.ADVANCEMENT_STREAM]`)`. No + draw is taken past name level. Returns: - The level-up outcome, including the raw hit die roll if one was made. + What the level gained, including the raw die when one was thrown. Raises: - ValueError: If the definition doesn't match the character's class, or the - character is already at the class's maximum level. + ValueError: If `definition` is not the character's class, or the character is already at + the class's maximum level. + + Examples: + ```python + from osrlib.core.alignment import Alignment + from osrlib.core.character import ( + ADVANCEMENT_STREAM, + CHARACTER_CREATION_STREAM, + create_character, + ) + from osrlib.core.classes import level_up + from osrlib.core.rng import RngStreams + from osrlib.core.ruleset import Ruleset + from osrlib.data import load_classes + + streams = RngStreams(master_seed=2) + fighter = load_classes().get("fighter") + character = create_character( + name="Rurik", + class_id="fighter", + alignment=Alignment.LAWFUL, + ruleset=Ruleset(), + stream=streams.get(CHARACTER_CREATION_STREAM), + ).character + result = level_up(character, fighter, streams.get(ADVANCEMENT_STREAM)) + print(result.new_level, result.hp_roll, result.hp_gained) + # 2 1 2 + print(character.level, character.max_hp, character.thac0) + # 2 10 19 + ``` """ if definition.id != character.class_id: raise ValueError(f"class definition {definition.id!r} does not match character class {character.class_id!r}") @@ -479,70 +851,118 @@ def level_up(character: Character, definition: ClassDefinition, stream: RngStrea class SkillCheckResult(BaseModel): - """A thief skill check's outcome. + """How a thief skill check came out. - `chance` is the effective target after modifiers (the pick-pockets ≥1%-failure - cap applied). `noticed` is set only for pick pockets: a roll of more than twice - the effective chance means the attempted theft is noticed (RAW) — what the - victim does about it is a game procedure. + Returned by [`thief_skill_check`][osrlib.core.classes.thief_skill_check]. Frozen. """ model_config = ConfigDict(frozen=True) skill: str + """The skill that was rolled, as its name.""" + roll: int + """The die result: d% for the six percentile skills, 1d6 for `hear_noise`.""" + chance: int + """The number the roll had to come in at or under, after any modifier you passed. Pick pockets is capped here at 99, + so a theft always has some chance of failing. + """ + passed: bool + """Whether the check succeeded.""" + noticed: bool | None = None + """For pick pockets only: True when the roll came in at more than twice the chance, which means the victim noticed + the attempt. `None` for every other skill. What a noticed thief then faces is the game's business, not the kernel's. + """ class DetectionResult(BaseModel): - """An X-in-6 detection check's outcome. + """How a chance-in-6 detection check came out. - `roll` is `None` for a zero chance: no die is consumed, since there is nothing to - roll under — for example, a non-dwarf searching for construction tricks simply - fails without a roll. + Returned by [`detection_check`][osrlib.core.classes.detection_check]. Frozen. """ model_config = ConfigDict(frozen=True) chance: int + """The chance in 6 the roll had to come in at or under.""" + roll: int | None = None + """The 1d6 result, or `None` when the chance was zero and no die was thrown. A character with no chance at all, such + as anyone but a dwarf looking for a shift in the stonework, fails without rolling. + """ + passed: bool + """Whether the check succeeded.""" def thief_skill_check( character: Character, definition: ClassDefinition, skill: str, *, modifier_pct: int = 0, stream: RngStream ) -> SkillCheckResult: - """Roll one thief skill check — an à la carte plain result, no events. + """Roll one of a thief's skills and return how it came out. - The six percentile skills roll d% with success on a result less than or equal - to the level row's chance; `hear_noise` rolls 1d6 against its X-in-6 bound. The - crawl procedures emit the events (and hide the referee-rolled outcomes); this - function just resolves the dice. + Call it when a thief tries something their skills cover: climbing a wall, listening at a door, + lifting a purse. It rolls and reports, nothing more. It emits no events and hides nothing, so + a front end that shows players only what their characters would know must decide for itself + what to reveal. Inside a crawl, the commands in + [`osrlib.crawl.exploration`][osrlib.crawl.exploration] call it and emit the events for you. - Pick pockets, per its RAW bullet: the caller folds the victim's over-5th-level - penalty into `modifier_pct` (−5% per victim level above 5th — the kernel never - sees the victim), the effective chance caps at 99 ("always at least a 1% chance - of failure"), and a roll of more than twice the effective chance sets `noticed`. + The six skills in + [`PERCENTILE_THIEF_SKILLS`][osrlib.core.classes.PERCENTILE_THIEF_SKILLS] roll d% and succeed at + or under the chance for the thief's level. `"hear_noise"` rolls 1d6 against a chance in 6 + instead, and ignores `modifier_pct`. + + Pick pockets has two rules of its own. Stealing from someone above fifth level is harder, by + 5% per level above the fifth, and you fold that into `modifier_pct` yourself, because the + kernel never sees the victim. The chance then caps at 99, so a theft is never certain, and a + roll of more than twice the chance means the victim noticed. Args: - character: The rolling thief. - definition: The character's class definition; must carry a thief skill - table. - skill: A percentile skill name from - [`PERCENTILE_THIEF_SKILLS`][osrlib.core.classes.PERCENTILE_THIEF_SKILLS], - or `"hear_noise"`. - modifier_pct: A percentage adjustment to the percentile chance (ignored for - `hear_noise`). - stream: The RNG stream, conventionally the crawl's `"exploration"` stream. + character: The thief making the attempt. Their level chooses the row. + definition: The character's class, which must have a thief skill table. + skill: One of the names in + [`PERCENTILE_THIEF_SKILLS`][osrlib.core.classes.PERCENTILE_THIEF_SKILLS], or + `"hear_noise"`. + modifier_pct: A percentage added to the chance before rolling, negative to make the + attempt harder. Ignored for `"hear_noise"`. + stream: The stream to draw from, conventionally the crawl's `"exploration"` stream. One + draw is taken. Returns: - The check outcome. + The roll, the chance it was measured against, and whether it passed. Raises: - ValueError: If the class has no thief skills or the skill name is unknown — - gating who may attempt a skill is the caller's validation. + ValueError: If the class has no thief skills, or the skill name is not one this function + knows. Deciding who is allowed to try a skill is yours to do before calling. + + Examples: + ```python + from osrlib.core.alignment import Alignment + from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character + from osrlib.core.classes import thief_skill_check + from osrlib.core.rng import RngStreams + from osrlib.core.ruleset import Ruleset + from osrlib.data import load_classes + + thief = load_classes().get("thief") + character = create_character( + name="Nim", + class_id="thief", + alignment=Alignment.NEUTRAL, + ruleset=Ruleset(), + stream=RngStreams(master_seed=4).get(CHARACTER_CREATION_STREAM), + ).character + stream = RngStreams(master_seed=1).get("exploration") + result = thief_skill_check(character, thief, "climb_sheer_surfaces", stream=stream) + print(result.roll, result.chance, result.passed) + # 65 87 True + stream = RngStreams(master_seed=9).get("exploration") + theft = thief_skill_check(character, thief, "pick_pockets", stream=stream) + print(theft.roll, theft.chance, theft.passed, theft.noticed) + # 100 20 False True + ``` """ if not definition.thief_skills: raise ValueError(f"{definition.id} has no thief skill table") @@ -561,20 +981,39 @@ def thief_skill_check( def detection_check(chance_in_six: int, *, stream: RngStream) -> DetectionResult: - """Roll the shared X-in-6 detection check: searching, listening, demi-human tags. + """Roll a chance-in-6 check: 1d6, succeeding at or under the chance. + + This is the one roll behind searching a wall for a secret door, listening at a door, spotting + a trap in a room, and a dwarf noticing that the stonework is wrong. Get the chance from + [`detection_chance`][osrlib.core.classes.detection_chance], which works out what this + character's chance at this kind of search is, then pass it here. - A zero (or negative) chance consumes no draw and fails: the OSE SRD grants - construction-trick perception to dwarves alone, and a character without it has - nothing to roll under. + A chance of zero, or below, fails without throwing a die and takes no draw from the stream. + Anyone but a dwarf looking for a shift in the stonework has no chance at all, and rolling for + them would both mislead the player and shift every later draw. Args: - chance_in_six: The X-in-6 chance, from - [`detection_chance`][osrlib.core.classes.detection_chance] or a class - tag. - stream: The RNG stream, conventionally the crawl's `"exploration"` stream. + chance_in_six: The chance to roll at or under, usually from + [`detection_chance`][osrlib.core.classes.detection_chance]. + stream: The stream to draw from, conventionally the crawl's `"exploration"` stream. One + draw is taken unless the chance is zero. Returns: - The check outcome. + The roll and whether it passed, with `roll` left `None` when no die was thrown. + + Examples: + ```python + from osrlib.core.classes import detection_check + from osrlib.core.rng import RngStreams + + stream = RngStreams(master_seed=4).get("exploration") + result = detection_check(2, stream=stream) + print(result.roll, result.passed) + # 2 True + nothing = detection_check(0, stream=stream) + print(nothing.roll, nothing.passed) + # None False + ``` """ if chance_in_six <= 0: return DetectionResult(chance=chance_in_six, passed=False) @@ -590,27 +1029,56 @@ def _ability_chance(definition: ClassDefinition, tag: str) -> int | None: def detection_chance(character: Character, definition: ClassDefinition, kind: str) -> int: - """Resolve a character's X-in-6 chance for one detection kind. + """Return this character's chance in 6 at one kind of search. - Precedence, applied in this order: listening uses the thief's `hear_noise` row - when present, else the class's `listening_at_doors` param, else the universal - 1-in-6; secret doors use `detect_secret_doors` (elf 2) else 1; room traps use - `detect_room_traps` (dwarf 2) else 1; construction tricks use - `detect_construction_tricks` (dwarf 2) else **zero** — the OSE SRD grants the - perception to dwarves alone, and "as a dwarf you can sense" has no baseline for - others, unlike the universal 1-in-6 search chances the SRD states for all PCs. + Call it before [`detection_check`][osrlib.core.classes.detection_check], which rolls against + the number it gives you. It reads the class's abilities and, for a thief listening, the level's + skill row, so it answers for whoever is searching without you having to know which classes are + good at what. + + Listening at doors takes the thief's `hear_noise` chance when the character is a thief, else + the class's own listening ability, else the 1 in 6 anyone gets. Searching for a secret door + takes the class's `detect_secret_doors` ability, which the elf has at 2, else 1. Looking for a + trap in a room takes `detect_room_traps`, which the dwarf has at 2, else 1. Noticing a shift + in the stonework takes `detect_construction_tricks`, which the dwarf has at 2, and everyone + else gets zero: the SRD gives this perception to dwarves and states no chance for anyone else, + unlike the searches it opens to every character. Args: - character: The detecting character. - definition: The character's class definition. - kind: One of `"listening"`, `"secret_doors"`, `"room_traps"`, or + character: The character searching. Their level chooses a thief's skill row. + definition: The character's class. + kind: What they are searching for: `"listening"`, `"secret_doors"`, `"room_traps"`, or `"construction"`. Returns: - The X-in-6 chance (0 means no chance at all). + The chance in 6. Zero means the character cannot do it at all, and + [`detection_check`][osrlib.core.classes.detection_check] fails it without a roll. Raises: - ValueError: If the kind is unknown. + ValueError: If `kind` is not one of the four. + + Examples: + ```python + from osrlib.core.alignment import Alignment + from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character + from osrlib.core.classes import detection_chance + from osrlib.core.rng import RngStreams + from osrlib.core.ruleset import Ruleset + from osrlib.data import load_classes + + dwarf = load_classes().get("dwarf") + character = create_character( + name="Thora", + class_id="dwarf", + alignment=Alignment.LAWFUL, + ruleset=Ruleset(), + stream=RngStreams(master_seed=1).get(CHARACTER_CREATION_STREAM), + ).character + print(detection_chance(character, dwarf, "room_traps")) + # 2 + print(detection_chance(character, dwarf, "secret_doors")) + # 1 + ``` """ if kind == "listening": if definition.thief_skills: @@ -630,24 +1098,40 @@ def detection_chance(character: Character, definition: ClassDefinition, kind: st class DrainResult(BaseModel): - """The outcome of energy drain. + """What an energy drain took from a character. - `hp_rolls` are the raw hit dice rolled for the drained levels (empty above name - level, where the loss is the flat-bonus delta). `slain` marks the terminal case: - a person drained of all levels dies, and `spawn_consequence` carries the SRD's - spawn prose as a structured-but-manual field — the kernel kills, the game - narrates. + Returned by [`drain_levels`][osrlib.core.classes.drain_levels]. Frozen. """ model_config = ConfigDict(frozen=True) levels_lost: int + """How many levels the drain removed. When the drain killed the character, the level that killed them is counted + here. + """ + new_level: int = Field(ge=0) + """The level the character now has, or 0 when the drain killed them.""" + hp_rolls: tuple[int, ...] = () + """The raw hit dice thrown for the levels lost, in order. Empty above name level, where the loss is a fixed number + of hit points and nothing is rolled. + """ + hp_lost: int + """The hit points taken from both maximum and current.""" + xp_after: int | None = None + """The experience the character is left with, or `None` when the drain killed them.""" + slain: bool = False + """True when the drain took the character's last level and killed them.""" + events: tuple[Event, ...] = () + """What to publish: a [`LevelDrainedEvent`][osrlib.core.events.LevelDrainedEvent] and, depending on the outcome, a + hit point report, the death events, and any spells forgotten because the character's capacity shrank. Feed them to + your event sink in order. + """ def drain_levels( @@ -659,41 +1143,82 @@ def drain_levels( stream: RngStream, spawn_consequence: str | None = None, ) -> DrainResult: - """Drain experience levels — the inverse of [`level_up`][osrlib.core.classes.level_up]. - - Saves, THAC0, and spell slots need no reversal because they derive from - [`row`][osrlib.core.classes.ClassDefinition.row]; only stored state reverses. - Per level drained, mirroring `level_up` exactly in reverse: above name level - subtract the flat-bonus delta (no roll, no CON); otherwise roll the class hit die - plus the CON modifier (minimum 1 per die) and subtract it from max and current - hit points — rolling the lost die is osrlib's RAW-faithful reading of "loses one - Hit Die of hit points" that keeps the model stateless. - - Floors: drain never reduces max HP below 1 or current HP below 1 while the - character retains a level — death by drain happens only by losing the last - level ("a person drained of all levels"), the terminal state. XP is set once - after all levels drain, by policy: `halfway` is the floored midpoint of the - former and new levels' thresholds (the wight); `level_minimum` is the new - level's threshold exactly (wraith, spectre, vampire). + """Take experience levels away from a character, undoing what [`level_up`][osrlib.core.classes.level_up] did. + + Call it when an undead creature that drains levels lands a hit: the wight takes one level, the + spectre and the vampire take two. Read the monster's `energy_drain` ability with + [`MonsterTemplate.ability`][osrlib.core.monsters.MonsterTemplate.ability] for the number of + levels and the experience policy, then pass them here. The attack itself resolves in + [`osrlib.core.combat`][osrlib.core.combat]. This is the consequence. + + Each level is taken exactly as it was given. Below name level the character throws the hit die + they would have rolled and loses that many hit points plus their CON modifier, at least 1. + Above name level they lose the fixed number of hit points the class grants there, with no roll. + Rolling the die back is what lets the model stay stateless: a character keeps no record of + which dice built their hit points, so the drain rolls a fresh one. THAC0, saving throws, and + spell capacity need nothing done to them, because they are read from the level. + + A character never drops below 1 maximum or 1 current hit point while they still have a level. + Death comes only from losing the last one, which is the SRD's person drained of all levels: the + result reports `slain`, the events include the death, and any spells that no longer fit the + shrunken capacity are forgotten newest first. + + Experience is rewritten once, after every level is taken. Under `"halfway"` the character keeps + the midpoint between the threshold they had reached and the one they fell back to. Under + `"level_minimum"` they keep exactly the new level's threshold. Args: - character: The drained character; mutated in place. - definition: The character's class definition. - levels: How many levels the drain removes (the spectre and vampire drain - two, applying the procedure twice). - xp_policy: `"halfway"` or `"level_minimum"` — per-monster data from the - `energy_drain` tag. - stream: The RNG stream for the lost hit die rolls, conventionally - [`ADVANCEMENT_STREAM`][osrlib.core.character.ADVANCEMENT_STREAM] — the - same subsystem as the gains it reverses. - spawn_consequence: The monster's spawn prose, carried on the drain event. + character: The character being drained. Mutated in place: level, experience, and both hit + point totals change. + definition: The character's class. It must be the character's own class. + levels: How many levels to take. The procedure runs once per level. + xp_policy: `"halfway"` or `"level_minimum"`, from the monster's `energy_drain` ability. + stream: The stream for the hit dice thrown back, conventionally + `streams.get(`[`ADVANCEMENT_STREAM`][osrlib.core.character.ADVANCEMENT_STREAM]`)`, the + same stream the gains came from. + spawn_consequence: What the victim becomes, in the monster's own words, put on the drain + event for a front end to show. Nothing acts on it. Returns: - The drain outcome, including the terminal death when all levels are lost. + What was lost, and the events to publish. Raises: - ValueError: If the definition doesn't match the character's class, `levels` - is not positive, or the policy is unknown. + ValueError: If `definition` is not the character's class, if `levels` is not positive, or + if `xp_policy` is neither of the two. + + Examples: + ```python + from osrlib.core.alignment import Alignment + from osrlib.core.character import ( + ADVANCEMENT_STREAM, + CHARACTER_CREATION_STREAM, + create_character, + ) + from osrlib.core.classes import apply_xp, drain_levels + from osrlib.core.rng import RngStreams + from osrlib.core.ruleset import Ruleset + from osrlib.data import load_classes + + streams = RngStreams(master_seed=2) + fighter = load_classes().get("fighter") + character = create_character( + name="Rurik", + class_id="fighter", + alignment=Alignment.LAWFUL, + ruleset=Ruleset(), + stream=streams.get(CHARACTER_CREATION_STREAM), + ).character + advancement = streams.get(ADVANCEMENT_STREAM) + apply_xp(character, fighter, 2500, advancement) + print(character.level, character.xp, character.max_hp) + # 2 2500 10 + + result = drain_levels(character, fighter, levels=1, xp_policy="halfway", stream=advancement) + print(result.levels_lost, result.new_level, result.hp_lost, result.slain) + # 1 1 9 False + print(character.level, character.xp, character.max_hp) + # 1 1000 1 + ``` """ if definition.id != character.class_id: raise ValueError(f"class definition {definition.id!r} does not match character class {character.class_id!r}") @@ -724,9 +1249,9 @@ def drain_levels( hp_lost += lost events: list[Event] = [] if slain: - # The killing level counts as lost: a level-1 victim loses 1 level, a - # spectre draining a level-2 fighter reports 2 (the model's level floor of 1 - # stays — the character is dead, not level 0). + # The level that killed them counts as lost, so a level-1 victim loses 1 and a spectre + # draining a level-2 fighter reports 2. The stored level stays at 1 because the model + # floors it there. The character is dead, not level 0. levels_lost = former_level - character.level + 1 character.xp = 0 events.append( @@ -772,9 +1297,9 @@ def drain_levels( ) ) if getattr(character, "memorized_spells", ()): - # The drain/memorization interplay: memorized copies in excess of the shrunk - # slots are forgotten newest-first. Runtime imports because the spells module - # sits above this one in the import graph (spells → combat → classes). + # Memorized spells beyond what the shrunken slots can fit are forgotten, newest first. + # The imports are here rather than at the top because spells imports combat, which + # imports this module. from osrlib.core.spells import forget_excess_memorized from osrlib.data import load_spells @@ -790,27 +1315,63 @@ def drain_levels( def apply_xp(character: Character, definition: ClassDefinition, award: int, stream: RngStream) -> XpAwardResult: - """Apply an XP award: class modifier, the one-level-per-award rule, and leveling. + """Give a character experience points, and level them up if the award takes them over a threshold. + + This is how characters advance. Split the experience a party earned among its members however + your game divides it, then call this once per member. It applies the class's modifier, stores + the new total, and calls [`level_up`][osrlib.core.classes.level_up] when the character has + crossed the next threshold, all in one step, so you never have to check thresholds yourself. - The class XP-modifier percentage applies first, with the result floored. Then the - rule exactly as written: XP that would reach two or more levels above the - starting level is clamped to 1 XP below the second level's threshold, and the - character gains one level. At the class's maximum level no further levels are - gained but XP keeps accumulating, unclamped — there is no next threshold to hold - the character under. + A single award never grants two levels. An award large enough to reach the level after next is + cut back to one point short of that second threshold, and the excess is lost, so a character + who kills a dragon at first level ends up at second and has to earn the rest. The result says + when that happened. + + At the class's maximum level the character stops gaining levels but keeps accumulating + experience, uncut, because there is no further threshold to keep them under. Args: - character: The character receiving the award; mutated in place. - definition: The character's class definition. - award: The unmodified XP award. Non-negative. - stream: The RNG stream for a level-up hit die roll. + character: The character receiving the award. Mutated in place: experience, and on a level + gain the level and hit points too. + definition: The character's class. It must be the character's own class. + award: The experience to award, before the class modifier. Not negative. + stream: The stream for a hit die if the award levels the character up, conventionally + `streams.get(`[`ADVANCEMENT_STREAM`][osrlib.core.character.ADVANCEMENT_STREAM]`)`. No + draw is taken when no level is gained. Returns: - The award outcome, including the level-up result when one occurred. + The whole story of the award: what it was, what the class made of it, and what the + character gained. Raises: - ValueError: If the definition doesn't match the character's class or the - award is negative. + ValueError: If `definition` is not the character's class, or `award` is negative. + + Examples: + ```python + from osrlib.core.alignment import Alignment + from osrlib.core.character import ( + ADVANCEMENT_STREAM, + CHARACTER_CREATION_STREAM, + create_character, + ) + from osrlib.core.classes import apply_xp + from osrlib.core.rng import RngStreams + from osrlib.core.ruleset import Ruleset + from osrlib.data import load_classes + + streams = RngStreams(master_seed=2) + fighter = load_classes().get("fighter") + character = create_character( + name="Rurik", + class_id="fighter", + alignment=Alignment.LAWFUL, + ruleset=Ruleset(), + stream=streams.get(CHARACTER_CREATION_STREAM), + ).character + result = apply_xp(character, fighter, 10000, streams.get(ADVANCEMENT_STREAM)) + print(result.modified_award, result.xp_after, result.level_after, result.clamped) + # 10000 3999 2 True + ``` """ if definition.id != character.class_id: raise ValueError(f"class definition {definition.id!r} does not match character class {character.class_id!r}") diff --git a/src/osrlib/core/monsters.py b/src/osrlib/core/monsters.py index 7e9743c..ae5901d 100644 --- a/src/osrlib/core/monsters.py +++ b/src/osrlib/core/monsters.py @@ -1,28 +1,56 @@ -"""Monster templates, instances, spawning, and the entity ID allocator. - -The 138 SRD monster pages compile into `monsters.json` (packed-variant pages expand to -one concrete entry per variant, because a frozen template must be spawnable) and load -as frozen [`MonsterTemplate`][osrlib.core.monsters.MonsterTemplate] models via -[`load_monsters`][osrlib.data.load_monsters]. Play spawns mutable -[`MonsterInstance`][osrlib.core.monsters.MonsterInstance]s from frozen templates with -[`spawn_monster`][osrlib.core.monsters.spawn_monster], so shared template data can -never be damaged by play: load the catalog once, then spawn one instance per creature. - -Ability bullets compile as structured tags plus the SRD prose (mirroring -`ClassAbility`): the tags the kernel executes (`regeneration`, `energy_drain`, `poison`, -`paralysis`, `petrification`, `breath_weapon`, `gaze`, `disease`, `uses_fire`) carry -structured params the engine reads directly; everything else compiles with -`manual=True` and stays prose the kernel doesn't execute. Defenses the damage pipeline -checks at damage time compile into the structured -[`Defenses`][osrlib.core.monsters.Defenses] shape while the bullets keep the prose. - -Spawned hit points draw from the -[`MONSTER_SPAWN_STREAM`][osrlib.core.monsters.MONSTER_SPAWN_STREAM] stream, a -module-level constant, so a combat-rules change never shifts spawned hit points in a -fixed scenario. - -Part of the core kernel, alongside [`osrlib.core.combat`][osrlib.core.combat], which -the spawned instances feed as combatants. +"""Monster templates, the creatures spawned from them, and the ids they are given. + +Two models do the work here, and they do different jobs. +[`MonsterTemplate`][osrlib.core.monsters.MonsterTemplate] is the stat block, frozen and shared: one +troll template describes every troll in the game. +[`MonsterInstance`][osrlib.core.monsters.MonsterInstance] is one creature in play, mutable, with its +own hit points and its own wounds. Load the catalog once with +[`load_monsters`][osrlib.data.load_monsters], then call +[`spawn_monster`][osrlib.core.monsters.spawn_monster] for each creature that enters a fight. Nothing +that happens to a creature then reaches the template or the other creatures spawned from it. + +The instances you spawn are combatants. Hand them to +[`osrlib.core.combat`][osrlib.core.combat] to fight, which takes a +[`Character`][osrlib.core.character.Character] or a monster instance as attacker or target, and to +[`osrlib.core.effects`][osrlib.core.effects] for conditions and timed modifiers. Their treasure +comes from [`osrlib.core.treasure`][osrlib.core.treasure] using the letters in +[`TreasureRef`][osrlib.core.monsters.TreasureRef], and the experience they are worth is on the +template. + +What a monster can do beyond hitting things is on the template as +[`MonsterAbility`][osrlib.core.monsters.MonsterAbility] records, each a tag the rules match on plus +the SRD's own text. The tags osrlib acts on are `regeneration`, `energy_drain`, `poison`, +`paralysis`, `petrification`, `breath_weapon`, `gaze`, `disease`, and `uses_fire`, and each one +includes the numbers the procedures need. Every other ability is marked manual and is text for a +referee to read. What a monster resists is separate, in +[`Defenses`][osrlib.core.monsters.Defenses], which the damage rules check every time a hit lands. + +Each monster page in the SRD becomes one template per creature it describes, so a page that prints +several sizes of hydra becomes one entry per size. Each template has to be spawnable by itself, and +a template covering several creatures at once would not be. + +Hit points are rolled from the +[`MONSTER_SPAWN_STREAM`][osrlib.core.monsters.MONSTER_SPAWN_STREAM] stream, kept apart from combat +so that a change to the combat rules never alters the creatures a seeded scenario spawns. +[`IdAllocator`][osrlib.core.monsters.IdAllocator], also here, hands out the entity ids those +creatures are known by. + +Typical usage: + +```python +from osrlib.core.monsters import MONSTER_SPAWN_STREAM, IdAllocator, spawn_monster +from osrlib.core.rng import RngStreams +from osrlib.data import load_monsters + +allocator = IdAllocator() +stream = RngStreams(master_seed=3).get(MONSTER_SPAWN_STREAM) +template = load_monsters().get("troll") +troll = spawn_monster(template, id=allocator.allocate("monster"), stream=stream) +print(troll.id, troll.name, troll.max_hp, troll.armour_class, troll.thac0) +# monster-0001 Troll 42 4 13 +print(template.xp, template.ability("regeneration").params["per_round"]) +# 650 3 +``` """ from enum import StrEnum @@ -64,102 +92,183 @@ ] MONSTER_SPAWN_STREAM = "monster_spawn" -"""Stream key convention for monster spawning draws: hit point rolls.""" +"""The stream key every session uses for rolling a spawned monster's hit points. + +A stream key names one independent random-number sequence inside an +[`RngStreams`][osrlib.core.rng.RngStreams] set. Pass `streams.get(MONSTER_SPAWN_STREAM)` as the +`stream` argument of [`spawn_monster`][osrlib.core.monsters.spawn_monster]. + +It is separate from the combat stream so that a change to how a fight resolves never alters the +creatures a seeded scenario puts in front of the players. +""" class DamageKey(StrEnum): - """The damage-source keys a `harmed_only_by` gate or reduction can name. + """What a source of damage can be, for the purposes of a monster's defenses. - `holy` is carried by holy water's combat facet: an undead target admits holy - damage through any `harmed_only_by` gate — the specific rule ("holy water - inflicts damage on undead monsters") overrides the general immunity, otherwise the - wight's silver-or-magic gate would absorb the one weapon made for it. + A monster that can be hurt only by certain kinds of attack names them here in + [`Defenses.harmed_only_by`][osrlib.core.monsters.Defenses], and a monster that takes reduced + damage from a kind names it in [`DamageReduction`][osrlib.core.monsters.DamageReduction]. The + damage rules check the keys on the attack against both. + + `holy` is the one that behaves unlike the rest. Holy water has it, and it gets through any + gate when the target is undead, because the SRD says outright that holy water harms undead. The + wight's silver-or-magic gate would otherwise absorb the one weapon made for killing wights. """ SILVER = "silver" + """A silver weapon, which is what gets through the lesser undead.""" + MAGIC = "magic" + """An enchanted weapon or a spell.""" + FIRE = "fire" + """Fire, whether from a torch, a flask of oil, or a spell.""" + COLD = "cold" + """Cold, from a creature's attack or a spell.""" + HOLY = "holy" + """Holy water, which harms undead whatever else they resist.""" class Element(StrEnum): - """Energy elements that appear in monster attacks, breath weapons, and defenses.""" + """The kinds of energy a monster's breath, attack, or defense can be made of. + + A dragon's breath names one of these, and so does a creature's immunity in + [`EnergyDefense`][osrlib.core.monsters.EnergyDefense]. The damage rules match the two against + each other to decide whether an attack lands at all. + """ FIRE = "fire" + """Fire, as a red dragon breathes and a fire giant ignores.""" + COLD = "cold" + """Cold, as a white dragon breathes.""" + LIGHTNING = "lightning" + """Lightning, as a blue dragon breathes.""" + ACID = "acid" + """Acid, as a black dragon breathes.""" + GAS = "gas" + """Poisonous gas, as a green dragon breathes.""" + POISON = "poison" + """Poison delivered by an attack rather than as a cloud.""" + STEAM = "steam" + """Scalding steam, as a dragon turtle breathes.""" class EnergyDefense(BaseModel): - """An elemental defense, checked by the damage pipeline. + """How a monster resists one kind of energy. - The SRD's forms pin to two fields: `immunity` is `"all"` (a giant is "unharmed by - fire", magical or not) or `"nonmagical"` (a red dragon is immune to its own breath - and to flaming oil, but not to *fire ball*); `auto_save_magical` treats saving - throws against magical forms of the element as automatically passed (the dragons' - "automatically save versus similar attack forms"). + Read them from [`Defenses.energy`][osrlib.core.monsters.Defenses], keyed by + [`Element`][osrlib.core.monsters.Element]. The damage rules check the entry for the element of + an incoming attack before rolling any damage. Frozen. """ model_config = ConfigDict(frozen=True) immunity: Literal["all", "nonmagical"] + """`"all"` when nothing of this element can hurt the creature, magical or not, as with a fire giant and fire. + `"nonmagical"` when ordinary sources cannot but magic can: a red dragon shrugs off its own breath and a flask of + burning oil, and still takes damage from a fire ball. + """ + auto_save_magical: bool = False + """True when the creature passes any saving throw against a magical form of this element without rolling, which is + the dragons' automatic save against attacks like their own breath. + """ class DamageReduction(BaseModel): - """A damage reduction applied after the roll: divide (floor, minimum 1). + """A cut taken out of the damage a monster suffers, applied after the dice are rolled. - Empty `keys` means the reduction applies to every source that passes the - `harmed_only_by` gate (the mummy's "all damage reduced by half"); named keys - restrict it (the wraith's half damage from silver weapons). + Read them from [`Defenses.reductions`][osrlib.core.monsters.Defenses]. The damage is divided + and rounded down, and a hit that gets through always does at least 1 point. Frozen. """ model_config = ConfigDict(frozen=True) keys: tuple[DamageKey, ...] = () + """Which sources of damage are reduced. Empty means all of them that got past the monster's gate, which is the mummy + taking half from everything. Naming keys narrows it, as with the wraith, which takes half from silver weapons alone. + """ + divisor: int = Field(default=2, ge=2) + """What the damage is divided by. 2 is halving, which is what the SRD prints.""" class Defenses(BaseModel): - """The structured defense shape combat checks at damage time. + """What a monster resists, in the form the damage rules check. - `harmed_only_by` is the weapon-material gate (empty means no gate): a source must - carry at least one listed key or the hit is absorbed with no damage rolled. - `energy` maps elements to defenses; `condition_immunities` names conditions the - creature can never gain (the undead poison/mind immunity). + Read it as [`MonsterTemplate.defenses`][osrlib.core.monsters.MonsterTemplate]. The combat + procedures consult it every time a hit lands, before any damage is rolled. The SRD's own + wording for the same defenses stays on the template's abilities, for a front end to show. + Frozen. """ model_config = ConfigDict(frozen=True) harmed_only_by: tuple[DamageKey, ...] = () + """The kinds of damage that can hurt this creature at all. An attack with none of them is absorbed and no damage is + rolled, which is how a wight ignores an ordinary sword. Empty means anything hurts it. + """ + reductions: tuple[DamageReduction, ...] = () + """Cuts taken out of the damage that does get through; see + [`DamageReduction`][osrlib.core.monsters.DamageReduction]. + """ + energy: dict[Element, EnergyDefense] = {} + """How the creature resists each kind of energy; see [`EnergyDefense`][osrlib.core.monsters.EnergyDefense].""" + condition_immunities: tuple[Condition, ...] = () + """Conditions the creature can never be put into, like the undead being beyond poison, charm, and sleep. Attempts to + apply one are dropped. + """ class MonsterHitDice(BaseModel): - """A monster's Hit Dice, exactly as the stat block prints them. + """A monster's Hit Dice, as the stat block prints them. - `die` is 8 unless fractional (`½` compiles as 1d4); `modifier` is signed (`1-1` is - −1). `asterisks` is the special-ability count — XP data, not noise. `fixed_hp` - forms (`1hp`, the hydra's 8 hp per HD) roll nothing; `count` is 0 for pure - fixed-hp forms. The modifier drives the attack-matrix "1 HD higher" rule and the - negative-modifier XP-band mapping (see [`osrlib.core.tables`][osrlib.core.tables]). + Read it as [`MonsterTemplate.hit_dice`][osrlib.core.monsters.MonsterTemplate]. + [`spawn_monster`][osrlib.core.monsters.spawn_monster] rolls hit points from it, and + [`osrlib.core.tables`][osrlib.core.tables] uses it to place the monster on the attack matrix + and in an experience band. Frozen. """ model_config = ConfigDict(frozen=True) count: int = Field(default=0, ge=0) + """How many dice are rolled. 0 for a creature whose hit points are fixed.""" + die: int = 8 + """The size of each die, which is 8 for almost everything. A creature with half a Hit Die rolls 1d4 instead.""" + modifier: int = 0 + """Hit points added to or taken from the total, and it may be negative: the SRD's `1-1` is a modifier of −1. A + positive modifier also makes the creature attack as though it had one more Hit Die, and a negative one lowers the + experience band. + """ + asterisks: int = Field(default=0, ge=0) + """How many special abilities the SRD credits the creature with, which is what raises its experience award. Not + decoration. + """ + average_hp: int | None = None + """The average hit points the SRD prints for the creature, for a referee who would rather not roll. Spawning ignores + it and rolls. + """ + fixed_hp: int | None = None + """Hit points that are not rolled at all, like the creature with exactly 1 hit point or a hydra with 8 per head. + `None` when the dice decide. + """ @model_validator(mode="after") def _rollable_or_fixed(self) -> MonsterHitDice: @@ -171,26 +280,46 @@ def _rollable_or_fixed(self) -> MonsterHitDice: class MonsterAttack(BaseModel): - """One attack within a routine: `count × name (damage + effects)`. + """One attack a monster makes: what it is called, how often, how much it hurts, and what else it does. - `damage` is a dice-grammar expression; `fixed_damage` covers flat forms (`1hp`); - `fixed_damage_options` covers printed alternatives (the insect swarm's `2 or 4hp`, - armour-dependent per its prose, which stays manual). `by_weapon` marks `or by - weapon` forms, with the printed modifier. `effects` are the effect keywords from - the damage parens (`poison`, `paralysis`, `energy_drain`, `charm`, ...) compiled - to tags on the attack. + Read them from an [`AttackRoutine`][osrlib.core.monsters.AttackRoutine]. A troll's routine + contains two talon attacks and a bite, so its routine has two of these, one with a count of 2. + Frozen. """ model_config = ConfigDict(frozen=True) count: int = Field(default=1, ge=1) + """How many of this attack the monster makes in a round.""" + name: str = Field(min_length=1) + """What the attack is, as the SRD names it: `"talon"`, `"bite"`, `"weapon"`.""" + damage: str | None = None + """The damage as a dice expression, like `"1d6"`, which [`roll`][osrlib.core.dice.roll] evaluates. `None` when the + damage is fixed instead. + """ + fixed_damage: int | None = None + """Damage that is a flat number rather than a roll. `None` when `damage` says it.""" + fixed_damage_options: tuple[int, ...] = () + """The alternatives the SRD prints when the damage depends on something it leaves to the referee, like an insect + swarm doing 2 or 4 depending on the target's armour. Choosing between them is the referee's call. + """ + by_weapon: bool = False + """True when the monster attacks with whatever weapon it carries, so `damage` is what the SRD prints as typical + rather than a fixed property of the creature. + """ + by_weapon_modifier: int = 0 + """The bonus or penalty the SRD prints alongside a by-weapon attack.""" + effects: tuple[str, ...] = () + """What a hit does beyond damage, as tags like `"poison"`, `"paralysis"`, or `"energy_drain"`. Look the matching + [`MonsterAbility`][osrlib.core.monsters.MonsterAbility] up on the template for the numbers behind each. + """ @field_validator("damage") @classmethod @@ -201,79 +330,140 @@ def _damage_must_parse(cls, value: str | None) -> str | None: class AttackRoutine(BaseModel): - """One alternative attack routine — a monster acts with one routine per round.""" + """One set of attacks a monster can make in a round, chosen as a whole. + + Read them from [`MonsterTemplate.attacks`][osrlib.core.monsters.MonsterTemplate]. Most + creatures have one. A creature with more than one is choosing between them, not doing both: a + dragon either claws and bites or breathes, and the referee or your game decides which in a + given round. Frozen. + """ model_config = ConfigDict(frozen=True) attacks: tuple[MonsterAttack, ...] = Field(min_length=1) + """The attacks the routine makes, at least one; see [`MonsterAttack`][osrlib.core.monsters.MonsterAttack].""" class MovementMode(BaseModel): - """One movement mode: rate per turn, encounter rate per round, and a descriptor. + """One way a monster gets around, and how fast. - `descriptor` is `None` for plain ground movement, else the SRD's word (`flying`, - `swimming`, `gliding`, `in human form`, `in webs`, ...). + Read them from [`MonsterTemplate.movement`][osrlib.core.monsters.MonsterTemplate]. The first + is always the creature's ordinary movement. Later ones are its other ways of moving, and you + pick the one the situation calls for. Frozen. """ model_config = ConfigDict(frozen=True) rate_feet: int = Field(ge=0) + """Feet covered in one exploration turn, which is the rate used while mapping and searching.""" + encounter_rate_feet: int = Field(ge=0) + """Feet covered in one combat round, which is a third of the exploration rate.""" + descriptor: str | None = None + """How the creature is moving, in the SRD's own word: `"flying"`, `"swimming"`, `"in webs"`. `None` for walking, + which needs no word. + """ class MonsterSaves(BaseModel): - """A monster's saving throws: the five values plus the printed save-as note. + """A monster's saving throws, and the stat block's note about where they come from. - `save_as` keeps the stat block's parenthetical (`"2"`, `"NH"`, `"Cleric 1"`, - `"F1 to F3"`) for provenance and validation against the monster save bands. + Read it as [`MonsterTemplate.saves`][osrlib.core.monsters.MonsterTemplate], or read + [`MonsterInstance.saves`][osrlib.core.monsters.MonsterInstance] to get the values already + adjusted for a creature that has been drained of Hit Dice. Frozen. """ model_config = ConfigDict(frozen=True) values: SavingThrows + """The five targets; see [`SavingThrows`][osrlib.core.classes.SavingThrows].""" + save_as: str + """What the stat block says the creature saves as, like `"2"` for a second-level fighter or `"Cleric 1"`. Keep it + for display and for checking the values against the SRD's bands. The rules read `values`. + """ class MoraleAlternate(BaseModel): - """A conditional morale score: `10 (8 fear of fire)` keeps score 8 + the prose.""" + """A morale score that applies only in a particular situation. + + Read them from [`MonsterTemplate.morale_alternates`][osrlib.core.monsters.MonsterTemplate]. A + creature that fights bravely except when fire is involved has its ordinary score on the + template and the exception here. Deciding whether the condition holds is the referee's call, so + nothing applies these for you. Frozen. + """ model_config = ConfigDict(frozen=True) score: int = Field(ge=2, le=12) + """The morale score in this situation, from 2 to 12. A morale check rolls 2d6 and the creature holds on a result at + or under it. + """ + condition: str = Field(min_length=1) + """When it applies, in the SRD's own words, like `"in melee"` or `"fear of fire"`.""" class AlignmentSpec(BaseModel): - """A monster's alignment options — compound alignments compile to options. + """Which alignments a monster may have, and which it usually has. - `Chaotic` is one option; `Lawful or Neutral` is two; `Any` is all three, with - `usual` carrying `Any, usually Lawful`. + Read it as [`MonsterTemplate.alignment`][osrlib.core.monsters.MonsterTemplate]. + [`spawn_monster`][osrlib.core.monsters.spawn_monster] settles on one alignment for each + creature it spawns, because the wards that turn on alignment need a single answer. Frozen. """ model_config = ConfigDict(frozen=True) options: tuple[Alignment, ...] = Field(min_length=1) + """The alignments this creature may be, at least one. A troll is chaotic and nothing else. A creature the SRD prints + as any alignment has all three here. + """ + usual: Alignment | None = None + """Which of the options the creature usually is, when the SRD says so. `None` when it does not, and a creature with + several options and no usual one spawns unresolved unless you name its alignment yourself. + """ class XpNote(BaseModel): - """A structured XP note for a leader/chieftain/guard variant (stats stay prose).""" + """What a leader among a group of these creatures is worth in experience. + + Read them from [`MonsterTemplate.xp_notes`][osrlib.core.monsters.MonsterTemplate]. A band of + gnolls has a leader worth more than the rest, and this is that number. What else makes the + leader different stays in the template's abilities as prose, because the SRD gives it as prose. + Frozen. + """ model_config = ConfigDict(frozen=True) role: str = Field(min_length=1) + """What the variant is called, like `"leader"`, `"chieftain"`, or `"bodyguard"`.""" + xp: int = Field(ge=0) + """The experience for defeating one.""" class NumberAppearingValue(BaseModel): - """One number-appearing value: dice, a fixed count, and `see below` semantics.""" + """How many of a creature turn up, as either dice to roll or a flat number. + + Read them off [`NumberAppearing`][osrlib.core.monsters.NumberAppearing]. Roll `dice` with + [`roll`][osrlib.core.dice.roll] when it is set, take `fixed` when that is, and fall back to the + creature's own description when `see_below` is. Exactly one of the three applies. Frozen. + """ model_config = ConfigDict(frozen=True) dice: str | None = None + """The dice to roll, like `"1d8"`. `None` when the count is fixed or described in prose.""" + fixed: int | None = None + """A flat count. `None` when dice or prose decide.""" + see_below: bool = False + """True when the SRD gives no number here and the creature's description says how many appear. `dice` and `fixed` + are both `None` then. + """ @field_validator("dice") @classmethod @@ -293,96 +483,221 @@ def _dice_or_fixed(self) -> NumberAppearingValue: class NumberAppearing(BaseModel): - """The stat block's two number-appearing values: dungeon, then lair/wilderness.""" + """How many of a creature appear, which depends on where they are met. + + Read it as + [`MonsterTemplate.number_appearing`][osrlib.core.monsters.MonsterTemplate] and roll the value + that matches the encounter. Frozen. + """ model_config = ConfigDict(frozen=True) dungeon: NumberAppearingValue + """How many are met wandering in a dungeon, which is the smaller group.""" + lair: NumberAppearingValue + """How many are met in their lair or in the wilderness, where a creature is found in its full numbers.""" class TreasureRef(BaseModel): - """A faithful reference to the stat block's treasure type, as printed. + """What treasure a monster has, as the stat block prints it. - `letters` are the primary treasure-type letters (`R + S` is two); - `parenthetical` keeps bracketed letters (`P (B)`); `special` keeps labels that are - not treasure types (`Tusks`, `Honey`). + Read it as [`MonsterTemplate.treasure`][osrlib.core.monsters.MonsterTemplate], then generate + the hoard with [`generate_treasure`][osrlib.core.treasure.generate_treasure], which takes a + treasure type letter; see [the treasure type index][treasure-types-index]. A creature with no + treasure has an empty reference. Frozen. """ model_config = ConfigDict(frozen=True) letters: tuple[str, ...] = () + """The treasure type letters, like `("D",)`. More than one means roll each.""" + parenthetical: tuple[str, ...] = () + """The letters the SRD prints in brackets. They are rolled as lair treasure alongside the primary letters.""" + extra_gp: int = Field(default=0, ge=0) + """Gold pieces the stat block adds on top of the rolled treasure.""" + multiplier: int = Field(default=1, ge=1) + """What the rolled treasure is multiplied by. 1 unless the SRD says otherwise.""" + special: tuple[str, ...] = () + """Valuables that are not treasure types at all, like an elephant's tusks or a bee's honey. Nothing generates these; + show them to the referee. + """ + see_below: bool = False + """True when the creature's description says what it has rather than the treasure line.""" class MonsterAbility(BaseModel): - """A structured monster-ability tag plus the SRD prose it came from. + """One thing a monster can do, as a tag the rules read plus the SRD text it came from. - `params` carries the mechanizable values the compiler fixes (the troll's - regeneration delay and rate, a breath weapon's shape and element); `manual` marks - abilities the kernel doesn't execute — games and narrators present the - prose. + Read them from [`MonsterTemplate.abilities`][osrlib.core.monsters.MonsterTemplate], or look one + up by tag with [`MonsterTemplate.ability`][osrlib.core.monsters.MonsterTemplate.ability]. The + combat and effect procedures match on the tag and read the numbers out of `params`, so a + monster's special powers are data rather than branches in the code. Frozen. + + Not everything reduces to numbers. An ability marked manual is text for a referee to read and + act on. Nothing in osrlib does anything with it. """ model_config = ConfigDict(frozen=True) tag: str = Field(min_length=1) + """The identifier the rules match on. The ones osrlib acts on are `"regeneration"`, `"energy_drain"`, `"poison"`, + `"paralysis"`, `"petrification"`, `"breath_weapon"`, `"gaze"`, `"disease"`, and `"uses_fire"`. + """ + name: str = Field(min_length=1) + """The ability's name as the SRD prints it, for display.""" + prose: str + """The SRD's own description, which is what to show a player or referee.""" + manual: bool = False + """True when nothing in osrlib acts on this ability and the prose is the whole of it.""" + params: dict[str, int | str | bool | tuple[int | str, ...]] = {} + """The values the rules read, like a troll's regeneration delay and rate, or the number of levels an energy drain + takes. Empty when there are none. + """ class AcAlternate(BaseModel): - """An alternate armour class with its printed condition (`9 [10] in human form`).""" + """An armour class a monster has only in particular circumstances. + + Read them from + [`MonsterTemplate.ac_alternates`][osrlib.core.monsters.MonsterTemplate]. A creature that + changes shape, or a band whose members wear different armour, prints more than one armour class + and this contains the ones that are not the creature's ordinary value. Deciding when one applies + is the referee's call. Nothing switches between them for you. Frozen. + """ model_config = ConfigDict(frozen=True) ac: int + """The armour class in the descending presentation, where lower is better.""" + ac_ascending: int + """The same defense in the ascending presentation, where higher is better.""" + condition: str = "" + """When this value applies, in the SRD's own words, like `"in human form"`. Empty when the SRD prints the + alternative without saying when it holds, as it does for a band whose members are armed differently. + """ class MonsterTemplate(BaseModel): - """A monster stat block, compiled from its SRD page. + """A monster's stat block: everything true of every creature of that kind. - Frozen SRD data: play never mutates a template. `page` is the source-page grouping - (variants of one page stay associable); `attack_roll_required` is False for the - `No hit roll required` AC sentinel (attacks auto-hit). `xp` is the printed value, - cross-validated against the XP-awards table at compile time. + Get one from [`load_monsters`][osrlib.data.load_monsters]`().get(monster_id)`; see + [the monster id index][monsters-index] for the ids. Then call + [`spawn_monster`][osrlib.core.monsters.spawn_monster] to put an actual creature in front of the + players. + + It is frozen, and play never writes to it. A template is shared by every creature of its kind, + while the hit points one creature has left, the wounds it has taken, and the conditions on it + all live on the [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] spawned from it. That + separation is why a wounded troll does not weaken every other troll in the dungeon. """ model_config = ConfigDict(frozen=True) id: str + """The monster id, like `"troll"`, which is what you look it up by.""" + name: str + """The creature's name as the SRD prints it, for display.""" + page: str + """Which SRD page this template was compiled from. Templates sharing a page are variants of one creature, like the + sizes of hydra or the colours of dragon. + """ + intro: str = "" + """The SRD's description of the creature, for a referee or a narrator to read out.""" + ac: int | None = None + """Armour class in the descending presentation. `None` for a creature whose attackers need no hit roll.""" + ac_ascending: int | None = None + """The same defense in the ascending presentation, or `None` alongside `ac`.""" + ac_alternates: tuple[AcAlternate, ...] = () + """Armour classes that apply only in particular circumstances; see + [`AcAlternate`][osrlib.core.monsters.AcAlternate]. + """ + attack_roll_required: bool = True + """False for a creature no attack roll is needed against, like a green slime, whose attacks land without one. Both + armour class fields are `None` then. + """ + hit_dice: MonsterHitDice + """The dice its hit points are rolled on; see [`MonsterHitDice`][osrlib.core.monsters.MonsterHitDice].""" + attacks: tuple[AttackRoutine, ...] = () + """The sets of attacks it can make, one chosen per round; see [`AttackRoutine`][osrlib.core.monsters.AttackRoutine]. + """ + thac0: int = Field(ge=2, le=20) + """The number it needs to hit armour class 0 under descending armour class.""" + attack_bonus: int = Field(ge=-1) + """The same attack under ascending armour class.""" + movement: tuple[MovementMode, ...] = Field(min_length=1) + """How it gets around and how fast, ordinary movement first; see + [`MovementMode`][osrlib.core.monsters.MovementMode]. + """ + saves: MonsterSaves + """Its saving throws; see [`MonsterSaves`][osrlib.core.monsters.MonsterSaves].""" + morale: int | None = Field(default=None, ge=2, le=12) + """How willing it is to keep fighting, from 2 to 12. A morale check rolls 2d6 and the creature holds on a result at + or under it. `None` when the stat block prints no morale score. + """ + morale_alternates: tuple[MoraleAlternate, ...] = () + """Morale scores that apply only in particular circumstances; see + [`MoraleAlternate`][osrlib.core.monsters.MoraleAlternate]. + """ + alignment: AlignmentSpec + """Which alignments it may have; see [`AlignmentSpec`][osrlib.core.monsters.AlignmentSpec].""" + xp: int = Field(ge=0) + """The experience for defeating one, as the SRD prints it.""" + xp_notes: tuple[XpNote, ...] = () + """What a leader among them is worth instead; see [`XpNote`][osrlib.core.monsters.XpNote].""" + number_appearing: NumberAppearing + """How many turn up, which depends on where they are met; see + [`NumberAppearing`][osrlib.core.monsters.NumberAppearing]. + """ + treasure: TreasureRef = TreasureRef() + """What they have; see [`TreasureRef`][osrlib.core.monsters.TreasureRef].""" + abilities: tuple[MonsterAbility, ...] = () + """What it can do beyond attacking; see [`MonsterAbility`][osrlib.core.monsters.MonsterAbility].""" + defenses: Defenses = Defenses() + """What it resists, in the form the damage rules check; see [`Defenses`][osrlib.core.monsters.Defenses].""" + categories: tuple[str, ...] = () + """What kind of thing it is, as tags like `"undead"`, `"person"`, and `"enchanted"`. Spells and effects that single + out a kind of creature match on these. + """ + overrides_applied: tuple[str, ...] = () + """The names of the compile-time corrections applied to this creature's SRD page. Provenance for anyone checking the + data against the SRD. Nothing in play reads it. + """ @model_validator(mode="after") def _ac_present_when_rolled_against(self) -> MonsterTemplate: @@ -394,13 +709,29 @@ def _ac_present_when_rolled_against(self) -> MonsterTemplate: return self def ability(self, tag: str) -> MonsterAbility | None: - """Return the first ability with `tag`, or `None`. + """Return this monster's ability with `tag`, or `None` when it has none. + + Use it to ask whether a creature has a power and to read the numbers behind it in one step: + the troll's regeneration rate, the number of levels a wight's touch drains, the shape and + element of a dragon's breath. Args: - tag: The ability tag, e.g. `"regeneration"`. + tag: The ability tag to look for, like `"regeneration"` or `"energy_drain"`. Returns: - The ability, or `None` when the monster doesn't have it. + The first ability with that tag, or `None`. + + Examples: + ```python + from osrlib.data import load_monsters + + wight = load_monsters().get("wight") + drain = wight.ability("energy_drain") + print(drain.params["levels"], drain.params["xp_policy"]) + # 1 halfway + print(wight.ability("breath_weapon")) + # None + ``` """ for ability in self.abilities: if ability.tag == tag: @@ -409,11 +740,17 @@ def ability(self, tag: str) -> MonsterAbility | None: class MonsterCatalog(BaseModel): - """The loaded monster list, with id lookup.""" + """Every monster, as returned by [`load_monsters`][osrlib.data.load_monsters]. + + Look one up by id with [`get`][osrlib.core.monsters.MonsterCatalog.get], or iterate `monsters` + to filter by Hit Dice, category, or whatever your encounter table needs. The catalog is loaded + once and cached, so calling the loader again is free. Frozen. + """ model_config = ConfigDict(frozen=True) monsters: tuple[MonsterTemplate, ...] + """The templates, in the order the data file lists them. Ids are unique.""" @model_validator(mode="after") def _ids_must_be_unique(self) -> MonsterCatalog: @@ -423,18 +760,30 @@ def _ids_must_be_unique(self) -> MonsterCatalog: return self def get(self, monster_id: str) -> MonsterTemplate: - """Return the monster template for `monster_id`. + """Return the monster with `monster_id`. Args: - monster_id: A monster id from [`load_monsters`][osrlib.data.load_monsters] - — see [the monster id index][monsters-index], e.g. `"troll"` or - `"red_dragon"`. + monster_id: The id to look up, like `"troll"` or `"red_dragon"`; see + [the monster id index][monsters-index] for all of them. Returns: - The monster template. + The monster template. Spawn a creature from it with + [`spawn_monster`][osrlib.core.monsters.spawn_monster]. Raises: - ValueError: If no monster has that id. + ValueError: If no monster has that id. The message names the id you passed. + + Examples: + ```python + from osrlib.data import load_monsters + + catalog = load_monsters() + troll = catalog.get("troll") + print(troll.name, troll.hit_dice.count, troll.xp) + # Troll 6 650 + print([template.id for template in catalog.monsters if template.page == "Hydra.md"]) + # ['hydra_10', 'hydra_11', 'hydra_12', 'hydra_5', 'hydra_6', 'hydra_7', 'hydra_8', 'hydra_9'] + ``` """ for template in self.monsters: if template.id == monster_id: @@ -443,50 +792,90 @@ def get(self, monster_id: str) -> MonsterTemplate: class MonsterInstance(BaseModel): - """A mutable monster spawned from a frozen template. + """One creature in play, spawned from a frozen [`MonsterTemplate`][osrlib.core.monsters.MonsterTemplate]. + + Get one from [`spawn_monster`][osrlib.core.monsters.spawn_monster]. This is what takes damage, + gains conditions, and dies. The template it came from never changes, and every other creature + spawned from it is unaffected by what happens here. + + It offers the same surface a [`Character`][osrlib.core.character.Character] does, which is + THAC0, attack bonus, both armour classes, saving throws, conditions, and stat modifiers, so + the functions in [`osrlib.core.combat`][osrlib.core.combat] and + [`osrlib.core.effects`][osrlib.core.effects] take either without caring which they got. - Exposes the same combatant surface as - [`Character`][osrlib.core.character.Character] (THAC0, attack bonus, AC both ways, - saves, conditions, stat modifiers), so combat functions take either. `nonregen_damage` - is the troll's non-regenerable damage ledger (fire and acid accrue here; - regeneration never heals it, and the troll is permanently dead only when this - ledger alone reaches max HP). `last_damaged_round` feeds regeneration's damage - delay; `breath_uses_today` tracks the dragons' three-per-day limit. `alignment` is - the operative alignment resolved at spawn (a multi-option `AlignmentSpec` alone - can't answer *protection from evil*'s ward gate); `None` means unresolved, which - the ward treats as differing. + Anything the template already says is read through `template` rather than copied here, and the + properties below do that for you where a drained creature would otherwise read the wrong value. """ model_config = ConfigDict(validate_assignment=True) id: str + """The entity id, usually from an [`IdAllocator`][osrlib.core.monsters.IdAllocator]. Events name the creature by it. + """ + template: MonsterTemplate + """The stat block this creature was spawned from. Read anything the creature has in common with its kind from here. + """ + max_hp: int = Field(ge=1) + """The hit points it was spawned with.""" + current_hp: int = Field(ge=0) + """The hit points it has left, from 0 up to `max_hp`. Reaching 0 means it has dropped. Death itself is the `dead` + condition, applied by [`kill`][osrlib.core.effects.kill]. + """ + conditions: tuple[ActiveCondition, ...] = () + """The conditions on it, applied and cleared through [`osrlib.core.effects`][osrlib.core.effects].""" + stat_modifiers: tuple[ActiveModifier, ...] = () + """Timed bonuses and penalties on it, from spells and effects.""" + alignment: Alignment | None = None + """The alignment this creature actually has, settled when it was spawned. `None` when the template offered several + and neither you nor the template named one. A ward that turns on alignment then treats it as differing, which errs + toward protecting the party. + """ + nonregen_damage: int = Field(default=0, ge=0) + """Damage a regenerating creature can never heal. Fire and acid land here, and a troll stays dead only once this + alone reaches its maximum hit points. + """ + last_damaged_round: int | None = None + """The combat round in which it was last hurt, which is what regeneration counts its delay from. `None` before + anything has hurt it. + """ + breath_uses_today: int = Field(default=0, ge=0) + """How many times it has used its breath weapon today. A creature with a breath weapon gets three uses a day.""" + drained_hd: int = Field(default=0, ge=0) + """How many Hit Dice have been drained from it. Its THAC0, attack bonus, and saving throws all re-derive from what + is left. + """ @property def name(self) -> str: - """The template's name.""" + """The creature's name, from its template. Use it wherever you show the creature to a player.""" return self.template.name @property def hit_dice_count(self) -> int: - """The instance's current Hit Dice count: the template's minus any drained.""" + """How many Hit Dice this creature still has: its template's, less any that were drained away. + + Its THAC0, attack bonus, and saving throws all follow from this rather than from the + template, which is why draining a creature weakens it in every way at once. + """ return max(0, self.template.hit_dice.count - self.drained_hd) @property def thac0(self) -> int: - """The printed THAC0 (already reflecting the bonus-hit-points 1-HD-higher rule). + """The number this creature needs to hit armour class 0 under descending armour class. - A drained instance re-derives from its reduced Hit Dice via the attack matrix - rows. + It is the template's printed value, which already accounts for a creature whose Hit Dice + have a bonus attacking as though it had one more. A creature that has been drained looks + its value up again for the Hit Dice it has left. """ if self.drained_hd == 0: return self.template.thac0 @@ -496,7 +885,11 @@ def thac0(self) -> int: @property def attack_bonus(self) -> int: - """The printed ascending-AC attack bonus; drained instances re-derive.""" + """The bonus this creature adds to an attack roll under ascending armour class. + + The ascending presentation of [`thac0`][osrlib.core.monsters.MonsterInstance.thac0], and it + re-derives after a drain in the same way. + """ if self.drained_hd == 0: return self.template.attack_bonus from osrlib.core.tables import thac0_for_hd @@ -505,20 +898,20 @@ def attack_bonus(self) -> int: @property def armour_class(self) -> int | None: - """Descending AC; `None` when no hit roll is required.""" + """The creature's armour class, where lower is better. `None` for a creature no attack roll is made against.""" return self.template.ac @property def armour_class_ascending(self) -> int | None: - """Ascending AC; `None` when no hit roll is required.""" + """The creature's armour class, where higher is better. `None` for a creature no attack roll is made against.""" return self.template.ac_ascending @property def saves(self) -> SavingThrows: - """The stat block's saving throw values; drained instances re-derive. + """The five saving throw targets for this creature. - A drained instance reads the monster saving-throw band for its reduced Hit - Dice. + The template's printed values, unless the creature has been drained of Hit Dice, in which + case it saves as the band its remaining Hit Dice put it in. """ if self.drained_hd == 0: return self.template.saves.values @@ -530,54 +923,90 @@ def saves(self) -> SavingThrows: @property def melee_modifier(self) -> int: - """Monsters' attack and damage rolls are not modified by STR (RAW).""" + """Always 0: monsters have no STR score, and the SRD gives them no bonus in its place. + + It exists so that the combat functions can read the same property on a monster as on a + [`Character`][osrlib.core.character.Character]. + """ return 0 @property def missile_modifier(self) -> int: - """Monsters' attack rolls are not modified by DEX (RAW).""" + """Always 0: monsters have no DEX score, and the SRD gives them no missile bonus in its place.""" return 0 @property def initiative_modifier(self) -> int: - """Monsters take a caller-supplied initiative modifier; the intrinsic one is 0.""" + """Always 0: a monster has no initiative modifier of its own. + + The SRD leaves a monster's initiative to the referee, so pass one to the initiative roll + yourself when you want it. + """ return 0 def spawn_monster( template: MonsterTemplate, *, id: str, stream: RngStream, alignment: Alignment | None = None ) -> MonsterInstance: - """Spawn a mutable instance from a frozen template, rolling hit points from its Hit Dice. + """Put one creature into play: roll its hit points and give it an identity of its own. - `template` is immutable stat-block data: load the catalog once with - [`load_monsters`][osrlib.data.load_monsters] and call `spawn_monster` once per - creature that enters play. Every instance gets its own hit points, conditions, and - stat modifiers, so damage and status changes on one instance never touch the - shared template or any other instance spawned from it. + Call it once per creature an encounter puts in front of the players. Load the catalog once with + [`load_monsters`][osrlib.data.load_monsters] and spawn from the same template as often as you + like. Each creature gets its own hit points, its own wounds, and its own conditions, and + nothing that happens to one reaches the template or any of its siblings. That is the reason to + spawn rather than pass templates around. - Hit points are the sum of `count` rolls of the hit die (d8, or d4 for ½ HD) plus - the signed modifier, minimum 1; fixed-hp forms (`1hp`, the hydra's 8 hp per HD) - roll nothing and are exact. The operative alignment resolves at spawn: the - caller's choice wins, else the template's `usual`, else its sole option; a - multi-option template with no usual and no caller choice stays unresolved, which - alignment-gated wards treat as differing (erring protective). + Hand what you get to [`osrlib.core.combat`][osrlib.core.combat] to fight it. Its treasure comes + from [`generate_treasure`][osrlib.core.treasure.generate_treasure] using the letters on the + template. + + Hit points are rolled from the template's Hit Dice and floored at 1, so even the unluckiest + roll leaves a creature standing. A creature whose hit points the SRD fixes, like the one + with exactly 1 or a hydra with 8 per head, gets that number and rolls nothing. + + The creature's alignment is settled here rather than left open, because a ward like + *protection from evil* has to have something to test. Your choice wins, then the template's + usual alignment, then its only option. A creature whose template offers + several with no usual one and no choice from you is left unresolved, and such a ward then + treats it as differing, which errs toward protecting the party. Args: - template: The frozen template to spawn from — get one from + template: The stat block to spawn from, from [`load_monsters`][osrlib.data.load_monsters]`().get(monster_id)`; see - [the monster id index][monsters-index] for valid ids. - id: The instance's entity id, conventionally from an + [the monster id index][monsters-index] for the ids. + id: The entity id to give it, usually + `allocator.allocate("monster")` from an [`IdAllocator`][osrlib.core.monsters.IdAllocator]. - stream: The RNG stream for the hit point rolls, conventionally - [`MONSTER_SPAWN_STREAM`][osrlib.core.monsters.MONSTER_SPAWN_STREAM]. - alignment: The encounter's or script's alignment choice; must be one of the - template's options. + stream: The stream to roll hit points on, conventionally + `streams.get(`[`MONSTER_SPAWN_STREAM`][osrlib.core.monsters.MONSTER_SPAWN_STREAM]`)`. + No draw is taken for a creature with fixed hit points. + alignment: The alignment this particular creature has, when you want to choose. It must be + one the template allows. Returns: - The spawned instance at full hit points. + The creature, at full hit points, with no conditions and no wounds. Raises: - ValueError: If `alignment` is not among the template's options. + ValueError: If `alignment` is not one the template allows. + + Examples: + ```python + from osrlib.core.monsters import MONSTER_SPAWN_STREAM, IdAllocator, spawn_monster + from osrlib.core.rng import RngStreams + from osrlib.data import load_monsters + + allocator = IdAllocator() + stream = RngStreams(master_seed=3).get(MONSTER_SPAWN_STREAM) + template = load_monsters().get("troll") + first = spawn_monster(template, id=allocator.allocate("monster"), stream=stream) + second = spawn_monster(template, id=allocator.allocate("monster"), stream=stream) + print(first.id, first.max_hp, second.id, second.max_hp) + # monster-0001 42 monster-0002 22 + + first.current_hp -= 10 + print(first.current_hp, second.current_hp) + # 32 22 + ``` """ if alignment is not None and alignment not in template.alignment.options: raise ValueError(f"{template.id} alignment options are {template.alignment.options}, got {alignment}") @@ -594,26 +1023,50 @@ def spawn_monster( class IdAllocator(BaseModel): - """A monotonic per-prefix entity ID counter (`monster-0001`, `effect-0001`). - - A [`GameSession`][osrlib.crawl.session.GameSession] adopts one instance so every - entity it creates — monsters, effects, NPCs, valuables — gets a unique id; - standalone callers can create their own instead. Serializable — the counters are - plain state. + """Hands out entity ids that no two things in a game share. + + Every creature, effect, and valuable a game creates needs an id, and this is what gives them + one. A [`GameSession`][osrlib.crawl.session.GameSession] keeps its own and passes it to + everything that allocates, so pass the session's allocator rather than a fresh one when you are + inside a session. Outside one, build your own. + + Ids count up per prefix and never repeat, which is what lets a save and its replay refer to the + same creature. The counters are ordinary state, so an allocator serializes with the rest of a + save and resumes where it left off. + + Examples: + ```python + from osrlib.core.monsters import IdAllocator + + allocator = IdAllocator() + print(allocator.allocate("monster"), allocator.allocate("monster")) + # monster-0001 monster-0002 + print(allocator.allocate("effect")) + # effect-0001 + ``` """ model_config = ConfigDict(validate_assignment=True) counters: dict[str, int] = {} + """How many ids have been handed out under each prefix. Written by + [`allocate`][osrlib.core.monsters.IdAllocator.allocate]; you never set it yourself. + """ def allocate(self, prefix: str) -> str: - """Return the next id for `prefix`. + """Return the next unused id under `prefix`. + + Each prefix counts independently and never repeats, so calling it twice with the same + prefix gives two different ids. It mutates the allocator, which is the point: the id is + spent once it is returned. Args: - prefix: The entity kind, e.g. `"monster"` or `"effect"`. + prefix: What kind of thing is being named, like `"monster"`, `"effect"`, `"npc"`, + or `"valuable"`. Returns: - The allocated id, `{prefix}-{n:04d}` with `n` starting at 1. + The id, which is the prefix, a hyphen, and a number padded to four digits, counting + from `0001`. """ n = self.counters.get(prefix, 0) + 1 self.counters[prefix] = n diff --git a/src/osrlib/core/npc.py b/src/osrlib/core/npc.py index 7e4985a..8905ca6 100644 --- a/src/osrlib/core/npc.py +++ b/src/osrlib/core/npc.py @@ -1,33 +1,63 @@ -"""NPC adventuring parties: the SRD generation procedure from the character model. - -Basic and Expert Adventurers generate through the same character kernel PCs use — -composition (the caller rolls the count: the wandering table's printed dice or the -compiled composition dice), one alignment for the whole party (RAW offers either; a -single alignment drives reaction, parley, and ward interactions coherently), then per -member in order: the d8 class-and-level row, the level dice by kind, 3d6-in-order -ability scores, hit points by rolling the class hit die per level through -[`level_up`][osrlib.core.classes.level_up] (CON applied, minimum 1 per level), XP at -the class's threshold for the rolled level, the equipment kit, and rolled spell -picks. All of those draw from the -[`NPC_PARTY_STREAM`][osrlib.core.npc.NPC_PARTY_STREAM] stream; the party's treasure -and Expert magic items draw from the treasure stream instead, since they are treasure -procedures and belong to its statistics. - -osrlib adopts several documented adaptations here (see the adaptations register): NPC -adventurers skip class ability-score requirements (RAW's procedure rolls class before -scores and names no re-roll); the equipment kits are invented over RAW's "normal -adventuring gear"; casters roll each open slot uniformly from the class-legal spells -of that level ("choose or roll" — rolling is the deterministic branch), with arcane -spell books equal to exactly the memorized picks; Expert magic items roll at 5% per -level per suitable sub-table in the master table's printed order, unusable rolls -ignored with no re-roll, and rolled wearable or wieldable items are equipped when -better than the kit piece (higher effective AC, or any enchantment over a mundane -arm). - -Part of the core kernel. Call -[`generate_npc_party`][osrlib.core.npc.generate_npc_party] to run the whole -procedure; it builds on [`osrlib.core.character`][osrlib.core.character], whose model -and creation functions it reuses for each party member. +"""NPC adventuring parties: a rival band of adventurers, rolled up from the SRD's procedure. + +[`generate_npc_party`][osrlib.core.npc.generate_npc_party] is the entry point. Tell it how many +members and whether they are the Basic or the Expert kind, hand it two seeded streams and an +[`IdAllocator`][osrlib.core.monsters.IdAllocator], and you get an +[`NpcParty`][osrlib.core.npc.NpcParty]: a band of classed characters with gear, memorized spells, +and treasure to take off them. Use it when a wandering monster roll turns up other adventurers, +or when you want a rival party for an encounter you are writing. + +Its members are ordinary [`Character`][osrlib.core.character.Character] models, the same ones the +players use, so everything else in the library takes them as they are: they fight through +[`osrlib.core.combat`][osrlib.core.combat], cast through +[`osrlib.core.spells`][osrlib.core.spells], and can be put into a +[`Party`][osrlib.crawl.party.Party] if you want to run them as one. +[`npc_defeat_xp`][osrlib.core.npc.npc_defeat_xp] gives the experience a party earns for defeating +one of them. + +How many adventurers appear is not decided here. Roll the count from the wandering monster table +that produced the encounter, then pass it in. + +Every member's own draws come from the +[`NPC_PARTY_STREAM`][osrlib.core.npc.NPC_PARTY_STREAM] stream: the class and level, the ability +scores, the hit points, the spells they have prepared. The party's treasure and the Expert band's +magic items come from the [`TREASURE_STREAM`][osrlib.core.treasure.TREASURE_STREAM] stream instead, +because they are treasure rolls and belong with the rest of a game's treasure statistics. + +Four things here are osrlib's reading rather than the SRD's letter, and all four appear in +[the adaptations register](https://mmacy.github.io/osrlib-python/adaptations/), the site page that +collects the places where osrlib settles an ambiguous rule one way or supplies a default the +tabletop game leaves to a referee. NPC adventurers are not checked against their class's ability +requirements, because the SRD's procedure rolls the class before the scores and offers no re-roll. +The equipment kits are osrlib's, standing in for the SRD's "normal adventuring gear". Casters get +spells rolled at random from the ones their class may cast, since the SRD lets the referee choose +or roll and only rolling is repeatable. An Expert band's magic items are rolled at 5% per level +against each kind of item the member could use, and an item nobody can use is dropped rather than +re-rolled. + +Typical usage: + +```python +from osrlib.core.monsters import IdAllocator +from osrlib.core.npc import NPC_PARTY_STREAM, generate_npc_party +from osrlib.core.rng import RngStreams + +streams = RngStreams(master_seed=5) +party = generate_npc_party( + "basic", + count=3, + npc_stream=streams.get(NPC_PARTY_STREAM), + treasure_stream=streams.get("treasure"), + allocator=IdAllocator(), +) +print(party.alignment.value) +# neutral +for member in party.members: + print(member.id, member.class_id, member.level, member.max_hp) +# npc-0001 halfling 2 4 +# npc-0002 thief 3 14 +# npc-0003 fighter 1 6 +``` """ from typing import Any, Literal @@ -63,17 +93,28 @@ ] NPC_PARTY_STREAM = "npc_party" -"""Stream key for NPC-party generation: composition, class, level, scores, hp, spells.""" +"""The stream key every session uses for rolling up NPC adventurers. + +A stream key names one independent random-number sequence inside an +[`RngStreams`][osrlib.core.rng.RngStreams] set. Pass `streams.get(NPC_PARTY_STREAM)` as the +`npc_stream` argument of [`generate_npc_party`][osrlib.core.npc.generate_npc_party], which draws +the party's alignment and then each member's class, level, ability scores, hit points, and spells +from it. -# The pinned kits (registered — RAW says only "normal adventuring gear"): weapons and -# armour per class, equipped on generation; every member also carries a -# standard-rations lot, a waterskin, and a torch lot (the gear the survival -# procedures read). +The party's treasure and its magic items do not come from this stream. They are treasure rolls, and +they draw from [`TREASURE_STREAM`][osrlib.core.treasure.TREASURE_STREAM] so that a change to how NPC +parties are built does not shift the treasure a game has already recorded. +""" + +# The kits are osrlib's, standing in for the SRD's "normal adventuring gear": weapons and +# armour per class, worn and wielded at generation. Every member also gets a lot of standard +# rations, a waterskin, and a lot of torches, which are the supplies the survival procedures read. _KITS: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = { # class_id: (item ids granted, item ids equipped) "cleric": (("mace", "chainmail", "shield"), ("mace", "chainmail", "shield")), - # The battle axe is two-handed, so the dwarf carries the shield unwielded — - # the equip conflict is enforced at equip time (pinned). + # The battle axe is two-handed, so the dwarf carries the shield without wielding it. The + # equip validator refuses the combination, so the kit lists the shield as granted but not + # equipped. "dwarf": (("battle_axe", "chainmail", "shield"), ("battle_axe", "chainmail")), "elf": (("sword", "long_bow", "arrows", "chainmail"), ("sword", "long_bow", "chainmail")), "fighter": (("sword", "chainmail", "shield"), ("sword", "chainmail", "shield")), @@ -87,7 +128,7 @@ _SUPPLIES = ("rations_standard", "waterskin", "torch") -# The master table's printed sub-table order — the Expert magic item rolls walk it. +# The order the master table prints its sub-tables in. The Expert magic item rolls walk it. _SUB_TABLE_ORDER = ( MagicItemType.ARMOUR, MagicItemType.MISC, @@ -101,34 +142,63 @@ class NpcParty(BaseModel): - """A generated NPC adventuring party: the members, their alignment, the loot. + """A band of NPC adventurers: who they are, what they believe, and what they carry between them. - `treasure` is the group's shared U + V bundle (rolled once), carried as a group - bundle that drops with the loot flow — slain or surrendered; a routed party - keeps it. + Returned by [`generate_npc_party`][osrlib.core.npc.generate_npc_party]. Run them as an + encounter: roll reaction with [`osrlib.crawl.encounter`][osrlib.crawl.encounter], fight them + through [`osrlib.core.combat`][osrlib.core.combat], and award + [`npc_defeat_xp`][osrlib.core.npc.npc_defeat_xp] per member if the players win. """ model_config = ConfigDict(validate_assignment=True) kind: Literal["basic", "expert"] + """`"basic"` for a band of low-level adventurers or `"expert"` for a seasoned one. It decided the level dice, the + armour the members wear, and whether they carry magic items. + """ + alignment: Alignment + """The alignment the whole band shares. One roll covers everyone, so reactions, parleys, and the wards that turn on + alignment all have a single answer. + """ + members: list[Character] + """The adventurers, as ordinary [`Character`][osrlib.core.character.Character] models with ids from the allocator + you passed. Everything in the library that takes a character takes these. + """ + treasure: GeneratedTreasure + """What the band carries between them, rolled once for the group rather than per member. It changes hands when they + are killed or surrender. A band that runs away keeps it. + """ def npc_defeat_xp(level: int) -> int: - """Return the XP award for defeating an NPC adventurer of `level`. + """Return the experience a party earns for defeating one NPC adventurer of this level. + + Call it once per defeated member of an [`NpcParty`][osrlib.core.npc.NpcParty], add the results + together with whatever else the party overcame, and hand the total to + [`apply_xp`][osrlib.core.classes.apply_xp] for each surviving character. - osrlib adopts the reading that an NPC adventurer's XP award is the OSE SRD's XP - awards table value for HD equal to the NPC's level, no plus-category, no ability - bonuses — RAW prices monsters, not classed NPCs, and level-as-HD is the straight + The SRD prices monsters by Hit Dice and says nothing about classed NPCs, so osrlib prices an + NPC adventurer as a monster of as many Hit Dice as they have levels, with no bonus for special + abilities. [The adaptations register](https://mmacy.github.io/osrlib-python/adaptations/), the + site page that collects the places where osrlib settles an ambiguous rule one way, records the reading. Args: level: The NPC's class level. Returns: - The base XP award. + The experience for defeating them. + + Examples: + ```python + from osrlib.core.npc import npc_defeat_xp + + print(npc_defeat_xp(1), npc_defeat_xp(3), npc_defeat_xp(5)) + # 10 35 175 + ``` """ label = xp_band_label(MonsterHitDice(count=level, die=8)) return load_combat_tables().xp_row(label).base @@ -154,7 +224,7 @@ def _grant_kit(member: Character, definition: ClassDefinition, kind: str) -> Non def _roll_spells(member: Character, definition: ClassDefinition, stream: RngStream) -> None: - """Roll each open slot uniformly from the class-legal spells of its level.""" + """Fill each of a caster's memorization slots with a spell drawn at random from its level.""" profile = caster_profile(definition) if profile is None: return @@ -167,7 +237,7 @@ def _roll_spells(member: Character, definition: ClassDefinition, stream: RngStre picks.append(MemorizedSpell(spell_id=candidates[stream.randbelow(len(candidates))].id)) member.memorized_spells = tuple(picks) if profile.kind == "arcane": - # Normal forms: the spell book equals exactly the memorized picks (pinned). + # An arcane NPC's spell book contains exactly the spells they have memorized. book: list[str] = [] for pick in picks: if pick.spell_id not in book: @@ -183,12 +253,11 @@ def _item_usable(member: Character, definition: ClassDefinition, instance: Magic def _maybe_equip_upgrade(member: Character, definition: ClassDefinition, instance: MagicItemInstance) -> None: - """Equip a rolled arm when it is better than the kit piece. + """Equip a rolled weapon or piece of armour when it beats the one from the kit. - Better means higher effective AC for armour and shields, or any enchantment - over a mundane arm for swords and weapons; cursed forms test as +1 (their - printed deception) and are equipped like any other — the curse reveals in - play. + Better means a higher armour class for armour and shields, or any enchantment at all over a + mundane weapon. A cursed item tests as though it were a +1, which is what it claims to be, so + it gets equipped like any other, and the curse comes out in play. """ template = magic_item_template(instance) inventory = member.inventory @@ -217,7 +286,7 @@ def _maybe_equip_upgrade(member: Character, definition: ClassDefinition, instanc def _roll_expert_items( member: Character, definition: ClassDefinition, kind: str, treasure_stream: RngStream, allocator: Any ) -> None: - """The Expert parties' magic items: 5% per level per suitable sub-table (RAW).""" + """Roll an Expert band member's magic items: 5% per level against each sub-table they could use.""" if kind != "expert": return profile = caster_profile(definition) @@ -248,25 +317,66 @@ def generate_npc_party( treasure_stream: RngStream, allocator: Any, ) -> NpcParty: - """Generate an NPC adventuring party by the SRD procedure. + """Roll up a band of NPC adventurers, complete with gear, spells, and treasure. - Draw order: one d6 alignment roll for the whole party, then per member — the d8 - class-and-level row, the level dice by `kind`, 3d6-in-order scores, the - first-level hit die, one `level_up` roll per level above first, and the spell - picks — all on `npc_stream`; then each member's Expert magic items and finally - the shared U + V group treasure on `treasure_stream`. + Use it when your game needs other adventurers: a wandering encounter, a rival party in a keyed + room, a patrol. You supply the size, because the table that produced the encounter sets how many + appear. Everything else is rolled here. + + The band shares one alignment, rolled once, so their reaction to the players and their + vulnerability to alignment-gated wards have a single answer. Then each member in turn gets a + class and a level from the SRD's table, ability scores rolled 3d6 in order, hit points rolled + per level through [`level_up`][osrlib.core.classes.level_up], experience set to the threshold + for their level, an equipment kit their class can use, and, if they cast, spells prepared at + random from their class's list. An Expert band wears heavier armour and each member gets a 5% + chance per level at each kind of magic item they could use. + + Members are not checked against their class's ability requirements, because the SRD rolls their + class before their scores. An elf here may have an INT a player character would not be allowed. + + The draws come off the two streams in a fixed order, which is what makes a seeded encounter + repeatable: the alignment and every member's own rolls from `npc_stream` in member order, then + each member's magic items and finally the shared treasure from `treasure_stream`. Args: - kind: `"basic"` (levels 1d3) or `"expert"` (per-row level dice). - count: The party size; the caller rolls it (the wandering row's printed - dice, or the compiled composition dice). - npc_stream: The `npc_party` stream. - treasure_stream: The treasure stream — items and the group bundle are - treasure procedures and belong to its statistics, not the NPC stream's. - allocator: The id allocator (`npc`, `magic-item`, and `valuable` prefixes). + kind: `"basic"` for a band of levels 1 to 3, or `"expert"` for a seasoned one whose level + dice depend on the class rolled. + count: How many adventurers appear. Roll it from the encounter table that sent them. + npc_stream: The stream for the members themselves, conventionally + `streams.get(`[`NPC_PARTY_STREAM`][osrlib.core.npc.NPC_PARTY_STREAM]`)`. + treasure_stream: The stream for their magic items and their shared treasure, conventionally + `streams.get(`[`TREASURE_STREAM`][osrlib.core.treasure.TREASURE_STREAM]`)`, so those + rolls land in the treasure statistics with every other treasure roll. + allocator: The [`IdAllocator`][osrlib.core.monsters.IdAllocator] that names the members and + the items and valuables they carry. Pass the session's own allocator so nothing + collides with ids already in play. Returns: - The generated party. + The band, its shared alignment, and its treasure. + + Examples: + ```python + from osrlib.core.monsters import IdAllocator + from osrlib.core.npc import NPC_PARTY_STREAM, generate_npc_party, npc_defeat_xp + from osrlib.core.rng import RngStreams + + streams = RngStreams(master_seed=5) + party = generate_npc_party( + "basic", + count=2, + npc_stream=streams.get(NPC_PARTY_STREAM), + treasure_stream=streams.get("treasure"), + allocator=IdAllocator(), + ) + print(party.kind, party.alignment.value) + # basic neutral + for member in party.members: + print(member.name, member.level, member.max_hp, member.armour_class) + # Halfling adventurer 1 2 4 6 + # Thief adventurer 2 3 14 7 + print(sum(npc_defeat_xp(member.level) for member in party.members)) + # 55 + ``` """ tables = load_encounter_tables() classes = load_classes() From ef951362c86d601323d5af9583a74d667a1f344a Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Sun, 13 Sep 2026 22:18:04 -0700 Subject: [PATCH 2/2] Address review of the core-creatures docstrings Point TreasureRef at plan_treasure_ref, which is the only function that honors the bracketed letters, the flat gold, and the multiplier, and say what a loop over `letters` alone would drop. State level_up's two settings separately: whether a die is rolled follows the hit dice count rising, and whether CON counts follows con_applies. They change over together in the shipped classes but are independent in the data, which is what LevelUpResult.con_applied is for. Give drain_levels and the result fields the same wording. Say that generate_npc_party rolls the first level's hit die itself and sends only levels 2 and up through level_up, and that a level past name level takes no draw. Use TREASURE_STREAM in the NPC examples, link EXPLORATION_STREAM from the thief and detection rolls, and fix the equipment id index link text and an unexpanded abbreviation in a comment. Claude-Session: https://claude.ai/code/session_01GL26QnA6dCrvUc3WmhzFSa --- src/osrlib/core/character.py | 2 +- src/osrlib/core/classes.py | 84 ++++++++++++++++++++++++------------ src/osrlib/core/monsters.py | 38 ++++++++++++---- src/osrlib/core/npc.py | 23 ++++++---- 4 files changed, 103 insertions(+), 44 deletions(-) diff --git a/src/osrlib/core/character.py b/src/osrlib/core/character.py index 6bb32ca..08b6b7d 100644 --- a/src/osrlib/core/character.py +++ b/src/osrlib/core/character.py @@ -1233,7 +1233,7 @@ def create_character( [the language id index][languages-index]. An INT of 12 or less allows none. purchases: What to buy from the starting gold, as `(item_id, lots)` pairs bought in the order given. `item_id` comes from [`load_equipment`][osrlib.data.load_equipment]; see - [the equipment index][equipment-index]. A lot is the catalog's unit of sale: weapons + [the equipment id index][equipment-index]. A lot is the catalog's unit of sale: weapons and armour sell one at a time, so `lots` is how many, while gear and ammunition sell in fixed bundles, and one lot of torches is six torches. equip_ids: Which of the bought items to wear or wield, in order. An item must have been diff --git a/src/osrlib/core/classes.py b/src/osrlib/core/classes.py index ec0d5e9..5a2d771 100644 --- a/src/osrlib/core/classes.py +++ b/src/osrlib/core/classes.py @@ -148,8 +148,10 @@ class HitDice(BaseModel): """Flat hit points added on top of the dice, which is how levels past name level grow. Never negative.""" con_applies: bool = True - """Whether the CON modifier applies to a die gained at this level. False above name level, where the gain is the - flat bonus and CON has stopped counting. + """Whether the CON modifier applies to a die gained at this level. + + The SRD clears it at the levels it marks with an asterisk, as in `9d8+2*`. It is read separately from whether a die + is rolled at all, which depends on `count` rising from the row below. """ @model_validator(mode="after") @@ -330,10 +332,10 @@ class WeaponPolicy(BaseModel): """ weapon_ids: tuple[str, ...] = () - """The weapon ids the policy names, from [`load_equipment`][osrlib.data.load_equipment]; see [the equipment - index][equipment-index]. The cleric's five blunt weapons are an example of a permitted list, and the long bow and - two-handed sword the dwarf and halfling are refused are an example of the other. Empty when the class may use - anything. + """The weapon ids the policy names, from [`load_equipment`][osrlib.data.load_equipment]; see + [the equipment id index][equipment-index]. The cleric's five blunt weapons are an example of a permitted list, and + the long bow and two-handed sword the dwarf and halfling are refused are an example of the other. Empty when the + class may use anything. """ manual_notes: tuple[str, ...] = () @@ -634,8 +636,8 @@ class LevelUpResult(BaseModel): """The level the character now has.""" hp_roll: int | None - """The raw hit die that was thrown, or `None` above name level, where the class gains a flat number of hit points - and rolls nothing. + """The raw hit die that was thrown, or `None` when the new level added no hit die and the gain was the difference + between the two rows' flat bonuses. """ hp_gained: int @@ -644,7 +646,12 @@ class LevelUpResult(BaseModel): """ con_applied: bool - """Whether the CON modifier counted toward the gain. False above name level.""" + """Whether the CON modifier counted toward the gain. + + It follows the new progression row's `con_applies`, which the SRD clears at the levels it marks with an asterisk, + and it is always False when no die was rolled. Read it rather than working it out from the level, because the two + are separate settings in the data. + """ class XpAwardResult(BaseModel): @@ -775,11 +782,23 @@ def level_up(character: Character, definition: ClassDefinition, stream: RngStrea rather than earned: building a character above first level, a referee's ruling, restoring a level a wight took. - While the class is still gaining hit dice, the character rolls one and adds the CON modifier, - gaining at least 1 hit point however the dice fall. Past name level the class gains a fixed - number of hit points instead, with no roll and no CON. Either way both maximum and current hit - points rise by the gain, so a level heals nothing: a wounded character is still wounded, with - a higher ceiling. + Two things about the new level's progression row decide what the gain is, and they are read + separately. Whether a die is rolled depends on the row having more hit dice than the row below + it: when it does, the character rolls one, and when it does not, the gain is the difference + between the two rows' flat bonuses and no die is thrown. Whether the CON modifier counts + depends on the new row's `con_applies`, which the SRD clears at the levels it marks with an + asterisk. A rolled die with CON cleared gains the raw die alone, and + [`con_applied`][osrlib.core.classes.LevelUpResult.con_applied] on the result says which way it + went. + + For the classes osrlib ships, both settings change over at name level, so a character rolls + with CON up to name level and takes a flat gain without CON after it. A class added as data can + set them independently, which is why the result reports them rather than leaving you to work + one out from the other. + + A rolled gain is floored at 1 hit point, however poor the die and the CON modifier are + together. Both maximum and current hit points rise by the gain, so a level heals nothing: a + wounded character is still wounded, with a higher ceiling. Nothing else needs updating. THAC0, saving throws, and spell capacity are read from the progression row for the new level, so they change on their own. @@ -790,7 +809,7 @@ def level_up(character: Character, definition: ClassDefinition, stream: RngStrea definition: The character's class. It must be the character's own class. stream: The stream for the hit die, conventionally `streams.get(`[`ADVANCEMENT_STREAM`][osrlib.core.character.ADVANCEMENT_STREAM]`)`. No - draw is taken past name level. + draw is taken when the new row adds no hit die. Returns: What the level gained, including the raw die when one was thrown. @@ -927,8 +946,11 @@ def thief_skill_check( `"hear_noise"`. modifier_pct: A percentage added to the chance before rolling, negative to make the attempt harder. Ignored for `"hear_noise"`. - stream: The stream to draw from, conventionally the crawl's `"exploration"` stream. One - draw is taken. + stream: The stream to draw from, conventionally the one a crawl names + [`EXPLORATION_STREAM`][osrlib.crawl.session.EXPLORATION_STREAM], whose key is + `"exploration"`. One draw is taken. The example below spells the key out rather than + importing the constant, because this module sits in the core layer and never reaches + up into the crawl layer. Returns: The roll, the chance it was measured against, and whether it passed. @@ -995,8 +1017,11 @@ def detection_check(chance_in_six: int, *, stream: RngStream) -> DetectionResult Args: chance_in_six: The chance to roll at or under, usually from [`detection_chance`][osrlib.core.classes.detection_chance]. - stream: The stream to draw from, conventionally the crawl's `"exploration"` stream. One - draw is taken unless the chance is zero. + stream: The stream to draw from, conventionally the one a crawl names + [`EXPLORATION_STREAM`][osrlib.crawl.session.EXPLORATION_STREAM], whose key is + `"exploration"`. One draw is taken unless the chance is zero. The example below spells + the key out rather than importing the constant, because this module sits in the core + layer and never reaches up into the crawl layer. Returns: The roll and whether it passed, with `roll` left `None` when no die was thrown. @@ -1114,8 +1139,10 @@ class DrainResult(BaseModel): """The level the character now has, or 0 when the drain killed them.""" hp_rolls: tuple[int, ...] = () - """The raw hit dice thrown for the levels lost, in order. Empty above name level, where the loss is a fixed number - of hit points and nothing is rolled. + """The raw hit dice thrown for the levels lost, in order. + + A level whose row carries no extra hit die throws nothing, so this is shorter than `levels_lost` when the drain + crossed such a level, and empty when every level it took was one of them. """ hp_lost: int @@ -1151,12 +1178,15 @@ def drain_levels( levels and the experience policy, then pass them here. The attack itself resolves in [`osrlib.core.combat`][osrlib.core.combat]. This is the consequence. - Each level is taken exactly as it was given. Below name level the character throws the hit die - they would have rolled and loses that many hit points plus their CON modifier, at least 1. - Above name level they lose the fixed number of hit points the class grants there, with no roll. - Rolling the die back is what lets the model stay stateless: a character keeps no record of - which dice built their hit points, so the drain rolls a fresh one. THAC0, saving throws, and - spell capacity need nothing done to them, because they are read from the level. + Each level is taken exactly as it was given, reading the same two settings + [`level_up`][osrlib.core.classes.level_up] reads. When the level being lost had more hit dice + than the level below it, the character throws that die and loses the result plus their CON + modifier, at least 1, with CON counting only when the row it came from says it does. When the + two rows have the same number of dice, the loss is the difference between their flat bonuses + and no die is thrown. Rolling the die back is what lets the model stay stateless: a character + keeps no record of which dice built their hit points, so the drain rolls a fresh one. THAC0, + saving throws, and spell capacity need nothing done to them, because they are read from the + level. A character never drops below 1 maximum or 1 current hit point while they still have a level. Death comes only from losing the last one, which is the SRD's person drained of all levels: the diff --git a/src/osrlib/core/monsters.py b/src/osrlib/core/monsters.py index ae5901d..92bc559 100644 --- a/src/osrlib/core/monsters.py +++ b/src/osrlib/core/monsters.py @@ -502,25 +502,47 @@ class NumberAppearing(BaseModel): class TreasureRef(BaseModel): """What treasure a monster has, as the stat block prints it. - Read it as [`MonsterTemplate.treasure`][osrlib.core.monsters.MonsterTemplate], then generate - the hoard with [`generate_treasure`][osrlib.core.treasure.generate_treasure], which takes a - treasure type letter; see [the treasure type index][treasure-types-index]. A creature with no - treasure has an empty reference. Frozen. + Read it as [`MonsterTemplate.treasure`][osrlib.core.monsters.MonsterTemplate], then pass it to + [`plan_treasure_ref`][osrlib.core.treasure.plan_treasure_ref], which sorts the letters into + lair, per-creature, and per-group treasure and carries `parenthetical`, `extra_gp`, and + `multiplier` through. Generate each letter in the plan with + [`generate_treasure`][osrlib.core.treasure.generate_treasure]; see + [the treasure type index][treasure-types-index] for the letters. + + Go through the plan rather than looping `letters` into + [`generate_treasure`][osrlib.core.treasure.generate_treasure] yourself. A loop over `letters` + alone drops the bracketed letters, the flat gold, and the multiplier without telling you, and + it treats a per-creature letter as though it were a lair hoard. + + A creature with no treasure has an empty reference. Frozen. """ model_config = ConfigDict(frozen=True) letters: tuple[str, ...] = () - """The treasure type letters, like `("D",)`. More than one means roll each.""" + """The treasure type letters, like `("D",)`. + + More than one means every one of them is generated. + [`plan_treasure_ref`][osrlib.core.treasure.plan_treasure_ref] sorts them by section, so a lair + letter, a per-creature letter, and a per-group letter in the same reference each land in the + right place. + """ parenthetical: tuple[str, ...] = () - """The letters the SRD prints in brackets. They are rolled as lair treasure alongside the primary letters.""" + """The letters the SRD prints in brackets. + + [`plan_treasure_ref`][osrlib.core.treasure.plan_treasure_ref] adds them to the lair treasure + whatever section they belong to, so a bandit's `U (A)` puts U on the group and A in the lair. + """ extra_gp: int = Field(default=0, ge=0) - """Gold pieces the stat block adds on top of the rolled treasure.""" + """Gold pieces the stat block adds on top of the rolled treasure, into the lair hoard.""" multiplier: int = Field(default=1, ge=1) - """What the rolled treasure is multiplied by. 1 unless the SRD says otherwise.""" + """How many times the whole listed generation repeats, as with a noble's `V × 3`. + + 1 unless the SRD says otherwise. + """ special: tuple[str, ...] = () """Valuables that are not treasure types at all, like an elephant's tusks or a bee's honey. Nothing generates these; diff --git a/src/osrlib/core/npc.py b/src/osrlib/core/npc.py index 8905ca6..a224434 100644 --- a/src/osrlib/core/npc.py +++ b/src/osrlib/core/npc.py @@ -41,13 +41,14 @@ from osrlib.core.monsters import IdAllocator from osrlib.core.npc import NPC_PARTY_STREAM, generate_npc_party from osrlib.core.rng import RngStreams +from osrlib.core.treasure import TREASURE_STREAM streams = RngStreams(master_seed=5) party = generate_npc_party( "basic", count=3, npc_stream=streams.get(NPC_PARTY_STREAM), - treasure_stream=streams.get("treasure"), + treasure_stream=streams.get(TREASURE_STREAM), allocator=IdAllocator(), ) print(party.alignment.value) @@ -304,7 +305,7 @@ def _roll_expert_items( instances = generate_magic_item(category, tier="expert", stream=treasure_stream, allocator=allocator) for instance in instances: if not _item_usable(member, definition, instance): - continue # unusable rolls are ignored, no re-roll (RAW) + continue # An item nobody can use is dropped, with no re-roll, as written. member.inventory.items.append(instance) _maybe_equip_upgrade(member, definition, instance) @@ -325,11 +326,16 @@ def generate_npc_party( The band shares one alignment, rolled once, so their reaction to the players and their vulnerability to alignment-gated wards have a single answer. Then each member in turn gets a - class and a level from the SRD's table, ability scores rolled 3d6 in order, hit points rolled - per level through [`level_up`][osrlib.core.classes.level_up], experience set to the threshold - for their level, an equipment kit their class can use, and, if they cast, spells prepared at - random from their class's list. An Expert band wears heavier armour and each member gets a 5% - chance per level at each kind of magic item they could use. + class and a level from the SRD's table, ability scores rolled 3d6 in order, hit points, + experience set to the threshold for their level, an equipment kit their class can use, and, if + they cast, spells prepared at random from their class's list. An Expert band wears heavier + armour and each member gets a 5% chance per level at each kind of magic item they could use. + + Hit points come in two parts, which matters if you are counting draws. The first level's hit + die is rolled here, directly, with the CON modifier added and the total floored at 1. Every + level after the first goes through [`level_up`][osrlib.core.classes.level_up], one call per + level, and each of those calls takes a draw only when that level's row adds a hit die. An + Expert dwarf rolled at level 11 or 12 passes name level, so its top levels take no draw. Members are not checked against their class's ability requirements, because the SRD rolls their class before their scores. An elf here may have an INT a player character would not be allowed. @@ -359,13 +365,14 @@ class before their scores. An elf here may have an INT a player character would from osrlib.core.monsters import IdAllocator from osrlib.core.npc import NPC_PARTY_STREAM, generate_npc_party, npc_defeat_xp from osrlib.core.rng import RngStreams + from osrlib.core.treasure import TREASURE_STREAM streams = RngStreams(master_seed=5) party = generate_npc_party( "basic", count=2, npc_stream=streams.get(NPC_PARTY_STREAM), - treasure_stream=streams.get("treasure"), + treasure_stream=streams.get(TREASURE_STREAM), allocator=IdAllocator(), ) print(party.kind, party.alignment.value)