diff --git a/src/osrlib/core/character.py b/src/osrlib/core/character.py index 4c74235..08b6b7d 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 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 + 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..5a2d771 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,50 @@ "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. + + 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") def _die_must_be_rollable(self) -> HitDice: @@ -105,43 +162,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 +257,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 +298,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 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, ...] = () + """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 +353,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 +536,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`. - Saves, THAC0, attack bonus, and spell slots are read from here, never stored: - energy drain is this function's inverse, not a redesign. + 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. + + 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 +573,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 +593,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 +623,116 @@ 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` when the new level added no hit die and the gain was the difference + between the two rows' flat bonuses. + """ + 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. + + 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): - """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. + + 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. - 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. + 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 +741,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". - `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. + Use it wherever you show a character's standing: a sheet, a party roster, the line a front end + prints when someone levels up. + + 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 +775,77 @@ 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. - - 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]. + """Raise a character one level and roll the hit points that come with it. + + 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. + + 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. 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 when the new row adds no hit die. 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 +870,121 @@ 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 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 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 +1003,42 @@ 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 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 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 +1054,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. + + 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. - 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. + 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 +1123,42 @@ 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. + + 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 + """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 +1170,85 @@ 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, 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 + 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 +1279,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 +1327,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 +1345,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..92bc559 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,243 @@ 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. + + 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. - `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`). + 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 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. + + [`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, into the lair hoard.""" + multiplier: int = Field(default=1, ge=1) + """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; + 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. + + 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. - `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. + 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 +731,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 +762,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 +782,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 +814,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. - 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. + 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. + + 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 +907,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 +920,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 +945,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 +1045,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..a224434 100644 --- a/src/osrlib/core/npc.py +++ b/src/osrlib/core/npc.py @@ -1,33 +1,64 @@ -"""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 +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_STREAM), + 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 +94,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 +129,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 +143,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 +225,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 +238,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 +254,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 +287,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) @@ -235,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) @@ -248,25 +318,72 @@ 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. + + 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. - 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`. + 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, + 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. + + 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 + 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_STREAM), + 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()