From e6baa271f265ccf4ad3a3ac64ebdcb4c9378c0ac Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Sun, 13 Sep 2026 22:06:14 -0700 Subject: [PATCH 1/2] Docstrings: core basics Rewrite the docstrings of the kernel's foundation modules so a developer reading the published reference can call them without opening the source: the RNG streams, dice, the game clock, the ruleset flags, ability scores, the SRD lookup tables, alignment, and structured rejections. Every pydantic field, enum member, and module constant now carries its own attribute docstring, every documented function has a runnable example, and osrlib.core's package docstring is the subpackage front page. Claude-Session: https://claude.ai/code/session_01GL26QnA6dCrvUc3WmhzFSa --- src/osrlib/core/__init__.py | 82 +++- src/osrlib/core/abilities.py | 693 +++++++++++++++++++++++++----- src/osrlib/core/alignment.py | 58 ++- src/osrlib/core/clock.py | 227 ++++++++-- src/osrlib/core/dice.py | 206 +++++++-- src/osrlib/core/rng.py | 379 ++++++++++++---- src/osrlib/core/ruleset.py | 293 +++++++++---- src/osrlib/core/tables.py | 783 +++++++++++++++++++++++++++------- src/osrlib/core/validation.py | 87 +++- 9 files changed, 2277 insertions(+), 531 deletions(-) diff --git a/src/osrlib/core/__init__.py b/src/osrlib/core/__init__.py index 28af2b0..57b6740 100644 --- a/src/osrlib/core/__init__.py +++ b/src/osrlib/core/__init__.py @@ -1,5 +1,81 @@ -"""The rules kernel: pure mechanics with no game loop. +"""The rules kernel: the B/X mechanics as pure functions over frozen models. -Kernel modules never import from `osrlib.crawl` (the framework layer) and are usable -à la carte — a game that wants only the math can call them directly. +The kernel is the half of osrlib that has no game loop. You hand a kernel function the +inputs a rule needs, plus a seeded random-number stream and a +[`Ruleset`][osrlib.core.ruleset.Ruleset] (the frozen set of optional-rule flags a game +plays under), and it returns the outcome together with the typed events that describe +it. Nothing here starts a session, keeps a turn order, or remembers where the party is +standing. You call the kernel directly when you want the rules without a game: a combat +simulator, a balance harness, a script that checks authored content. The dungeon-crawl +framework in `osrlib.crawl` is one consumer of the kernel, and yours is another. Kernel +modules never import from `osrlib.crawl`, so what you build on the kernel keeps working +whatever the crawl layer does above it. + +The modules, in the order you meet them: + +- [`osrlib.core.rng`][osrlib.core.rng]: a master seed in, named deterministic streams + out. Every other module here takes one of those streams as an argument. +- [`osrlib.core.dice`][osrlib.core.dice]: a dice expression such as `"2d6+1"` and a + stream in, the individual dice and the total out. +- [`osrlib.core.ruleset`][osrlib.core.ruleset]: optional-rule flags in, a frozen + `Ruleset` out, which most resolution functions read. +- [`osrlib.core.clock`][osrlib.core.clock]: a span of rounds, turns, or days in, the + new elapsed time and the turn and day boundaries crossed out. +- [`osrlib.core.alignment`][osrlib.core.alignment] and + [`osrlib.core.validation`][osrlib.core.validation]: the two vocabularies the rest of + the kernel shares, an alignment and a structured refusal reason. +- [`osrlib.core.abilities`][osrlib.core.abilities]: a score of 3 to 18 in, the SRD's + modifiers, an ability check, or an adjusted score set out. +- [`osrlib.core.classes`][osrlib.core.classes], + [`osrlib.core.monsters`][osrlib.core.monsters], + [`osrlib.core.items`][osrlib.core.items], and + [`osrlib.core.spells`][osrlib.core.spells]: an id from a compiled catalog in, a + frozen template or a playable instance out. +- [`osrlib.core.tables`][osrlib.core.tables]: Hit Dice, an armour class, or a 2d6 + total in, the printed table cell out. +- [`osrlib.core.character`][osrlib.core.character] and + [`osrlib.core.npc`][osrlib.core.npc]: a class id, a ruleset, and a stream in, a + rolled character or a generated NPC party out. +- [`osrlib.core.combat`][osrlib.core.combat] and + [`osrlib.core.effects`][osrlib.core.effects]: an attacker, a defender, and a context + in, the resolution and its events out. +- [`osrlib.core.treasure`][osrlib.core.treasure]: a treasure-type letter and a stream + in, coins, gems, jewellery, and magic items out. +- [`osrlib.core.events`][osrlib.core.events]: the base class every kernel event + subclasses, and the rules those events follow. + +The compiled SRD catalogs the kernel reads come from [`osrlib.data`][osrlib.data], and +the exceptions it raises are in [`osrlib.errors`][osrlib.errors]. + +Typical usage: + +```python +from osrlib.core.abilities import ability_check +from osrlib.core.clock import GameClock, TimeUnit +from osrlib.core.dice import roll +from osrlib.core.rng import RngStreams +from osrlib.data import load_ability_tables + +# One master seed forks every stream the kernel draws from. +stream = RngStreams(master_seed=7).get("character_creation") + +# Roll a strength score, then read what the SRD's table grants at that score. +strength = roll("3d6", stream) +assert strength.rolls == (2, 6, 6) +assert strength.total == 14 + +tables = load_ability_tables() +assert tables.melee_modifier(strength.total) == 1 +assert tables.open_doors_chance(strength.total) == 3 + +# An ability check rolls 1d20 and succeeds on equal-or-under the score. +check = ability_check(strength.total, stream) +assert (check.roll, check.success) == (13, True) + +# The clock counts rounds and reports the turn and day boundaries an advance crosses. +clock = GameClock() +crossings = clock.advance(1, TimeUnit.TURN) +assert clock.rounds == 60 +assert [(crossing.unit, crossing.index) for crossing in crossings] == [(TimeUnit.TURN, 1)] +``` """ diff --git a/src/osrlib/core/abilities.py b/src/osrlib/core/abilities.py index 5e8d5d9..0cde3b1 100644 --- a/src/osrlib/core/abilities.py +++ b/src/osrlib/core/abilities.py @@ -1,23 +1,48 @@ -"""Ability scores: modifier tables, checks, prime requisites, and the adjustment step. - -The six modifier tables (and the prime requisite XP table) are compiled from the SRD's -Ability Scores page into `abilities.json` and load as one frozen -[`AbilityTables`][osrlib.core.abilities.AbilityTables] model, which carries one accessor -per SRD column. Tables are stored as score bands exactly as the SRD prints them (`4–5`, -`13–15`), validated to cover 3–18 contiguously. - -Scores range 3–18: 3d6 can roll nothing else, the SRD's tables list nothing else, and -the creation-time adjustment step may not raise a score above 18 nor lower one below 9. - -Ability checks per the SRD: roll 1d20, equal-or-under the score succeeds, with a -caller-supplied difficulty modifier (±4 easy/hard) applied to the roll. A natural 1 -always succeeds and a natural 20 always fails — *inverted* from attack rolls, where a -natural 20 always hits. Open-doors checks are d6 ≤ the STR-derived chance. - -The adjustment step is pure validation plus atomic application over a rolled score set -and a chosen class's prime requisites: lower STR/INT/WIS two-for-one into prime -requisites, floor 9, cap 18, with class-specific restrictions (a thief may not lower -STR) carried as data on the class definition. +"""What a character's six ability scores are worth, and what you can do with them. + +Start with [`load_ability_tables`][osrlib.data.load_ability_tables], which gives you an +[`AbilityTables`][osrlib.core.abilities.AbilityTables] with the SRD's printed tables. +Hand any of its accessors a score from 3 to 18 and get the number the rules apply: the +melee modifier for strength, the armour class modifier for dexterity, the hit points per +Hit Die for constitution, and so on. A [`Character`][osrlib.core.character.Character] +exposes the ones it needs as properties, so you call the accessors yourself when you're +working outside a character. + +Two dice functions live here as well. +[`ability_check`][osrlib.core.abilities.ability_check] rolls the SRD's generic check for +a task the rules don't otherwise cover, and +[`open_doors_check`][osrlib.core.abilities.open_doors_check] rolls a strength-based +attempt to force a stuck door. Both take an +[`RngStream`][osrlib.core.rng.RngStream]. + +The rest of the module is character creation's third step, where a player trades points +between abilities before play. +[`validate_adjustment`][osrlib.core.abilities.validate_adjustment] says whether a +proposed trade is legal and [`apply_adjustment`][osrlib.core.abilities.apply_adjustment] +carries it out. [`create_character`][osrlib.core.character.create_character] runs the +whole creation sequence, so reach for these two when you're building a character step by +step and letting a player choose. + +Scores run 3 to 18. That's what 3d6 can roll, what the SRD's tables list, and what the +adjustment step has to stay inside. + +Typical usage: + +```python +from osrlib.core.abilities import ability_check +from osrlib.core.rng import RngStreams +from osrlib.data import load_ability_tables + +tables = load_ability_tables() + +# What a strength of 16 is worth. +assert tables.melee_modifier(16) == 2 +assert tables.open_doors_chance(16) == 4 + +# A check against a dexterity of 13, on a stream you supply. +check = ability_check(13, RngStreams(master_seed=3).get("exploration")) +assert (check.roll, check.success) == (18, False) +``` """ from enum import StrEnum @@ -52,45 +77,97 @@ ] MIN_SCORE = 3 -"""The lowest possible ability score (3d6 minimum; also the tables' floor).""" +"""The lowest an ability score can be, which is 3. + +Three is what three dice showing 1 add up to, and the lowest row of every table here. +Below it there's no rule to apply, so the accessors and the check functions raise +`ValueError` rather than guess. Use it to bound a slider, or to check a score your own +code produced. +""" MAX_SCORE = 18 -"""The highest possible ability score (3d6 maximum; also the adjustment raise cap).""" +"""The highest an ability score can be, which is 18. + +Eighteen is what three dice showing 6 add up to, the top row of every table here, and the +ceiling the creation-time trade may not push a score past. The accessors raise +`ValueError` above it, because the tables print no row to read. +""" ADJUSTMENT_FLOOR = 9 -"""No score may be lowered below this value during the adjustment step.""" +"""The lowest a score may be traded down to during character creation, which is 9. + +The floor stops a player from emptying one ability to buy up another. It applies only to +the trade in [`validate_adjustment`][osrlib.core.abilities.validate_adjustment]. A score +rolled below 9 is legal, and it **cannot** be lowered further. +""" class AbilityScore(StrEnum): - """The six ability scores. + """Which of the six abilities a score belongs to. + + Use these as the keys of a score dictionary, which is how every function here and in + [`osrlib.core.character`][osrlib.core.character] passes a character's abilities + around. All six keys are expected to be present. - The wire values are lowercase (`"str"`, `"int"`, ...) — they serialize into - characters and saves; changing them is a `schema_version` bump. + The lowercase values serialize into characters and saved games. Changing one is a + `schema_version` bump, the version stamp that marks a serialized model's shape. """ STR = "str" + """Strength: melee attack and damage, and the chance to force a stuck door open.""" + INT = "int" + """Intelligence: how many extra languages a character speaks, and whether they can read and write.""" + WIS = "wis" + """Wisdom: the modifier on saving throws against magical effects.""" + DEX = "dex" + """Dexterity: armour class, missile attacks, and initiative under the individual-initiative rule.""" + CON = "con" + """Constitution: the hit points added to each Hit Die rolled.""" + CHA = "cha" + """Charisma: how monsters and NPCs react, and how many retainers will follow the character, how loyally.""" class Literacy(StrEnum): - """The INT table's literacy column.""" + """How well a character reads and writes, which intelligence decides. + + Read it off [`AbilityTables.literacy`][osrlib.core.abilities.AbilityTables.literacy] + or [`Character.literacy`][osrlib.core.character.Character.literacy]. It matters + whenever the party finds something written, a scroll or a map or an inscription. + """ ILLITERATE = "illiterate" + """Cannot read or write, at intelligence 5 or below.""" + BASIC = "basic" + """Partial literacy, at intelligence 6 to 8: the step the SRD prints between illiterate and literate.""" + LITERATE = "literate" + """Can read and write the character's native languages, at intelligence 9 or above.""" class ScoreBand(BaseModel): - """A contiguous score range in a modifier table, as the SRD prints it (`4–5`).""" + """The run of scores one table row covers, such as 4 to 5. + + The SRD prints its modifier tables in bands rather than one row per score, and osrlib + keeps them that way. Every row model below is a band with the row's own columns added. + You'll meet these when you read a whole table off + [`AbilityTables`][osrlib.core.abilities.AbilityTables], for instance to draw the table + on screen. To look up a single score, call an accessor instead and skip the rows + entirely. + """ model_config = ConfigDict(frozen=True) min_score: int = Field(ge=MIN_SCORE, le=MAX_SCORE) + """The lowest score this row covers.""" + max_score: int = Field(ge=MIN_SCORE, le=MAX_SCORE) + """The highest score this row covers. Equal to `min_score` on a one-score row.""" @model_validator(mode="after") def _band_must_be_ordered(self) -> ScoreBand: @@ -100,52 +177,90 @@ def _band_must_be_ordered(self) -> ScoreBand: class StrengthRow(ScoreBand): - """One STR band: melee modifier and open-doors chance (X-in-6).""" + """One row of the strength table, with the two things strength grants.""" melee: int + """What to add to a melee attack roll and to melee damage. Negative at a low score.""" + open_doors: int = Field(ge=0, le=6) + """The chance in 6 of forcing a stuck door open. + + Pass it to [`open_doors_check`][osrlib.core.abilities.open_doors_check], which rolls + the die against it. + """ class IntelligenceRow(ScoreBand): - """One INT band: additional spoken languages, literacy, and broken speech at INT 3.""" + """One row of the intelligence table, covering language and literacy.""" additional_languages: int = Field(ge=0) + """How many languages beyond the character's native ones they may choose at creation.""" + literacy: Literacy + """How well the character reads and writes. See [`Literacy`][osrlib.core.abilities.Literacy].""" + broken_speech: bool = False + """True only at intelligence 3, where the character speaks their native language brokenly.""" class WisdomRow(ScoreBand): - """One WIS band: saving throw modifier versus magical effects.""" + """One row of the wisdom table. Wisdom grants a saving throw modifier and no other bonus.""" magic_saves: int + """What to add to a saving throw against a magical effect. Negative at a low score.""" class DexterityRow(ScoreBand): - """One DEX band: AC modifier, missile attack modifier, and optional-rule initiative modifier.""" + """One row of the dexterity table, with the three things dexterity grants.""" ac: int + """What to add to armour class. A positive number here makes a descending armour class better, so it lowers it.""" + missile: int + """What to add to a missile attack roll. It doesn't touch missile damage.""" + initiative: int + """What to add to an individual initiative roll. + + Read only when the `individual_initiative` flag on + [`Ruleset`][osrlib.core.ruleset.Ruleset] is on, since initiative is otherwise rolled + once for a whole side. + """ class ConstitutionRow(ScoreBand): - """One CON band: hit point modifier per Hit Die.""" + """One row of the constitution table. Constitution grants hit points and no other bonus.""" hit_points: int + """What to add to every Hit Die rolled, whether at creation or on gaining a level. + + A die never yields fewer than 1 hit point however negative this is. + """ class CharismaRow(ScoreBand): - """One CHA band: NPC reaction modifier, retainer maximum, and retainer loyalty.""" + """One row of the charisma table, with the three things charisma grants.""" npc_reactions: int + """What to add to a monster or NPC reaction roll, which decides how a meeting starts.""" + max_retainers: int = Field(ge=0) + """How many hired followers the character may have at once.""" + retainer_loyalty: int = Field(ge=0) + """The loyalty a retainer of this character starts with, rolled against when the retainer's nerve is tested.""" class PrimeRequisiteRow(ScoreBand): - """One prime requisite band: XP modifier percentage for single-prime-requisite classes.""" + """One row of the prime requisite table, which sets how fast a character earns experience. + + A prime requisite is the ability a class is built around: wisdom for a cleric, + strength for a fighter. This table applies to a class with a single prime requisite. A + class with more than one has its own tiers on its class definition. + """ xp_modifier_pct: int + """The percentage added to or taken off experience earned, from −20 at a score of 3 to +10 at 16 or above.""" def _validate_coverage(rows: tuple[ScoreBand, ...], table: str) -> None: @@ -159,22 +274,58 @@ def _validate_coverage(rows: tuple[ScoreBand, ...], table: str) -> None: class AbilityTables(BaseModel): - """The six ability modifier tables plus the prime requisite XP table. - - Loaded from `abilities.json` via - [`load_ability_tables`][osrlib.data.load_ability_tables]. Accessors take a score in - 3–18 and raise stdlib `ValueError` outside that range (programmer misuse). + """The SRD's ability tables, and the accessors that read a score out of them. + + Get one from [`load_ability_tables`][osrlib.data.load_ability_tables], which loads the + tables that ship with the package and caches them, so calling it repeatedly costs + nothing. Then call the accessor for the column you want, passing a score from + [`MIN_SCORE`][osrlib.core.abilities.MIN_SCORE] to + [`MAX_SCORE`][osrlib.core.abilities.MAX_SCORE]. A score outside that range raises + `ValueError`, because a score outside it is a mistake in your code rather than an + outcome the rules allow. + + A [`Character`][osrlib.core.character.Character] reads these tables for you and offers + the results as properties, so use the accessors when you have a bare score and no + character. + + The fields contain the raw rows. Read them to draw a table, and use the accessors to + play. + + Examples: + ```python + from osrlib.data import load_ability_tables + + tables = load_ability_tables() + assert tables.melee_modifier(18) == 3 + assert tables.hit_point_modifier(3) == -3 + + # The rows are there when you want to show the whole table. + assert (tables.strength[1].min_score, tables.strength[1].max_score) == (4, 5) + ``` """ model_config = ConfigDict(frozen=True) strength: tuple[StrengthRow, ...] + """The strength table's rows, lowest score first, together covering 3 to 18 with no gaps.""" + intelligence: tuple[IntelligenceRow, ...] + """The intelligence table's rows, lowest score first, together covering 3 to 18 with no gaps.""" + wisdom: tuple[WisdomRow, ...] + """The wisdom table's rows, lowest score first, together covering 3 to 18 with no gaps.""" + dexterity: tuple[DexterityRow, ...] + """The dexterity table's rows, lowest score first, together covering 3 to 18 with no gaps.""" + constitution: tuple[ConstitutionRow, ...] + """The constitution table's rows, lowest score first, together covering 3 to 18 with no gaps.""" + charisma: tuple[CharismaRow, ...] + """The charisma table's rows, lowest score first, together covering 3 to 18 with no gaps.""" + prime_requisite: tuple[PrimeRequisiteRow, ...] + """The prime requisite experience table's rows, lowest score first, together covering 3 to 18 with no gaps.""" @model_validator(mode="after") def _tables_must_cover_all_scores(self) -> AbilityTables: @@ -191,97 +342,314 @@ def _row[RowT: ScoreBand](self, rows: tuple[RowT, ...], score: int) -> RowT: raise ValueError(f"no band covers score {score}") # unreachable given coverage validation def melee_modifier(self, score: int) -> int: - """Return the STR modifier to melee attack and damage rolls.""" + """Return what a strength score adds to melee attack rolls and melee damage. + + Missile attacks take the dexterity modifier instead. See + [`missile_modifier`][osrlib.core.abilities.AbilityTables.missile_modifier]. + + Args: + score: A strength score from 3 to 18. + + Returns: + The modifier, from −3 at a score of 3 to +3 at 18. + + Raises: + ValueError: If `score` is outside 3 to 18. + """ return self._row(self.strength, score).melee def open_doors_chance(self, score: int) -> int: - """Return the STR-derived X-in-6 chance to force open a stuck door.""" + """Return the chance in 6 that a strength score forces a stuck door open. + + Pass the result to + [`open_doors_check`][osrlib.core.abilities.open_doors_check], which rolls against + it. + + Args: + score: A strength score from 3 to 18. + + Returns: + The chance in 6, from 1 at a score of 3 to 5 at 18. + + Raises: + ValueError: If `score` is outside 3 to 18. + """ return self._row(self.strength, score).open_doors def additional_languages(self, score: int) -> int: - """Return the number of additional spoken languages granted by INT.""" + """Return how many languages beyond the native ones an intelligence score grants. + + The choices themselves are checked by + [`validate_extra_languages`][osrlib.core.character.validate_extra_languages], + which reads this same allowance. + + Args: + score: An intelligence score from 3 to 18. + + Returns: + The count, 0 below a score of 13 and up to 3 at 18. + + Raises: + ValueError: If `score` is outside 3 to 18. + """ return self._row(self.intelligence, score).additional_languages def literacy(self, score: int) -> Literacy: - """Return the INT-derived literacy in the character's native languages.""" + """Return how well an intelligence score lets a character read and write. + + Args: + score: An intelligence score from 3 to 18. + + Returns: + The [`Literacy`][osrlib.core.abilities.Literacy] band for that score. + + Raises: + ValueError: If `score` is outside 3 to 18. + """ return self._row(self.intelligence, score).literacy def magic_save_modifier(self, score: int) -> int: - """Return the WIS modifier to saving throws versus magical effects.""" + """Return what a wisdom score adds to saving throws against magical effects. + + It applies to magical effects only, not to a saving throw against a trap or a + dragon's breath. + + Args: + score: A wisdom score from 3 to 18. + + Returns: + The modifier, from −3 at a score of 3 to +3 at 18. + + Raises: + ValueError: If `score` is outside 3 to 18. + """ return self._row(self.wisdom, score).magic_saves def ac_modifier(self, score: int) -> int: - """Return the DEX modifier to AC (a bonus lowers descending AC).""" + """Return what a dexterity score is worth to armour class. + + The number is a bonus: it's subtracted from a descending armour class, where + lower is better, and added to an ascending one. + [`Character.armour_class`][osrlib.core.character.Character.armour_class] applies + it for you. + + Args: + score: A dexterity score from 3 to 18. + + Returns: + The bonus, from −3 at a score of 3 to +3 at 18. + + Raises: + ValueError: If `score` is outside 3 to 18. + """ return self._row(self.dexterity, score).ac def missile_modifier(self, score: int) -> int: - """Return the DEX modifier to missile attack rolls (not damage).""" + """Return what a dexterity score adds to missile attack rolls. + + It changes the attack roll only. Missile damage takes no ability modifier. + + Args: + score: A dexterity score from 3 to 18. + + Returns: + The modifier, from −3 at a score of 3 to +3 at 18. + + Raises: + ValueError: If `score` is outside 3 to 18. + """ return self._row(self.dexterity, score).missile def initiative_modifier(self, score: int) -> int: - """Return the DEX modifier to individual initiative (optional rule).""" + """Return what a dexterity score adds to an individual initiative roll. + + It is read only when the `individual_initiative` flag on + [`Ruleset`][osrlib.core.ruleset.Ruleset] is on. With the flag off, initiative is + rolled once for a whole side and no ability modifier applies. + + Args: + score: A dexterity score from 3 to 18. + + Returns: + The modifier, from −2 at a score of 3 to +2 at 18. + + Raises: + ValueError: If `score` is outside 3 to 18. + """ return self._row(self.dexterity, score).initiative def hit_point_modifier(self, score: int) -> int: - """Return the CON modifier applied per Hit Die rolled (minimum 1 hp per die).""" + """Return what a constitution score adds to every Hit Die a character rolls. + + It applies at creation and again at each level gained. However negative it is, a + die never ends up granting fewer than 1 hit point. + + Args: + score: A constitution score from 3 to 18. + + Returns: + The modifier, from −3 at a score of 3 to +3 at 18. + + Raises: + ValueError: If `score` is outside 3 to 18. + """ return self._row(self.constitution, score).hit_points def npc_reaction_modifier(self, score: int) -> int: - """Return the CHA modifier to NPC reactions.""" + """Return what a charisma score adds to a monster or NPC reaction roll. + + Add it to the 2d6 total before reading the result with + [`reaction_result`][osrlib.core.tables.reaction_result], which clamps a modified + total into the printed outer bands. + + Args: + score: A charisma score from 3 to 18. + + Returns: + The modifier, from −2 at a score of 3 to +2 at 18. + + Raises: + ValueError: If `score` is outside 3 to 18. + """ return self._row(self.charisma, score).npc_reactions def max_retainers(self, score: int) -> int: - """Return the CHA-derived maximum number of retainers.""" + """Return how many hired followers a charisma score lets a character keep at once. + + Args: + score: A charisma score from 3 to 18. + + Returns: + The count, from 1 at a score of 3 to 7 at 18. + + Raises: + ValueError: If `score` is outside 3 to 18. + """ return self._row(self.charisma, score).max_retainers def retainer_loyalty(self, score: int) -> int: - """Return the CHA-derived retainer loyalty rating.""" + """Return the loyalty a retainer of a character with this charisma score starts with. + + Loyalty is the number a retainer's nerve is tested against when the party asks + something risky of them. + + Args: + score: A charisma score from 3 to 18. + + Returns: + The loyalty score, from 4 at a charisma of 3 to 10 at 18. + + Raises: + ValueError: If `score` is outside 3 to 18. + """ return self._row(self.charisma, score).retainer_loyalty def prime_requisite_xp_modifier_pct(self, score: int) -> int: - """Return the XP modifier percentage for a single prime requisite at `score`.""" + """Return the percentage a single prime requisite score changes earned experience by. + + A prime requisite is the ability a class is built around. This table covers a + class with exactly one. A class with more than one has its own tiers on its + class definition, and + [`xp_modifier_pct`][osrlib.core.classes.xp_modifier_pct] picks the right source + for you. + + Args: + score: A prime requisite score from 3 to 18. + + Returns: + The percentage, from −20 at a score of 3 to +10 at 16 or above. + + Raises: + ValueError: If `score` is outside 3 to 18. + """ return self._row(self.prime_requisite, score).xp_modifier_pct class AbilityCheckResult(BaseModel): - """The outcome of an ability check, with the raw roll kept for display.""" + """How an ability check turned out, with the die kept so you can show it. + + [`ability_check`][osrlib.core.abilities.ability_check] returns one. + """ model_config = ConfigDict(frozen=True) roll: int + """What the d20 came up, from 1 to 20, before the modifier.""" + score: int + """The ability score the roll was checked against.""" + modifier: int + """The difficulty modifier that was applied to the roll. Positive made it harder.""" + success: bool + """Whether the check succeeded. + + True when the modified roll came out at or under the score, and on a natural 1 + whatever the score. + """ class OpenDoorsResult(BaseModel): - """The outcome of an open-doors check, with the raw roll kept for display.""" + """How an attempt to force a door turned out, with the die kept so you can show it. + + [`open_doors_check`][osrlib.core.abilities.open_doors_check] returns one. + """ model_config = ConfigDict(frozen=True) roll: int + """What the d6 came up, from 1 to 6.""" + chance: int + """The chance in 6 that was rolled against.""" + success: bool + """Whether the door opened, which it did when the roll came out at or under the chance.""" def ability_check(score: int, stream: RngStream, modifier: int = 0) -> AbilityCheckResult: """Roll an ability check: 1d20, equal-or-under the score succeeds. - The caller-supplied difficulty modifier is added to the roll (the SRD suggests −4 - for an easy task, +4 for a difficult one). A natural 1 always succeeds and a - natural 20 always fails — inverted from attack rolls, where a natural 20 always - hits and a natural 1 always misses. + Use this for a task the rules don't cover with a procedure of their own: shoving a + boulder, spotting a change in the stonework, holding a rope. Pick the ability that + fits and set the difficulty with `modifier`. For the things the rules do cover, call + the function that covers them: + [`open_doors_check`][osrlib.core.abilities.open_doors_check] for a stuck door, the + saving throws in [`osrlib.core.combat`][osrlib.core.combat] for magic and traps, and + the thief skills on the class definition for a thief's own work. + + The roll is a d20, and the check succeeds on a modified roll at or under the score, so + a higher score succeeds more often. The SRD suggests −4 for an easy task and +4 for a + difficult one. A natural 1 always succeeds and a natural 20 always fails, which is the + opposite way round from an attack roll. Args: - score: The ability score to check against, in 3–18. - stream: The RNG stream to draw from. - modifier: Difficulty modifier added to the roll; positive makes it harder. + score: The ability score to check against, from 3 to 18. + stream: The stream the d20 draws from. + modifier: The difficulty, added to the roll. Positive makes the check harder. Returns: - The check outcome, including the raw d20 roll. + The outcome, with the raw d20 kept for display. Raises: - ValueError: If `score` is outside 3–18. + ValueError: If `score` is outside 3 to 18. + + Examples: + ```python + from osrlib.core.abilities import ability_check + from osrlib.core.rng import RngStreams + + stream = RngStreams(master_seed=3).get("exploration") + + # An 18 against a score of 13 fails, as a natural 20 would have. + check = ability_check(13, stream) + assert (check.roll, check.success) == (18, False) + + # A difficult task: +4 on the roll, so a 5 is still under 13. + harder = ability_check(13, stream, modifier=4) + assert (harder.roll, harder.success) == (5, True) + ``` """ if not MIN_SCORE <= score <= MAX_SCORE: raise ValueError(f"ability score must be in {MIN_SCORE}-{MAX_SCORE}, got {score}") @@ -296,18 +664,40 @@ def ability_check(score: int, stream: RngStream, modifier: int = 0) -> AbilityCh def open_doors_check(chance: int, stream: RngStream) -> OpenDoorsResult: - """Roll an open-doors check: d6, equal-or-under the X-in-6 chance succeeds. + """Roll one character's attempt to force a stuck door open. + + Get the chance from + [`AbilityTables.open_doors_chance`][osrlib.core.abilities.AbilityTables.open_doors_chance] + for the character's strength. A d6 at or under the chance opens the door. The function + rolls and reports, and nothing else: whether the door then opens, how much noise the + attempt made, and how much time it cost are yours to apply. + + In a running game [`ForceDoor`][osrlib.crawl.commands.ForceDoor] does all of that for + you, with the events to match, so call this function when you're working outside a + session. Args: - chance: The X-in-6 chance, from - [`AbilityTables.open_doors_chance`][osrlib.core.abilities.AbilityTables.open_doors_chance]. - stream: The RNG stream to draw from. + chance: The chance in 6, from 0 to 6. + stream: The stream the d6 draws from. Returns: - The check outcome, including the raw d6 roll. + The outcome, with the raw d6 kept for display. Raises: - ValueError: If `chance` is outside 0–6. + ValueError: If `chance` is outside 0 to 6. + + Examples: + ```python + from osrlib.core.abilities import open_doors_check + from osrlib.core.rng import RngStreams + from osrlib.data import load_ability_tables + + chance = load_ability_tables().open_doors_chance(16) + assert chance == 4 + + result = open_doors_check(chance, RngStreams(master_seed=3).get("exploration")) + assert (result.roll, result.success) == (5, False) + ``` """ if not 0 <= chance <= 6: raise ValueError(f"open-doors chance must be in 0-6, got {chance}") @@ -319,16 +709,46 @@ def open_doors_check(chance: int, stream: RngStream) -> OpenDoorsResult: class AbilityAdjustment(BaseModel): - """The creation-time adjustment: even reductions traded two-for-one into prime requisite raises. + """A proposed trade of ability points, made once while a character is being created. + + Build one from what the player chose, check it with + [`validate_adjustment`][osrlib.core.abilities.validate_adjustment], and carry it out + with [`apply_adjustment`][osrlib.core.abilities.apply_adjustment]. The exchange rate + is two points down for one point up, and the points bought can only go into the + class's prime requisites, the abilities the class is built around. + + An adjustment with nothing in it is legal and changes nothing, which is what you build + for a player who keeps the scores as rolled. - `lowered` maps abilities to the (positive) amount subtracted; `raised` maps - abilities to the (positive) amount added. An empty adjustment is a legal no-op. + Examples: + ```python + from osrlib.core.abilities import AbilityAdjustment, AbilityScore + + # Four points out of intelligence and wisdom buys two points of strength. + adjustment = AbilityAdjustment( + lowered={AbilityScore.INT: 2, AbilityScore.WIS: 2}, + raised={AbilityScore.STR: 2}, + ) + assert sum(adjustment.raised.values()) == sum(adjustment.lowered.values()) // 2 + ``` """ model_config = ConfigDict(frozen=True) lowered: dict[AbilityScore, int] = {} + """How much to take off each ability, as a positive number. + + Only strength, intelligence, and wisdom may appear, each amount must be even, and no + score may end below [`ADJUSTMENT_FLOOR`][osrlib.core.abilities.ADJUSTMENT_FLOOR]. + """ + raised: dict[AbilityScore, int] = {} + """How much to add to each ability, as a positive number. + + Only the class's prime requisites may appear, the amounts must add up to half the + points taken off, and no score may end above + [`MAX_SCORE`][osrlib.core.abilities.MAX_SCORE]. + """ @model_validator(mode="after") def _amounts_must_be_positive(self) -> AbilityAdjustment: @@ -345,28 +765,66 @@ def validate_adjustment( prime_requisites: tuple[AbilityScore, ...], may_not_lower: tuple[AbilityScore, ...] = (), ) -> list[Rejection]: - """Validate an adjustment against the SRD's rules, returning structured rejections. - - The rules, per the SRD's Creating a Character step 3 and the adaptations register's - interpretations: - - - Only STR, INT, and WIS may be lowered. - - A prime requisite of the chosen class may not be lowered, nor may an ability in - the class's `may_not_lower` restrictions (the thief's STR). - - Each lowered score drops by an even amount (the two-for-one trade is per-score, - so an odd reduction would strand half a point). - - The total raise equals the sum of reductions divided by two, distributed freely - among the class's prime requisites and nowhere else. - - No lowered score drops below 9; no raised score rises above 18. + """Check a proposed points trade against the rules, and say what is wrong with it. + + Call this before [`apply_adjustment`][osrlib.core.abilities.apply_adjustment] and show + the player what came back. An empty list means the trade is legal. The two functions + take the same four arguments, so you can pass the same ones straight on. + + The rules it enforces, from the SRD's third character-creation step and osrlib's + readings of it in + [the adaptations register](https://mmacy.github.io/osrlib-python/adaptations/), the + page that lists every place osrlib commits to one reading of the rules: + + - Only strength, intelligence, and wisdom may be lowered. + - A prime requisite of the chosen class may not be lowered, and neither may an ability + the class forbids lowering, which is why a thief may not lower strength. + - Each score comes down by an even amount, since the two-for-one trade is worked out + per score and an odd amount would strand half a point. + - The points bought add up to half the points sold, and go only into the class's prime + requisites. + - No score is lowered below [`ADJUSTMENT_FLOOR`][osrlib.core.abilities.ADJUSTMENT_FLOOR] + or raised above [`MAX_SCORE`][osrlib.core.abilities.MAX_SCORE]. Args: - scores: The rolled scores, all six abilities present. - adjustment: The proposed adjustment. - prime_requisites: The chosen class's prime requisites. - may_not_lower: Class-specific lowering restrictions, from the class definition. + scores: The rolled scores, with all six abilities present. + adjustment: The trade the player proposed. + prime_requisites: The chosen class's prime requisites, from + [`ClassDefinition`][osrlib.core.classes.ClassDefinition]. + may_not_lower: Abilities this class forbids lowering, also from the class + definition. Returns: - Structured rejections; empty when the adjustment is legal. + One [`Rejection`][osrlib.core.validation.Rejection] per rule broken, empty when the + trade is legal. A single proposal can break several rules at once, so expect more + than one. + + Examples: + ```python + from osrlib.core.abilities import AbilityAdjustment, AbilityScore, validate_adjustment + + scores = { + AbilityScore.STR: 9, + AbilityScore.INT: 13, + AbilityScore.WIS: 12, + AbilityScore.DEX: 11, + AbilityScore.CON: 14, + AbilityScore.CHA: 10, + } + + # Four points down, two points up, into the fighter's prime requisite. + legal = AbilityAdjustment( + lowered={AbilityScore.INT: 2, AbilityScore.WIS: 2}, + raised={AbilityScore.STR: 2}, + ) + assert validate_adjustment(scores, legal, (AbilityScore.STR,)) == [] + + # Buying two points for two is not the rate. + greedy = AbilityAdjustment(lowered={AbilityScore.INT: 2}, raised={AbilityScore.STR: 2}) + assert [rejection.code for rejection in validate_adjustment(scores, greedy, (AbilityScore.STR,))] == [ + "creation.adjustment.points_mismatch" + ] + ``` """ rejections: list[Rejection] = [] for ability, amount in adjustment.lowered.items(): @@ -419,21 +877,58 @@ def apply_adjustment( prime_requisites: tuple[AbilityScore, ...], may_not_lower: tuple[AbilityScore, ...] = (), ) -> dict[AbilityScore, int]: - """Apply a validated adjustment atomically, returning the new score set. + """Carry out a points trade and return the adjusted scores. + + Call [`validate_adjustment`][osrlib.core.abilities.validate_adjustment] first and pass + the same arguments on. This function validates again and refuses rather than produce a + score set the rules forbid, so an exception here means your code let an illegal trade + through, not that the player chose badly. It either applies the whole trade or changes + nothing. + + Feed the result to [`create_character`][osrlib.core.character.create_character] as the + scores to build the character from. Args: - scores: The rolled scores; not mutated. - adjustment: The adjustment to apply. + scores: The rolled scores. Left as they are, because the adjusted set comes back + as a new dictionary. + adjustment: The trade to carry out. prime_requisites: The chosen class's prime requisites. - may_not_lower: Class-specific lowering restrictions. + may_not_lower: Abilities this class forbids lowering. Returns: - A new score dict with reductions and raises applied. + A new score dictionary with the points moved. All six abilities are present, and + the ones the trade didn't touch keep their rolled values. Raises: - ValueError: If the adjustment fails - [`validate_adjustment`][osrlib.core.abilities.validate_adjustment] — - applying an illegal adjustment is programmer misuse. + ValueError: If the trade breaks any rule + [`validate_adjustment`][osrlib.core.abilities.validate_adjustment] checks. The + message names the rejection codes. + + Examples: + ```python + from osrlib.core.abilities import AbilityAdjustment, AbilityScore, apply_adjustment + + scores = { + AbilityScore.STR: 9, + AbilityScore.INT: 13, + AbilityScore.WIS: 12, + AbilityScore.DEX: 11, + AbilityScore.CON: 14, + AbilityScore.CHA: 10, + } + adjustment = AbilityAdjustment( + lowered={AbilityScore.INT: 2, AbilityScore.WIS: 2}, + raised={AbilityScore.STR: 2}, + ) + + adjusted = apply_adjustment(scores, adjustment, (AbilityScore.STR,)) + assert adjusted[AbilityScore.STR] == 11 + assert adjusted[AbilityScore.INT] == 11 + assert adjusted[AbilityScore.CON] == 14 + + # The rolled scores are untouched. + assert scores[AbilityScore.STR] == 9 + ``` """ rejections = validate_adjustment(scores, adjustment, prime_requisites, may_not_lower) if rejections: diff --git a/src/osrlib/core/alignment.py b/src/osrlib/core/alignment.py index d2ec5b2..939161e 100644 --- a/src/osrlib/core/alignment.py +++ b/src/osrlib/core/alignment.py @@ -1,9 +1,18 @@ -"""The three alignments, in a home importable by both characters and monsters. +"""The three alignments every creature in the game declares. -[`Alignment`][osrlib.core.alignment.Alignment] lives in its own module because both -character and monster data carry alignments, and the generated-data loaders import -the monster models: a module the loaders import must not import `character`, which -itself imports the loaders. +The module has one member, +[`Alignment`][osrlib.core.alignment.Alignment]. You pass one to +[`create_character`][osrlib.core.character.create_character] when you roll a player +character, and you read one off a monster: a template has an +[`AlignmentSpec`][osrlib.core.monsters.AlignmentSpec] of the alignments its kind can +have, and [`spawn_monster`][osrlib.core.monsters.spawn_monster] settles on one for the +individual. The rules read alignment in two places: a character's alignment tongue, the +secret language its adherents share, is derived from it by +[`Character.alignment_tongue`][osrlib.core.character.Character.alignment_tongue], and +spells and magic items that care whose side a creature is on compare alignments. + +Alignment sits in its own module so that both the character models and the monster +models can import it without importing each other. """ from enum import StrEnum @@ -14,12 +23,45 @@ class Alignment(StrEnum): - """The three alignments. + """The cosmic principle a creature follows: law, neutrality, or chaos. + + Reference a member by name (`Alignment.LAWFUL`) when you write the value yourself, + and call the enum on a string (`Alignment("lawful")`) when the value arrives from + saved data or from a player's input. The member compares equal to its own lowercase + string, so `character.alignment == "lawful"` is true as well. + + The rules attach no mechanical penalty to acting against alignment. The SRD leaves + that to the referee. What the rules do read is the alignment tongue derived from the + value, and the alignment comparisons a handful of spells and magic items make. + + The lowercase values serialize into characters and saved games. Changing one is a + `schema_version` bump, the version stamp that marks a serialized model's shape. - The wire values are lowercase — they serialize into characters and saves; changing - them is a `schema_version` bump. + Examples: + ```python + from osrlib.core.alignment import Alignment + + assert Alignment("lawful") is Alignment.LAWFUL + assert Alignment.CHAOTIC == "chaotic" + ``` """ LAWFUL = "lawful" + """Goodness, order, truth, and justice as the natural order of the universe. + + Lawful creatures are trustworthy, protect others, and act for the good of the group. + """ + NEUTRAL = "neutral" + """A balance between law and chaos, with neither side dominant. + + Neutral creatures cooperate with others as long as it costs them nothing, and live by + their own talents rather than relying on anyone else. + """ + CHAOTIC = "chaotic" + """The individual's own desires above all, in a universe the creature believes is random. + + Chaotic creatures lie and use others to their own ends, break laws, and follow their + whims. + """ diff --git a/src/osrlib/core/clock.py b/src/osrlib/core/clock.py index 36b4e60..ce428dc 100644 --- a/src/osrlib/core/clock.py +++ b/src/osrlib/core/clock.py @@ -1,13 +1,48 @@ -"""Time units and the game clock. +"""How much game time has passed, counted in rounds. -B/X time comes in three units: the round (10 seconds), the turn (10 minutes = 60 -rounds), and the day (144 turns). Internally the clock is a single integer count of -rounds — the finest unit — so arithmetic is exact and serialization is one field. +[`GameClock`][osrlib.core.clock.GameClock] is the entry point: you create one, call +[`advance`][osrlib.core.clock.GameClock.advance] whenever the party spends time, and +read `rounds`, `turns`, and `days` off it. Each advance returns the turn and day +boundaries it crossed, as [`BoundaryCrossing`][osrlib.core.clock.BoundaryCrossing] +values, so you can run whatever your game does at the end of a turn or a day: burn a +torch down, check for wandering monsters, eat a day's rations. -Advancing the clock reports which turn and day boundaries were crossed, in order, so -the effects engine can resolve expirations and ticks at each boundary per the -canonical tick order. An advance that lands exactly on a boundary reports that -boundary: a torch lit at turn 0 expires when the clock reaches turn 6, not turn 7. +In an ordinary game you never build a clock yourself. A +[`GameSession`][osrlib.crawl.session.GameSession] keeps one and advances it as commands +consume time, and +[`EffectsLedger.advance`][osrlib.core.effects.EffectsLedger.advance] takes the session's +clock when it ages poison, light, and the rest. Build your own when you use the kernel +without a session and still want time to pass. + +B/X measures time in three units: the round of 10 seconds, the turn of 10 minutes, and +the day. The clock keeps one integer count of rounds, the finest unit, so the arithmetic +is exact and the whole clock saves as one number. An advance that lands exactly on a +boundary counts as crossing it: a torch lit at turn 0 burns out when the clock reaches +turn 6, not turn 7. + +Typical usage: + +```python +from osrlib.core.clock import GameClock, TimeUnit + +clock = GameClock() + +# Six turns of searching: one crossing per turn, none for a day. +crossings = clock.advance(6, TimeUnit.TURN) +assert [(crossing.unit, crossing.index) for crossing in crossings] == [ + (TimeUnit.TURN, 1), + (TimeUnit.TURN, 2), + (TimeUnit.TURN, 3), + (TimeUnit.TURN, 4), + (TimeUnit.TURN, 5), + (TimeUnit.TURN, 6), +] +assert (clock.rounds, clock.turns, clock.days) == (360, 6, 0) + +# Ten rounds of combat cross nothing: the clock is mid-turn. +assert clock.advance(10) == [] +assert clock.rounds == 370 +``` """ from enum import StrEnum @@ -25,24 +60,70 @@ ] SECONDS_PER_ROUND = 10 -"""Length of a combat round in seconds.""" +"""How many seconds of story time one combat round takes. + +Nothing in the rules reads this. It's here so you can put a wall-clock duration on the +screen, or pace an animation, without writing 10 into your own code. The B/X rules give +no unit finer than the round, so there's nothing below it to count. +""" ROUNDS_PER_TURN = 60 -"""Rounds per exploration turn (10 minutes).""" +"""How many rounds make up one exploration turn, which is ten minutes of story time. + +The round is the combat unit and the turn is the exploration unit, and this is the +conversion between them. Use it to say how long something lasts in the other unit: a +torch that burns for six turns burns for `6 * ROUNDS_PER_TURN` rounds. + +Don't reassign it to play at a different scale. Every duration the SRD prints is quoted +in turns or rounds at this ratio, so changing it rescales them all at once, with nothing +to show for it. To work in a unit of your own, advance the clock by rounds and convert +the result yourself. +""" TURNS_PER_DAY = 144 -"""Exploration turns per day.""" +"""How many exploration turns make up one day. + +That's a full 24 hours of turns, not only the ones spent underground. The day boundary +is where the rules put daily events, such as eating and preparing spells. +""" ROUNDS_PER_DAY = ROUNDS_PER_TURN * TURNS_PER_DAY -"""Rounds per day (8640).""" +"""How many rounds make up one day, which is 8640. + +It's the product of the two ratios above rather than a separate number, so it can never +disagree with them. +""" class TimeUnit(StrEnum): - """The B/X time units.""" + """The unit an amount of game time is counted in. + + Pass one to [`advance`][osrlib.core.clock.GameClock.advance] to say what your number + means, and read one off a [`BoundaryCrossing`][osrlib.core.clock.BoundaryCrossing] to + see which kind of boundary you crossed. The rounds each unit is worth are + [`ROUNDS_PER_TURN`][osrlib.core.clock.ROUNDS_PER_TURN] and + [`ROUNDS_PER_DAY`][osrlib.core.clock.ROUNDS_PER_DAY]. + """ ROUND = "round" + """The combat unit, ten seconds of story time. + + One attack, one spell, or one move in a fight takes a round. + """ + TURN = "turn" + """The exploration unit, ten minutes of story time. + + One move through the dungeon, one search of a room, or one attempt to listen at a + door takes a turn. + """ + DAY = "day" + """The supply unit, a full day of story time. + + A session consumes the party's rations and water on each day boundary, and a caster + prepares spells once a day. + """ _ROUNDS_PER_UNIT: dict[TimeUnit, int] = { @@ -53,23 +134,71 @@ class TimeUnit(StrEnum): class BoundaryCrossing(BaseModel): - """A turn or day boundary crossed by a clock advance. + """One turn or day boundary that a clock advance passed. - `index` is the ordinal of the boundary in its own unit: crossing into turn 6 has - `unit=TimeUnit.TURN, index=6`, and lies at absolute round `round` (here 360). - When a day boundary coincides with a turn boundary (every day boundary does), the - turn crossing is reported first — finer units before coarser. + [`advance`][osrlib.core.clock.GameClock.advance] returns a list of these, oldest + first. Walk the list and do whatever your game owes the end of a turn or the end of + a day, in the order the boundaries arrived. There are no round crossings: every + round is a round boundary, so the count of them is the advance itself. + + The model is frozen, so you can keep a crossing as a record of when something + happened. + + Examples: + ```python + from osrlib.core.clock import GameClock, TimeUnit + + # A day boundary is also a turn boundary, and the turn is reported first. + clock = GameClock(rounds=8580) + crossings = clock.advance(1, TimeUnit.TURN) + assert [(crossing.unit, crossing.index, crossing.round) for crossing in crossings] == [ + (TimeUnit.TURN, 144, 8640), + (TimeUnit.DAY, 1, 8640), + ] + ``` """ model_config = ConfigDict(frozen=True) unit: TimeUnit + """Which kind of boundary this is, `TimeUnit.TURN` or `TimeUnit.DAY`. + + A crossing is never `TimeUnit.ROUND`. An advance of `n` rounds crosses `n` round + boundaries, so listing them would only repeat the number you passed in. + """ + index: int = Field(ge=1) + """Which boundary of that kind, counted from the start of the game. + + The first turn of play is 1 and the sixth is 6, so the ordinal matches the way the + rules count durations ("burns for six turns"). + """ + round: int = Field(ge=1) + """The clock reading at which the boundary sits, in rounds since the start of the game. + + Turn 6 sits at round 360. Use it to stamp whatever you record at the boundary, so the + record and the clock agree. + """ class GameClock(BaseModel): - """The game clock: elapsed time as a single count of rounds. + """How much time has passed in the game, as a count of rounds. + + Call `GameClock()` to start a game at time zero, or `GameClock(rounds=n)` to resume + one. A [`GameSession`][osrlib.crawl.session.GameSession] makes its own and keeps it + on `session.clock`, so reach for the constructor only when you're running the kernel + without a session. + + Unlike most models in the kernel this one is mutable: + [`advance`][osrlib.core.clock.GameClock.advance] moves the same clock forward rather + than returning a new one, because everything that spends time shares one clock. Pass + the clock itself to anything that consumes time, not a copy, or their views of the + game's time drift apart. Assigning `rounds` directly works and is validated, but + skips the boundary report, so prefer `advance`. + + The clock saves as one integer, and `GameClock.model_validate(document)` reads it + back. Examples: ```python @@ -78,40 +207,82 @@ class GameClock(BaseModel): clock = GameClock() crossings = clock.advance(2, TimeUnit.TURN) assert clock.turns == 2 - assert [c.index for c in crossings if c.unit is TimeUnit.TURN] == [1, 2] + assert [crossing.index for crossing in crossings if crossing.unit is TimeUnit.TURN] == [1, 2] + + # The whole clock round-trips as one field. + assert GameClock.model_validate(clock.model_dump()).rounds == 120 ``` """ model_config = ConfigDict(validate_assignment=True) rounds: int = Field(default=0, ge=0) + """Rounds elapsed since the start of the game. + + The clock's only stored value. `turns` and `days` are read off it. Advancing is what + normally changes it, and it can never go below zero. + """ @property def turns(self) -> int: - """Whole turns elapsed.""" + """Return the number of whole exploration turns elapsed, rounding down. + + Read it for a "how long have we been down here" display. A turn in progress + doesn't count until it finishes, so a clock at round 59 still reads 0 turns. + """ return self.rounds // ROUNDS_PER_TURN @property def days(self) -> int: - """Whole days elapsed.""" + """Return the number of whole days elapsed, rounding down. + + Read it to tell how many days of rations the party has eaten. A day in progress + doesn't count until it finishes. + """ return self.rounds // ROUNDS_PER_DAY def advance(self, n: int, unit: TimeUnit = TimeUnit.ROUND) -> list[BoundaryCrossing]: """Advance the clock and report the turn and day boundaries crossed. + Call this whenever the party spends time, then act on the crossings you get + back. The clock moves in place, and the return value is the report rather than a new + clock. + + If you're aging effects as well as counting time, call + [`EffectsLedger.advance`][osrlib.core.effects.EffectsLedger.advance] instead and + hand it this clock: it advances the clock for you and returns the events that + expiring and ticking effects produced. Advancing the clock here does nothing to + effects on its own. + Args: - n: How many units to advance. Must be non-negative; zero is a legal no-op - that crosses nothing. + n: How many units to advance. Must be non-negative. Zero is legal and crosses + nothing. unit: The unit to advance in. Returns: Every turn and day boundary in the advanced span, in chronological order, - with a coinciding turn boundary before its day boundary. A boundary landed - on exactly is included; the starting position is not (it was reported by - the advance that reached it). + with a coinciding turn boundary before its day boundary. A boundary the + advance lands on exactly is included. The position you started from isn't, + because the advance that reached it already reported it. Raises: - ValueError: If `n` is negative. + ValueError: If `n` is negative. Time never runs backwards, so to rewind a + game, restore it from a save. + + Examples: + ```python + from osrlib.core.clock import GameClock, TimeUnit + + clock = GameClock() + + # A round of combat crosses nothing. + assert clock.advance(1) == [] + + # Searching a room takes a turn, and the turn boundary comes back. + crossings = clock.advance(1, TimeUnit.TURN) + assert [(crossing.unit, crossing.index) for crossing in crossings] == [(TimeUnit.TURN, 1)] + assert clock.rounds == 61 + ``` """ if n < 0: raise ValueError(f"cannot advance the clock backwards, got n={n}") diff --git a/src/osrlib/core/dice.py b/src/osrlib/core/dice.py index 1564a75..27d8ac6 100644 --- a/src/osrlib/core/dice.py +++ b/src/osrlib/core/dice.py @@ -1,24 +1,34 @@ -"""Dice expression parsing and rolling. - -The grammar is `NdS` with an optional `+M`/`-M` modifier and an optional `×K` -multiplier (`x` and `*` accepted as ASCII aliases): `3d6`, `1d6+1`, `1d4-1`, `2d6×10`. -`N` defaults to 1, `d%` is an alias for `d100`, and die sizes are the closed set -{2, 3, 4, 6, 8, 10, 12, 20, 100}. Parsing is case-insensitive, surrounding whitespace -is stripped, internal whitespace is rejected, and component order is fixed: dice, then -modifier, then multiplier. Numerals are canonical ASCII digits — no Unicode digits, no -leading zeros — with the dice count in 1–999, the modifier magnitude at most 999999, -and the multiplier in 1–999999. Anything else raises +"""Rolling dice from the strings the rules are written in. + +[`roll`][osrlib.core.dice.roll] is the entry point: hand it a dice expression such as +`"3d6"` and an [`RngStream`][osrlib.core.rng.RngStream] from +[`RngStreams.get`][osrlib.core.rng.RngStreams.get], and it returns a +[`RollResult`][osrlib.core.dice.RollResult] with each die and the total. Call +[`parse`][osrlib.core.dice.parse] first only when you want to check an expression +without rolling it, or to inspect its parts. Every dice field in the compiled SRD data +is written in this grammar, so monster damage, treasure quantities, and spell effects all +go straight to `roll`. + +The grammar is `NdS` with an optional `+M` or `-M` modifier and an optional `×K` +multiplier, with `x` and `*` accepted as ASCII aliases for `×`: `3d6`, `1d6+1`, `1d4-1`, +`2d6×10`. `N` defaults to 1, `d%` means `d100`, and the die sizes are the closed set +{2, 3, 4, 6, 8, 10, 12, 20, 100}. Parsing ignores case and surrounding whitespace, +rejects whitespace inside the expression, and fixes the order as dice, then modifier, +then multiplier. Numerals are plain ASCII digits with no leading zeros: the dice count +runs 1 to 999, the modifier's magnitude reaches 999999, and the multiplier runs 1 to +999999. Anything else raises [`ContentValidationError`][osrlib.errors.ContentValidationError]. -Evaluation order is `(sum of dice + M) × K` — the modifier applies before the -multiplier, which is *not* ordinary arithmetic precedence: `2d6+1×10` means -`(2d6 + 1) × 10`, the B/X treasure-roll convention, not `2d6 + 10`. +A total is `(sum of the dice + M) × K`, so the modifier applies before the multiplier. +That isn't ordinary arithmetic precedence: `2d6+1×10` means `(2d6 + 1) × 10`, the +convention B/X treasure rolls are printed in, not `2d6 + 10`. -Results are not clamped: `1d4-1` can total 0 and `1d4-2` can total −1. Minimum-1-damage -is combat's rule, not the dice module's. +Totals aren't clamped, so `1d4-1` can come out 0 and `1d4-2` can come out −1. If you're +rolling damage and want the floor of 1 hit point, that floor belongs to combat, and +[`resolve_attack`][osrlib.core.combat.resolve_attack] applies it for you. -Every roll draws from an explicitly passed [`RngStream`][osrlib.core.rng.RngStream]; -there is no default stream and no module-level RNG. +There's no default stream and no module-level random number generator. You pass the +stream on every call, which is what makes a game replayable. """ import re @@ -37,11 +47,23 @@ ] ALLOWED_SIDES = frozenset({2, 3, 4, 6, 8, 10, 12, 20, 100}) -"""The closed set of legal die sizes.""" +"""Every die size an expression may name: 2, 3, 4, 6, 8, 10, 12, 20, and 100. + +These are the dice the B/X rules roll and the only sizes the compiled SRD data contains. +Read the set to offer a picker or to check a size before you build an expression. +[`parse`][osrlib.core.dice.parse] checks it for you and raises +[`ContentValidationError`][osrlib.errors.ContentValidationError] on anything else, so a +d7 never reaches a rules function. + +The set is closed on purpose, so adding to it isn't the way to roll an unusual die. Draw +that one yourself with +[`RngStream.randbelow`][osrlib.core.rng.RngStream.randbelow], which takes any positive +bound. +""" -# Canonical ASCII digits only, no leading zeros, bounded lengths: the grammar freezes -# with parse acceptance, so what \d would quietly admit (Unicode digits, 5000-digit -# numerals) must be rejected here, not discovered as contract later. +# Canonical ASCII digits only, no leading zeros, bounded lengths. What parse accepts is +# the grammar, so the things \d would quietly admit (Unicode digits, 5000-digit numerals) +# are rejected here rather than found later to be part of the contract. _EXPRESSION_PATTERN = re.compile( r""" (?P[1-9][0-9]{0,2})? @@ -55,14 +77,53 @@ class DiceExpression(BaseModel): - """A parsed dice expression: `count` dice of `sides` sides, `+modifier`, `×multiplier`.""" + """A dice expression taken apart: how many dice, of what size, plus what, times what. + + [`parse`][osrlib.core.dice.parse] returns one. Read its fields to show a roll before + it happens ("2d6+1, ×10"), to work out a range, or to check what a piece of content + will roll. Pass it back to [`roll`][osrlib.core.dice.roll] when you want the result. + Handing `roll` the same expression object repeatedly saves reparsing the string each + time. + + Building one directly is legal and validated, so you can assemble an expression from + parts instead of formatting a string. The model is frozen, so make a changed copy with + `expression.model_copy(update={"count": 4})`. + + Examples: + ```python + from osrlib.core.dice import parse + + expression = parse("2d6+1×10") + assert (expression.count, expression.sides) == (2, 6) + assert (expression.modifier, expression.multiplier) == (1, 10) + + # The lowest and highest totals the expression can produce. + lowest = (expression.count + expression.modifier) * expression.multiplier + highest = (expression.count * expression.sides + expression.modifier) * expression.multiplier + assert (lowest, highest) == (30, 130) + ``` + """ model_config = ConfigDict(frozen=True) count: int = Field(ge=1) + """How many dice to roll. At least 1, and at most 999 from a parsed string.""" + sides: int + """How many sides each die has. + + One of [`ALLOWED_SIDES`][osrlib.core.dice.ALLOWED_SIDES]. Any other value raises a + pydantic `ValidationError`. A `d%` in the source string arrives here as 100. + """ + modifier: int = 0 + """What to add to the sum of the dice, before the multiplier. Negative subtracts, and 0 means none.""" + multiplier: int = Field(default=1, ge=1) + """What to multiply the modified sum by, after the modifier. 1 means none. + + B/X treasure rolls are printed this way, as in `2d6×1000` gold pieces. + """ @field_validator("sides") @classmethod @@ -73,35 +134,85 @@ def _sides_must_be_allowed(cls, value: int) -> int: class RollResult(BaseModel): - """The outcome of rolling a dice expression. + """What a dice roll produced: each die, and the total they add up to. + + [`roll`][osrlib.core.dice.roll] returns one. Take `total` for the number the rules + call for, and show `rolls` to the player. The game's events include the individual + dice for the same reason: so an interface can put them on the screen. + + Attack and damage resolution in [`osrlib.core.combat`][osrlib.core.combat] hands back + results of this shape too, so one piece of display code covers both. + + Examples: + ```python + from osrlib.core.dice import roll + from osrlib.core.rng import RngStreams - Individual die results are kept — not just the total — because events want to show - the rolls. `total` is `(sum(rolls) + modifier) × multiplier`. + result = roll("2d6+1×10", RngStreams(master_seed=42).get("treasure")) + assert result.rolls == (4, 2) + assert result.total == (sum(result.rolls) + result.modifier) * result.multiplier + assert result.total == 70 + ``` """ model_config = ConfigDict(frozen=True) rolls: tuple[int, ...] + """What each die came up, in the order they were rolled. One entry per die.""" + modifier: int + """The modifier that was added to the sum of the dice, copied from the expression.""" + multiplier: int + """The multiplier that was applied after the modifier, copied from the expression.""" + total: int + """The number the rules use: the sum of the dice, plus the modifier, times the multiplier. + + Not clamped, so a `1d4-1` roll can total 0. + """ def parse(expression: str) -> DiceExpression: - """Parse a dice expression string. + """Take a dice expression string apart into its dice, modifier, and multiplier. + + Call this to check an expression a player or an adventure file supplied before you + trust it, or to read its parts. To roll instead, call + [`roll`][osrlib.core.dice.roll], which parses the string itself. Parsing first saves + nothing unless you roll the same expression many times. Args: - expression: A dice expression such as `"3d6"`, `"d%"`, or `"2d6+1×10"`. - Case-insensitive; surrounding whitespace is ignored. + expression: A dice expression such as `"3d6"`, `"d%"`, or `"2d6+1×10"`. Case doesn't + matter and surrounding whitespace is ignored. Returns: - The parsed, frozen expression model. + The expression taken apart, frozen. Raises: - ContentValidationError: If the expression doesn't match the grammar: unknown die - size, zero dice, zero multiplier, non-canonical numerals, internal - whitespace, or components out of the fixed dice-modifier-multiplier order. - TypeError: If `expression` is not a string. + ContentValidationError: If the string doesn't match the grammar: a die size + outside [`ALLOWED_SIDES`][osrlib.core.dice.ALLOWED_SIDES], zero dice, a zero + multiplier, a numeral with a leading zero or a non-ASCII digit, whitespace + inside the expression, or the parts out of the fixed dice, modifier, + multiplier order. + TypeError: If `expression` isn't a string. + + Examples: + ```python + from osrlib.core.dice import parse + from osrlib.errors import ContentValidationError + + assert parse("3d6").count == 3 + + # A bare d means one die, and d% means d100. + assert parse("d%").count == 1 + assert parse("d%").sides == 100 + + # A die the rules never roll is refused. + try: + parse("2d7") + except ContentValidationError as error: + assert "die size must be one of" in str(error) + ``` """ if not isinstance(expression, str): raise TypeError(f"expression must be a str, got {type(expression).__name__}") @@ -119,18 +230,27 @@ def parse(expression: str) -> DiceExpression: def roll(expression: str | DiceExpression, stream: RngStream) -> RollResult: - """Roll a dice expression, drawing from the given stream. + """Roll a dice expression, drawing from the stream you pass. - Dice roll left to right, one die of size S drawing `randbelow(S) + 1` — this - mapping is part of the determinism contract. + This is the one way dice are rolled in osrlib. Get a stream from + [`RngStreams.get`][osrlib.core.rng.RngStreams.get] and reuse it: the same seed and + the same sequence of calls give the same rolls, which is what makes a saved game + replay. Take the number out of `total` and show `rolls` if your interface displays + dice. + + Dice are rolled left to right, and one die of size S draws + [`randbelow(S)`][osrlib.core.rng.RngStream.randbelow] plus 1. That mapping is fixed: + changing it would change every roll in every existing saved game. Args: - expression: The expression to roll, as a string (parsed first) or an - already-parsed [`DiceExpression`][osrlib.core.dice.DiceExpression]. - stream: The RNG stream to draw from. + expression: What to roll, as a string this parses for you or as a + [`DiceExpression`][osrlib.core.dice.DiceExpression] you already parsed. + stream: The stream the dice draw from. It advances, so passing the same stream + again gives different dice. Returns: - The roll outcome, including each individual die result. + The dice and the total. The total isn't clamped, so it can be zero or negative + when the expression has a negative modifier. Raises: ContentValidationError: If a string expression doesn't match the grammar. @@ -148,9 +268,13 @@ def roll(expression: str | DiceExpression, stream: RngStream) -> RollResult: assert scores.rolls == (4, 2, 3) assert scores.total == 9 - # Modifier then multiplier: (2d6 + 1) × 10, evaluated in that order. + # Modifier then multiplier: (2d6 + 1) × 10, in that order. gold = roll("2d6+1×10", stream) assert gold.total == (sum(gold.rolls) + 1) * 10 + + # A fresh stream on the same seed repeats the sequence exactly. + again = roll("3d6", RngStreams(master_seed=42).get("treasure")) + assert again.rolls == scores.rolls ``` """ if isinstance(expression, str): diff --git a/src/osrlib/core/rng.py b/src/osrlib/core/rng.py index 830b9ac..0a24fbc 100644 --- a/src/osrlib/core/rng.py +++ b/src/osrlib/core/rng.py @@ -1,25 +1,53 @@ -"""Named deterministic RNG streams backed by pure-Python PCG64. - -This module is the determinism contract made concrete: draw sequences are part of the -public compatibility guarantee, so every algorithmic choice here is frozen by golden -vectors in the test suite. Do not change any of it without bumping expectations -consciously — an "equivalent" reimplementation that shifts a single draw breaks replays -and golden files. - -The generator is PCG64 — specifically the `pcg_setseq_128_xsl_rr_64` variant (128-bit -LCG state, XSL-RR output to 64 bits), the same generator behind numpy's `PCG64` (not -`PCG64DXSM`, which is a different algorithm). Each `next_uint64()` advances the LCG -first, then applies XSL-RR to the *new* state; this is the pcg-c 128-bit convention -numpy follows, and the opposite of the widely tutorialized pcg32 pattern. - -Streams are forked from a master seed by stable string keys: seed material is -`SHA-256(master_seed_bytes + b":" + stream_key_utf8)` with the master seed encoded as -16 bytes big-endian, so stream identity depends only on the master seed and the key -string. Adding draws to one subsystem's stream never shifts results in another. - -Randomness in the library must always come from an explicitly passed -[`RngStream`][osrlib.core.rng.RngStream] — never the stdlib `random` module, and never -a module-level default. +"""Where every random number in osrlib comes from: named, seeded, repeatable streams. + +[`RngStreams`][osrlib.core.rng.RngStreams] is the entry point. Make one from a master +seed, the single integer a whole game's randomness is derived from, ask it for a stream +by name with [`get`][osrlib.core.rng.RngStreams.get], and pass that stream to whatever +you call: [`roll`][osrlib.core.dice.roll], the combat and treasure functions, character +creation. A [`GameSession`][osrlib.crawl.session.GameSession] builds its own container +from the seed you give it and hands out the right stream for each rule, so during +ordinary play you never touch this module. + +A stream is named by a plain string, and each name draws its own independent sequence. +That's the point: rolling a hundred treasure hoards doesn't change what the next attack +rolls. Add a new draw to one subsystem and no other subsystem's results move, which is +what lets a saved game replay and a bug reproduce. [The RNG streams +reference][rng-streams] lists the names a running session uses and what each one covers. +Standalone code isn't bound to those names. A name is a label, and all that matters is +that you ask for the same one each time. + +Two draws made with the same master seed and the same stream name come out the same, in +this release and in every later one. That promise fixes every choice here. The generator +is PCG64, in the `pcg_setseq_128_xsl_rr_64` form with 128 bits of state and a 64-bit +output, the same generator numpy calls `PCG64` rather than its `PCG64DXSM`. Each +[`next_uint64`][osrlib.core.rng.RngStream.next_uint64] advances the state first and then +takes the output from the new state, following the C implementation numpy follows. +Streams are forked from the master seed as +`SHA-256(master_seed_bytes + b":" + stream_key_utf8)`, with the master seed written as 16 +bytes, most significant first. Reimplementing any of that, even in a way that looks +equivalent, shifts draws and breaks saved games. + +Nothing in osrlib reaches for Python's `random` module or keeps a generator of its own. +If you're writing a rule of your own to run beside osrlib's, take a stream as an +argument the same way. + +Typical usage: + +```python +from osrlib.core.dice import roll +from osrlib.core.rng import RngStreams + +streams = RngStreams(master_seed=42) + +# Each name is its own sequence, so one subsystem's draws never move another's. +attack = roll("1d20", streams.get("combat")) +gold = roll("2d6×100", streams.get("treasure")) +assert attack.total == 14 +assert gold.total == 600 + +# Same seed, same name, same draws. +assert roll("1d20", RngStreams(master_seed=42).get("combat")).total == 14 +``` """ import hashlib @@ -44,22 +72,38 @@ def derive_init_pair(master_seed: int, key: str) -> tuple[int, int]: - """Derive a PCG64 `(initstate, initseq)` pair for a named stream. + """Work out the two numbers PCG64 needs to start a named stream. - Seed material is `SHA-256(master_seed_bytes + b":" + stream_key_utf8)` with the - master seed encoded as fixed-width 16-byte big-endian. The 32-byte digest splits - into the init pair, both halves read big-endian: bytes 0-15 are `initstate`, - bytes 16-31 are `initseq`. + You rarely call this. [`RngStreams.get`][osrlib.core.rng.RngStreams.get] calls it for + you, and [`RngStream.from_seed_material`][osrlib.core.rng.RngStream.from_seed_material] + wraps it in one step. Reach for it when you're checking osrlib's forking against + another implementation, or building the same stream in another language. + + The seed material is `SHA-256(master_seed_bytes + b":" + stream_key_utf8)`, with the + master seed written as exactly 16 bytes, most significant first. The digest's first + 16 bytes become the init state and its last 16 the sequence selector, each read most + significant byte first. Args: - master_seed: The session's master seed, in `[0, 2**128)`. - key: The stream's name, e.g. `"combat"` or `"treasure"`. + master_seed: The game's master seed, from 0 up to but not including 2**128. + key: The stream's name, such as `"combat"` or `"treasure"`. Returns: - The `(initstate, initseq)` pair for the canonical PCG64 init. + The `(initstate, initseq)` pair, ready for + [`RngStream`][osrlib.core.rng.RngStream]. Raises: - ValueError: If `master_seed` is out of range. + ValueError: If `master_seed` is outside the allowed range. + + Examples: + ```python + from osrlib.core.rng import RngStream, derive_init_pair + + initstate, initseq = derive_init_pair(42, "combat") + + # The pair is what RngStreams.get builds its stream from. + assert RngStream(initstate, initseq).next_uint64() == 12816652903456971652 + ``` """ if not 0 <= master_seed < _SEED_BOUND: raise ValueError(f"master_seed must be in [0, 2**128), got {master_seed}") @@ -71,17 +115,44 @@ def derive_init_pair(master_seed: int, key: str) -> tuple[int, int]: class RngStreamState(BaseModel): - """A serializable snapshot of an in-progress stream. + """Where a stream had got to, in a form you can write to disk. + + [`RngStream.export_state`][osrlib.core.rng.RngStream.export_state] returns one and + [`RngStream.restore`][osrlib.core.rng.RngStream.restore] takes it back, so a game + saved halfway through a dungeon resumes on the very next draw rather than starting + the sequence over. [`save_game`][osrlib.persistence.save_game] and + [`load_game`][osrlib.persistence.load_game] do this for every stream a session has + touched, so you only build one of these yourself when you're saving a game without a + session. + + The two numbers are the generator's internals. Read them if you're comparing + implementations. Don't compute them. + + Examples: + ```python + from osrlib.core.rng import RngStream, RngStreams + + stream = RngStreams(master_seed=42).get("combat") + stream.randbelow(20) - Captures the raw PCG64 internals — the 128-bit LCG state and the stream increment — - so saves can restore mid-sequence streams exactly via - [`RngStream.restore`][osrlib.core.rng.RngStream.restore]. + # Save the position, draw on, and a restored copy continues from the save. + snapshot = stream.export_state() + expected = stream.randbelow(20) + assert RngStream.restore(snapshot).randbelow(20) == expected + ``` """ model_config = ConfigDict(frozen=True) state: int = Field(ge=0, lt=_SEED_BOUND) + """The generator's 128-bit state: how far along the sequence the stream has got.""" + inc: int = Field(ge=0, lt=_SEED_BOUND) + """The generator's increment, which is what makes one stream's sequence differ from another's. + + Always odd, by the way PCG builds it, and a pydantic `ValidationError` says so if you + pass an even number. + """ @field_validator("inc") @classmethod @@ -92,28 +163,49 @@ def _inc_must_be_odd(cls, value: int) -> int: class RngStream: - """A single PCG64 stream. + """One named sequence of random numbers, which you pass to whatever needs to roll. + + Get one from [`RngStreams.get`][osrlib.core.rng.RngStreams.get] rather than building + it, unless you're writing a test or using a single stream on its own. Every function + in the kernel that rolls anything takes one of these, and it's the only source of + randomness in the library. + + Drawing advances the stream, so two calls give two different results and the order of + your calls is part of what the seed determines. Pass the stream itself, never a copy. + Draw with [`randbelow`][osrlib.core.rng.RngStream.randbelow] for a bounded number, or + let [`roll`][osrlib.core.dice.roll] do it from a dice expression. + + Constructing one directly runs PCG64's own initialization from an + `(initstate, initseq)` pair, which is what + [`derive_init_pair`][osrlib.core.rng.derive_init_pair] produces from a master seed and + a name. - Construct via [`RngStreams.get`][osrlib.core.rng.RngStreams.get] in normal play; - direct construction from an `(initstate, initseq)` pair runs the canonical PCG64 - init and exists for tests and à la carte use. + Examples: + ```python + from osrlib.core.rng import RngStreams + + stream = RngStreams(master_seed=42).get("combat") + assert stream.randbelow(20) + 1 == 14 + ``` """ __slots__ = ("_inc", "_state") def __init__(self, initstate: int, initseq: int) -> None: - """Initialize the stream with the canonical PCG64 init procedure. + """Start the stream from a PCG64 init pair. - The init is `state = 0; inc = (initseq << 1) | 1; step; state += initstate; - step`, all mod 2**128. It discards the top bit of `initseq` — expected - behavior, not a bug to fix. + The initialization is PCG's own: set the state to 0, set the increment to + `(initseq << 1) | 1`, step, add `initstate`, step, everything modulo 2**128. It + drops the top bit of `initseq`, which is what the reference implementation does + and not a defect to work around. Args: - initstate: The 128-bit init state, in `[0, 2**128)`. - initseq: The 128-bit stream-selection constant, in `[0, 2**128)`. + initstate: The 128-bit init state, from 0 up to but not including 2**128. + initseq: The 128-bit sequence selector, in the same range. Two streams with + the same init state and different selectors draw different sequences. Raises: - ValueError: If either argument is out of range. + ValueError: If either argument is outside the allowed range. """ if not 0 <= initstate < _SEED_BOUND: raise ValueError(f"initstate must be in [0, 2**128), got {initstate}") @@ -127,29 +219,43 @@ def __init__(self, initstate: int, initseq: int) -> None: @classmethod def from_seed_material(cls, master_seed: int, key: str) -> RngStream: - """Derive and initialize the named stream for a master seed. + """Build the named stream for a master seed, in one step. + + Use this when you want a single stream and no container. + [`RngStreams`][osrlib.core.rng.RngStreams] is the better choice when you want + several, because it remembers each one and can save them all together. Args: - master_seed: The session's master seed, in `[0, 2**128)`. + master_seed: The game's master seed, from 0 up to but not including 2**128. key: The stream's name. Returns: - A freshly initialized stream; the same arguments always produce a stream - that yields the identical draw sequence. + A stream at the start of its sequence. The same seed and name always give a + stream that draws the same numbers. + + Examples: + ```python + from osrlib.core.rng import RngStream + + stream = RngStream.from_seed_material(42, "combat") + assert stream.randbelow(20) + 1 == 14 + ``` """ return cls(*derive_init_pair(master_seed, key)) @classmethod def restore(cls, snapshot: RngStreamState) -> RngStream: - """Restore a stream from an exported snapshot. + """Rebuild a stream at the position a snapshot recorded. + + Use it when you're loading a game you saved yourself. + [`load_game`][osrlib.persistence.load_game] restores a session's streams for you. Args: - snapshot: A state previously returned by + snapshot: A position from [`export_state`][osrlib.core.rng.RngStream.export_state]. Returns: - A stream that continues the draw sequence exactly where the exporting - stream left off. + A stream whose next draw is the one the saved stream would have made. """ stream = cls.__new__(cls) stream._state = snapshot.state @@ -157,10 +263,15 @@ def restore(cls, snapshot: RngStreamState) -> RngStream: return stream def export_state(self) -> RngStreamState: - """Export the stream's exact position for serialization. + """Record where the stream has reached, so you can come back to it. + + Pair it with [`restore`][osrlib.core.rng.RngStream.restore]. To save a whole + game's worth of streams at once, call + [`RngStreams.export_states`][osrlib.core.rng.RngStreams.export_states] instead. + Exporting draws nothing and leaves the stream where it was. Returns: - A frozen snapshot of the raw PCG64 state and increment. + A frozen record of the position, ready to serialize. """ return RngStreamState(state=self._state, inc=self._inc) @@ -168,13 +279,19 @@ def _step(self) -> None: self._state = (self._state * _PCG_MULTIPLIER + self._inc) & _MASK128 def next_uint64(self) -> int: - """Draw the next raw 64-bit output. + """Draw the next raw 64-bit number from the stream. - Advances the LCG first, then applies XSL-RR to the new state (the pcg-c - 128-bit convention). + This is the generator's own output, with no bound applied. For a die or any + bounded value, call [`randbelow`][osrlib.core.rng.RngStream.randbelow] instead: + taking a remainder of this number yourself biases the result, and it draws a + different count of raw numbers than osrlib does, which puts the stream out of step + with a replay. + + The stream advances first and then produces the output from its new state, + following the reference C implementation. Returns: - A uniformly distributed integer in `[0, 2**64)`. + A number from 0 up to but not including 2**64, each equally likely. """ self._step() state = self._state @@ -183,23 +300,37 @@ def next_uint64(self) -> int: return ((xored >> rot) | (xored << ((64 - rot) & 63))) & _MASK64 def randbelow(self, n: int) -> int: - """Draw a uniformly distributed integer in `[0, n)`. + """Draw a number from 0 up to but not including `n`, each equally likely. + + This is the bounded draw everything in osrlib is built on. For a die, add 1: + `stream.randbelow(6) + 1` is a d6. For a dice expression, call + [`roll`][osrlib.core.dice.roll] instead and let it do the arithmetic. - The algorithm is frozen as top-bits rejection sampling: with - `k = (n - 1).bit_length()`, each candidate is `next_uint64() >> (64 - k)`, - rejected and redrawn while `candidate >= n`. No masking of low bits. - Rejection means the raw-draw count per bounded draw is variable: power-of-two - bounds never reject; others (3, 6, 10, 12, 20, 100) can. `randbelow(1)` has - `k = 0`, always yields 0, and still consumes one draw. + How many raw numbers a single call consumes varies. The method takes the top bits + of a raw draw and throws the candidate away if it lands at or above `n`, which is + what keeps every result equally likely. A bound that's a power of two never throws + anything away, and bounds of 3, 6, 10, 12, 20, and 100 sometimes do, so a stream's + position after a roll depends on which values came up rather than on how many + times you called. `randbelow(1)` returns 0 and still uses a draw. Args: - n: The exclusive upper bound. Must be positive. + n: The bound, which the result stays below. Must be positive. Returns: - A uniformly distributed integer in `[0, n)`. + A number from 0 up to but not including `n`. Raises: - ValueError: If `n <= 0`. + ValueError: If `n` is zero or negative. + + Examples: + ```python + from osrlib.core.rng import RngStreams + + stream = RngStreams(master_seed=42).get("combat") + + # A d20 is a draw below 20, plus one. + assert stream.randbelow(20) + 1 == 14 + ``` """ if n <= 0: raise ValueError(f"n must be positive, got {n}") @@ -212,11 +343,23 @@ def randbelow(self, n: int) -> int: class RngStreams: - """The named-stream container forked from a session's master seed. + """All of a game's random number streams, forked from one master seed. + + Make one with the seed you want the game to run on, then call + [`get`][osrlib.core.rng.RngStreams.get] for each stream you need. A stream is built + the first time you ask for it and kept, so asking again gives you the same stream at + the position you left it. Which streams exist is up to you, and a name you've never + used gets a stream of its own the first time you ask. - Streams are created lazily on first access and cached: the same key always returns - the same stream object, and stream identity depends only on the master seed and the - key string. + Keep one container for the whole game and pass streams out of it. Two containers on + the same seed draw the same numbers as each other, which means handing out streams + from a second container quietly repeats rolls the first one already made. + + A [`GameSession`][osrlib.crawl.session.GameSession] keeps one of these, so you only + build your own when you're running the kernel without a session. Save a game's worth + of positions with [`export_states`][osrlib.core.rng.RngStreams.export_states] and put + them back with [`restore_states`][osrlib.core.rng.RngStreams.restore_states]. [The RNG + streams reference][rng-streams] lists the names a session uses. Examples: ```python @@ -224,8 +367,10 @@ class RngStreams: streams = RngStreams(master_seed=42) combat = streams.get("combat") - d20 = combat.randbelow(20) + 1 - assert 1 <= d20 <= 20 + + # The same name gives back the same stream, mid-sequence. + assert streams.get("combat") is combat + assert combat.randbelow(20) + 1 == 14 ``` """ @@ -235,10 +380,12 @@ def __init__(self, master_seed: int) -> None: """Create the container for a master seed. Args: - master_seed: The session's master seed, in `[0, 2**128)`. + master_seed: The game's master seed, from 0 up to but not including 2**128. + Any integer in range works. Record the one you used if you want to + replay the game. Raises: - ValueError: If `master_seed` is out of range. + ValueError: If `master_seed` is outside the allowed range. """ if not 0 <= master_seed < _SEED_BOUND: raise ValueError(f"master_seed must be in [0, 2**128), got {master_seed}") @@ -247,17 +394,37 @@ def __init__(self, master_seed: int) -> None: @property def master_seed(self) -> int: - """The master seed this container forks streams from.""" + """Return the master seed every stream in this container is forked from. + + Read it to record what a game was seeded with, so you can rebuild the same + container later. It cannot be changed: a container's seed is fixed when you make + it. + """ return self._master_seed def get(self, key: str) -> RngStream: - """Return the named stream, creating it on first use. + """Return the stream with this name, making it the first time you ask. + + Pass what comes back to whatever draws. Args: - key: The stream's name, e.g. `"combat"` or `"treasure"`. + key: The stream's name, such as `"combat"` or `"treasure"`. Any string works; + [the RNG streams reference][rng-streams] lists the ones a session uses. Returns: - The stream for `key`; repeated calls return the same object. + The stream for that name, at whatever position it has reached. Asking twice + gives the same stream, not a copy. + + Examples: + ```python + from osrlib.core.rng import RngStreams + + streams = RngStreams(master_seed=42) + + # Two names, two independent sequences. + assert streams.get("combat").randbelow(20) + 1 == 14 + assert streams.get("treasure").randbelow(6) + 1 == 4 + ``` """ stream = self._streams.get(key) if stream is None: @@ -266,22 +433,58 @@ def get(self, key: str) -> RngStream: return stream def export_states(self) -> dict[str, RngStreamState]: - """Export every touched stream's exact position, keyed by stream name. + """Record where every stream you've used has reached, keyed by name. + + Write the result into your save file alongside the master seed, and put it back + with [`restore_states`][osrlib.core.rng.RngStreams.restore_states] when you load. + [`save_game`][osrlib.persistence.save_game] does this for a session, so call it + yourself only when you're saving a game you built without one. - Untouched streams need no snapshot — they re-derive from the master seed - on first use. Keys are sorted so serialization is deterministic. + A name you've never asked for is left out. A stream like that has drawn nothing, + and it rebuilds itself from the master seed the first time you use it. Returns: - The stream snapshots for saves. + One position per stream that has been used, in sorted name order so two saves + of the same game are byte for byte the same. + + Examples: + ```python + from osrlib.core.rng import RngStreams + + streams = RngStreams(master_seed=42) + streams.get("combat").randbelow(20) + + # Only the stream that was used is recorded. + assert sorted(streams.export_states()) == ["combat"] + ``` """ return {key: self._streams[key].export_state() for key in sorted(self._streams)} def restore_states(self, states: dict[str, RngStreamState]) -> None: - """Restore previously exported stream positions. + """Put saved stream positions back, so a loaded game draws on from where it stopped. + + Call it on a container built with the same master seed the game was saved under. + A stream named in `states` is replaced. One that isn't is left alone, and a stream + that has never been used rebuilds itself from the seed. Args: - states: Snapshots from + states: Positions from [`export_states`][osrlib.core.rng.RngStreams.export_states]. + + Examples: + ```python + from osrlib.core.rng import RngStreams + + streams = RngStreams(master_seed=42) + streams.get("combat").randbelow(20) + saved = streams.export_states() + expected = streams.get("combat").randbelow(20) + + # A fresh container on the same seed picks up the next draw, not the first. + loaded = RngStreams(master_seed=42) + loaded.restore_states(saved) + assert loaded.get("combat").randbelow(20) == expected + ``` """ for key, snapshot in states.items(): self._streams[key] = RngStream.restore(snapshot) diff --git a/src/osrlib/core/ruleset.py b/src/osrlib/core/ruleset.py index 7d491a2..d581a72 100644 --- a/src/osrlib/core/ruleset.py +++ b/src/osrlib/core/ruleset.py @@ -1,13 +1,45 @@ -"""The `Ruleset` model: optional-rule and adaptation flags. - -Every SRD optional rule and every documented adaptation is a named flag with a -default. Flags are read at resolution time, so a `Ruleset` is fixed for the life of a -session (it participates in saves and replays). The model is frozen and rejects unknown -flags — a typo'd flag name errors instead of silently doing nothing. +"""The house rules a game plays under, as one frozen set of flags. + +[`Ruleset`][osrlib.core.ruleset.Ruleset] is the entry point and almost the whole module. +Build one at the start of a game, pass it to +[`GameSession.new`][osrlib.crawl.session.GameSession.new] or, away from a session, to +each kernel function that takes a `ruleset` argument, and leave it alone after that. The +resolution functions read it as they work, so every flag takes effect the moment you set +it, and the ruleset travels inside saves so a game resumes under the rules it began +with. + +`Ruleset()` with no arguments plays the SRD as written, which is what you want unless +you have a reason to change something. Each flag is either an optional rule the SRD +prints as an alternative to its own default, or an adaptation: a decision the tabletop +game leaves to a human referee, which a program running unattended has to make somehow. +The two enum flags, [`EncumbranceMode`][osrlib.core.ruleset.EncumbranceMode] and +[`XpAwardTiming`][osrlib.core.ruleset.XpAwardTiming], pick among alternatives instead of +switching one behavior on and off. + +[The ruleset options guide][ruleset-options] walks through each flag with the play it +changes, and [the adaptations register](https://mmacy.github.io/osrlib-python/adaptations/), +the page that lists every place osrlib supplies a default the tabletop game leaves to a +referee, gives the reasoning behind each one. + +Typical usage: + +```python +from osrlib.core.ruleset import EncumbranceMode, Ruleset, XpAwardTiming + +# The SRD as written. +rules = Ruleset() +assert rules.variable_weapon_damage is True +assert rules.encumbrance is EncumbranceMode.BASIC +assert rules.xp_award_timing is XpAwardTiming.ON_RETURN + +# A game that pays experience out as it's earned and tracks no encumbrance. +house_rules = Ruleset(xp_award_timing=XpAwardTiming.IMMEDIATE, encumbrance=EncumbranceMode.NONE) +assert house_rules.variable_weapon_damage is True +``` """ -# Every flag here is read by an implemented behavior; never ship a flag whose behavior -# doesn't exist. Adding new flags with defaults is schema-legal. +# Every flag here is read by a behavior that exists. Never ship a flag whose behavior +# doesn't. Adding new flags with defaults is schema-legal. from enum import StrEnum @@ -21,115 +53,202 @@ class XpAwardTiming(StrEnum): - """When the XP award fires; see the adventure award procedure. + """When a party is paid the experience points it has earned. + + Set it on [`Ruleset.xp_award_timing`][osrlib.core.ruleset.Ruleset]. The choice is + about pacing: `ON_RETURN` makes getting out of the dungeon alive the thing that pays, + and `IMMEDIATE` pays as the party goes, which suits a game with no trip home. - The wire values are `"on_return"` and `"immediate"` — lowercase, serialized - into saves; changing them is a `schema_version` bump. + The lowercase values serialize into saved games. Changing one is a `schema_version` + bump, the version stamp that marks a serialized model's shape. """ ON_RETURN = "on_return" + """Award experience when the party survives and returns to safety, as the tabletop rules have it. + + Experience for defeated monsters and recovered treasure is held until the party gets + back to town, and treasure lost on the way is never paid for. + """ + IMMEDIATE = "immediate" + """Award experience as it's earned: monsters at the end of each encounter, treasure as it's picked up. + + This is an adaptation for continuous play, where the party may never make a trip + home. Reaching town pays nothing extra, and dropping treasure doesn't take the + experience back. + """ class EncumbranceMode(StrEnum): - """How carried weight is tracked; see [`osrlib.core.items`][osrlib.core.items]. + """Which system tracks what the party is carrying, and how much it slows them down. + + Set it on [`Ruleset.encumbrance`][osrlib.core.ruleset.Ruleset]. The weights and the + movement rates live in [`osrlib.core.items`][osrlib.core.items], which reads this + choice. - The wire values are `"none"`, `"basic"`, and `"detailed"` — lowercase, serialized - into saves; changing them is a `schema_version` bump. + The lowercase values serialize into saved games. Changing one is a `schema_version` + bump, the version stamp that marks a serialized model's shape. """ NONE = "none" + """Track nothing: every character moves at the base rate of 120 feet per turn, whatever they carry.""" + BASIC = "basic" + """Set movement rate from worn armour, and from whether the character is carrying a significant amount of treasure. + + This is the SRD's own default and the mode a `Ruleset()` picks. A character whose + tracked weight passes the maximum load cannot move at all. + """ + DETAILED = "detailed" + """Total the coin weight of armour, gear, and treasure, and set movement rate from banded weight thresholds. + + Heavier bookkeeping than `BASIC`, and the same maximum load: past it, the character + cannot move. + """ class Ruleset(BaseModel): - """The optional-rule and adaptation flags a session plays under. - - Each flag is either an optional rule from the OSE SRD or a documented adaptation - (see the adaptations register on the documentation site). Build a `Ruleset` when a - session starts; it is frozen, and it travels with saves and replays so a game - always resumes under the rules it began with. - - Attributes: - hp_reroll_at_first_level: SRD optional rule, default off: first-level - hit-point dice showing 1 or 2 (the raw die, before the CON modifier) are - re-rolled until the die shows 3 or more. - encumbrance: Which encumbrance system tracks carried weight and drives - movement rates; see [`osrlib.core.items`][osrlib.core.items]. Default - basic. - variable_weapon_damage: SRD optional rule, default on: each weapon deals the - damage listed in its description. Off means every weapon — and every - piece of gear swung as one — deals 1d6, the SRD's baseline "PC attacks - inflict 1d6 damage" rule. Unarmed attacks stay 1d2 either way (their own - rule, not weapon damage), and monster damage is unaffected: monsters - always deal the damage in their descriptions. - individual_initiative: SRD optional rule, default off: each combat - participant rolls its own 1d6 initiative, DEX-modified for characters - (plus the halfling's `initiative_bonus` class tag). The tabletop game - leaves monster initiative modifiers to the referee; osrlib takes a - caller-supplied modifier, default 0. - thac0_arithmetic: SRD optional rule, default off: the attack target number is - unclamped `THAC0 − AC` arithmetic instead of the attack-matrix lookup. - The SRD's ascending-AC attack procedure is algebraically identical, so - this one flag covers both presentations; the matrix differs only through - its 2..20 clamping. - weapon_reload: SRD optional rule, default off: a weapon with the reload - quality cannot fire two rounds running. The attack validator rejects the - shot when the caller-supplied context says the weapon fired last round; - round-to-round bookkeeping belongs to the battle layer, and the kernel - enforces the rule given honest context. - hd5_counts_as_magical: The SRD's invulnerabilities optional rule, default - off: a monster of 5 or more Hit Dice — or another invulnerable monster — - can harm creatures otherwise hurt only by silver or magic weapons. - Following the rule's own wording, osrlib applies the flag only to - weapon-material requirements limited to silver and magic, and reads - "another invulnerable monster" as a monster bearing such a requirement - itself. - deprivation_penalties: A documented adaptation, default off. Food and water - consumption is tracked either way; this flag controls whether going - without carries penalties. The tabletop game leaves starvation penalties - to the referee ("at the referee's discretion, for example..."); osrlib - fixes a schedule drawn from the SRD's own examples — see the adaptations - register. After one full day without food or water: −1 to attack rolls, - and rest is needed twice as often (fatigue after three unrested turns - instead of six). After two days: movement also halves. From the third day - on: 1d4 hit points lost per day. Food and water deprivation don't stack — - the worse track applies. - magic_item_death_save: The SRD's referee-optional saving throw for magic - items whose owner dies to a destructive effect, default on: each magic - item in the doomed inventory rolls the owner's save values against the - destructive source's category, adding the item's best combat bonus. - Survivors land in a drop pile at the victim's cell rather than vanishing — - surviving the blast but not the looting would be no survival at all. - xp_award_timing: When XP awards fire, default `on_return` — the tabletop - rule: XP for defeated monsters and recovered treasure is awarded when the - party survives and returns to safety. `immediate` is a documented - adaptation for continuous CRPG play: monster XP lands at each encounter's - end, treasure XP at each acquisition, nothing more on reaching town, and - dropped treasure never refunds. - aoe_friendly_fire: A documented adaptation, default on: an area effect - landing on a monster group at melee range catches engaged party members - among its candidates. Off means area effects never include party members - among a monster group's candidates. - formation_width_limit: A documented adaptation, default on: passage width - caps how many combatants fight abreast — three inside a keyed area, two - in corridor cells — following the SRD's "2–3 characters fighting - side-by-side in a 10' wide passage". Off lifts the cap: every combatant - may melee. + """The rules a game plays under: one flag per decision osrlib lets you make. + + Build one at the start of a game and pass it wherever a `ruleset` argument appears. + `Ruleset()` plays the SRD as written, so name only the flags you want to change. The + model is frozen, so you cannot edit a ruleset mid-game, and it goes into saves and + replays, so a resumed game keeps the rules it started with. It also refuses a field + name it doesn't know, which turns a misspelled flag into a pydantic + `ValidationError` rather than a setting that quietly does nothing. + + Each flag is one of two kinds. An optional rule is an alternative the SRD itself + prints beside its own procedure, and osrlib's default is the book's. An adaptation is + a default osrlib supplies where the tabletop game hands the decision to a human + referee, and [the adaptations register](https://mmacy.github.io/osrlib-python/adaptations/) + gives the reasoning behind each one. + + Examples: + ```python + from osrlib.core.ruleset import Ruleset + + rules = Ruleset(individual_initiative=True, hp_reroll_at_first_level=True) + assert rules.individual_initiative is True + + # The rest keep the book's defaults. + assert rules.thac0_arithmetic is False + assert rules.magic_item_death_save is True + ``` """ model_config = ConfigDict(frozen=True, extra="forbid") hp_reroll_at_first_level: bool = False + """Reroll a first-level hit die that comes up 1 or 2. An optional rule, off by default. + + Turn it on and the raw die is rerolled until it shows 3 or more, before the CON + modifier is added, so a first-level character starts with at least 3 hit points before + that modifier. + """ + encumbrance: EncumbranceMode = EncumbranceMode.BASIC + """Which system tracks carried weight and sets movement rate. Basic by default. + + See [`EncumbranceMode`][osrlib.core.ruleset.EncumbranceMode] for what each mode + counts, and [`osrlib.core.items`][osrlib.core.items] for the weights and rates it + reads. + """ + variable_weapon_damage: bool = True + """Let each weapon deal the damage its own description lists. An optional rule, on by default. + + Turn it off and every weapon deals 1d6, and so does every piece of gear swung as one, + which is the SRD's baseline combat system. Either way an unarmed attack deals 1d2, which is + its own rule rather than weapon damage, and monsters always deal the damage printed + in their descriptions. + """ + individual_initiative: bool = False + """Roll initiative for each combatant instead of once per side. An optional rule, off by default. + + Turn it on and every participant rolls its own 1d6. A character adds its DEX + modifier, and a halfling adds its class initiative bonus on top. The tabletop game leaves a monster's + initiative modifier to the referee, so osrlib takes one from the caller and defaults + it to 0. + """ + thac0_arithmetic: bool = False + """Work out the attack target number by subtraction instead of reading the attack matrix. + + An optional rule, off by default. Turn it on and the number to roll is `THAC0 − AC` + with no clamping. The SRD's ascending-armour-class procedure is the same arithmetic, + so this one flag covers both presentations. The matrix differs only in keeping its + cells within 2 to 20, which shows once modifiers push a total past those bounds. + """ + weapon_reload: bool = False + """Stop a weapon with the reload quality, mainly the crossbow, from firing two rounds running. + + An optional rule, off by default. Turn it on and the attack validator refuses the shot + when the combat context you pass says the weapon fired last round. The kernel enforces the rule + from the context it's given. Keeping track of what fired when is the battle layer's + job. + """ + hd5_counts_as_magical: bool = False + """Let big monsters hurt creatures that only silver or magic weapons can harm. An optional rule, off by default. + + The SRD prints this among its invulnerability rules. Turn it on and a monster of 5 or + more Hit Dice gets past such a defense, and so does a monster that has the same defense + itself. Following the rule's own wording, osrlib applies it only where the defense is + limited to silver and magic, and reads "another invulnerable monster" as one bearing + such a defense. + """ + magic_item_death_save: bool = True + """Give a dead character's magic items a saving throw against the effect that killed their owner. + + An optional rule the SRD leaves to the referee, on by default. Each magic item in the + doomed inventory rolls the owner's save values against the destructive source's + category, adding the item's best combat bonus. What survives lands in a drop pile at + the victim's cell, where the party can pick it up, rather than vanishing with the + body. + """ + xp_award_timing: XpAwardTiming = XpAwardTiming.ON_RETURN + """When earned experience is actually paid out. On return by default, which is the tabletop rule. + + See [`XpAwardTiming`][osrlib.core.ruleset.XpAwardTiming]. The immediate setting is an + adaptation, osrlib's default where the tabletop game hands the decision to a referee, + and it suits a game whose party never goes home. + """ + deprivation_penalties: bool = False + """Attach mechanical penalties to going without food or water. An adaptation, off by default. + + An adaptation is a default osrlib supplies where the tabletop game hands the decision + to a referee, and the tabletop rules leave starvation penalties to the referee's + discretion. The party's food and water are tracked either way, so this flag changes + only what happens once they run out. Turn it on and the schedule osrlib draws from the + SRD's own examples applies. After one full day without: −1 to attack rolls, and rest needed + twice as often, so fatigue sets in after three unrested turns rather than six. After + two days, movement halves as well. From the third day on, 1d4 hit points are lost per + day. Hunger and thirst don't stack, so whichever is worse applies. + [The adaptations register](https://mmacy.github.io/osrlib-python/adaptations/) has the + reasoning. + """ + aoe_friendly_fire: bool = True + """Let an area effect cast at a monster group in melee catch the party members fighting it. + + An adaptation, on by default, an adaptation being a default osrlib supplies where the + tabletop game hands the decision to a referee. Turn it off and an area effect aimed at + a monster group never counts party members among its candidates, which makes a + fireball safe to drop on a melee. + """ + formation_width_limit: bool = True + """Cap how many combatants can fight side by side, by how wide the passage is. + + An adaptation, on by default, an adaptation being a default osrlib supplies where the + tabletop game hands the decision to a referee. Turn it on and three may fight abreast + inside a keyed area, two in a corridor cell, following the SRD's note about two or + three characters fighting side by side in a ten-foot passage. Turn it off and the cap + lifts, so every combatant may melee. + """ diff --git a/src/osrlib/core/tables.py b/src/osrlib/core/tables.py index fb53672..d64b68b 100644 --- a/src/osrlib/core/tables.py +++ b/src/osrlib/core/tables.py @@ -1,32 +1,45 @@ -"""The rules tables as data: attack matrix, saves, XP, turning, reaction, encounters. - -The combat tables — the attack matrix, monster saving throws, XP awards, turning -undead, and monster reactions — ship with the package as frozen models and load via -[`load_combat_tables`][osrlib.data.load_combat_tables]. The dungeon encounter tables -(six dungeon-level columns plus the NPC adventurer generation tables) load via -[`load_encounter_tables`][osrlib.data.load_encounter_tables]. Load first, then resolve -with the helpers here: [`to_hit_ac`][osrlib.core.tables.to_hit_ac], -[`reaction_result`][osrlib.core.tables.reaction_result], -[`monster_xp`][osrlib.core.tables.monster_xp], and their kin. - -The shipped attack matrix matches the OSE SRD verbatim, and every printed cell equals -`clamp(THAC0 − AC, 2, 20)`; AC values outside the printed −3..9 columns extend by the -same formula — the printed bounds are page layout, not a rules cliff. The clamping is -exactly what distinguishes matrix mode from the `thac0_arithmetic` ruleset flag once -modifiers push totals past the plateaus. - -Monster stat blocks carry explicit THAC0 and save values (already reflecting the -"bonus hit points attack as 1 HD higher" rule), so the HD-keyed lookups here serve -validation, custom monsters, and the save-as resolutions from packed-variant -expansion. +"""The printed rules tables, and the lookups that read an answer out of them. + +Two loaders get you the data. [`load_combat_tables`][osrlib.data.load_combat_tables] +gives you a [`CombatTables`][osrlib.core.tables.CombatTables] with the attack matrix, the +monster saving throws, the experience awards, the turning-undead table, and the monster +reaction table. [`load_encounter_tables`][osrlib.data.load_encounter_tables] gives you an +[`EncounterTables`][osrlib.core.tables.EncounterTables] with the wandering-monster table +for each dungeon level, along with the tables that generate an NPC adventuring party. +Both cache, so calling them repeatedly costs nothing. + +Then call the lookup you want. [`to_hit_ac`][osrlib.core.tables.to_hit_ac] says what an +attacker must roll, [`reaction_result`][osrlib.core.tables.reaction_result] turns a 2d6 +total into how a meeting starts, [`monster_xp`][osrlib.core.tables.monster_xp] says what +a monster is worth, and [`turning_column`][osrlib.core.tables.turning_column] with +[`TurningTable.result`][osrlib.core.tables.TurningTable.result] says whether a cleric +drives the undead off. Most take a monster's +[`MonsterHitDice`][osrlib.core.monsters.MonsterHitDice] rather than a whole monster, so +they work on a stat block you assembled yourself. + +In a game run by a [`GameSession`][osrlib.crawl.session.GameSession] you rarely call any +of this, because combat, encounters, and experience awards read the tables for you. Reach +for the module when you're building a tool, checking custom content, or running the rules +without a session. + +The attack matrix as shipped matches the SRD cell for cell, and every cell works out to +`THAC0 − AC` kept within 2 to 20. The printed columns run −3 to 9, and an armour class +outside them follows the same arithmetic, so an armour class the page never prints still +has an answer. That clamping is what separates the matrix from the `thac0_arithmetic` +flag on [`Ruleset`][osrlib.core.ruleset.Ruleset], and it shows only once modifiers push a +total past the ends. + +A monster's stat block includes its own THAC0 and saving throws, already reflecting the +rule that bonus hit points make a monster attack as though it had one more Hit Die. The +Hit Dice lookups here are therefore for checking a stat block, for a monster you wrote +yourself, and for the forms the shipped data expands into several templates. """ -# Layering: the encounter-table models live here in core, not in crawl/, because the -# osrlib/data/ loaders import their model homes and core modules import the loaders — -# a crawl/ model home would give the loaders a core → data → crawl transitive import. -# The crawl layer consumes these models; it doesn't define them. (The tables themselves -# compile from the SRD markdown sources into combat_tables.json / encounter_tables.json -# at build time.) +# The encounter-table models live here in core rather than in crawl/ because the +# osrlib/data/ loaders import their model homes and core modules import the loaders: a +# crawl/ home would give the loaders a core to data to crawl import chain. The crawl layer +# consumes these models and doesn't define them. The tables themselves compile from the +# SRD markdown sources into combat_tables.json and encounter_tables.json at build time. from enum import StrEnum from typing import Annotated, Literal @@ -71,11 +84,20 @@ ] TURNING_COLUMNS = ("1", "2", "2*", "3", "4", "5", "6", "7-9") -"""The turning table's monster-HD column labels, exactly as the SRD's cleric turning table prints them.""" +"""The column labels of the turning-undead table, in the order the SRD prints them. -# The attack matrix's HD rows as (max effective HD, THAC0). Effective HD is the count -# plus 1 for a bonus hit-point modifier ("attack as 1 HD higher"); negative modifiers -# keep the unmodified row (pinned — the goblin's 1-1 keeps 19 [0]). +Each label names the Hit Dice of the undead being turned, with `2*` for a two-Hit-Dice +monster that has a special ability and `7-9` covering three counts at once. Undead above 9 +Hit Dice have no column and cannot be turned. + +[`turning_column`][osrlib.core.tables.turning_column] picks the right label from a +monster's Hit Dice, so read the tuple when you're drawing the table rather than to choose +a column. +""" + +# The attack matrix's HD rows as (max effective HD, THAC0). Effective HD is the count plus +# 1 for a bonus hit-point modifier, the "attack as 1 HD higher" rule. A negative modifier +# keeps the unmodified row, so the goblin's 1-1 stays on 19 [0]. _MATRIX_HD_ROWS = ( (1, 19), (2, 18), @@ -97,17 +119,29 @@ class AttackMatrixRow(BaseModel): - """One attack matrix row: the monster-HD label, THAC0 both ways, and the cells. + """One row of the attack matrix: everything one class of attacker needs to hit. - `by_ac` maps AC −3..9 to the attack roll required to hit it, exactly as printed. + Read these to draw the matrix. To find a number to roll, call + [`to_hit_ac`][osrlib.core.tables.to_hit_ac] instead, which works for any armour class + rather than only the printed ones. """ model_config = ConfigDict(frozen=True) hd_label: str + """Which attacker the row is for, as the SRD labels it: `"NH"` for a normal human, then Hit Dice bands.""" + thac0: int = Field(ge=2, le=20) + """The roll this attacker needs to hit armour class 0, which is the number the whole row is derived from.""" + attack_bonus: int = Field(ge=-1) + """The same attacker written as an ascending-armour-class bonus, which is 19 minus the THAC0.""" + by_ac: dict[int, int] + """The roll needed against each descending armour class from −3 to 9, exactly as printed. + + Every value is the THAC0 minus the armour class, kept within 2 to 20. + """ @model_validator(mode="after") def _cells_cover_printed_columns(self) -> AttackMatrixRow: @@ -117,11 +151,17 @@ def _cells_cover_printed_columns(self) -> AttackMatrixRow: class AttackMatrix(BaseModel): - """The attack matrix: 16 THAC0 rows from `20 [-1]`/NH to `5 [+14]`.""" + """The SRD's attack matrix: what every class of attacker needs to roll against every armour class. + + Read it off [`CombatTables.attack_matrix`][osrlib.core.tables.CombatTables] to show the + table. The rest of the time call [`to_hit_ac`][osrlib.core.tables.to_hit_ac], which + gives the same answer for any THAC0 and any armour class. + """ model_config = ConfigDict(frozen=True) rows: tuple[AttackMatrixRow, ...] + """The rows, worst attacker first, each one better than the last.""" @model_validator(mode="after") def _rows_descend_by_thac0(self) -> AttackMatrix: @@ -132,32 +172,50 @@ def _rows_descend_by_thac0(self) -> AttackMatrix: class MonsterSaveBand(BaseModel): - """One monster saving-throw band. + """One band of the monster saving-throw table, covering a run of Hit Dice. - `min_hd`/`max_hd` bound the band's Hit Dice; NH is `min_hd=None` (normal humans - are "less than 1 Hit Die" with their own row) and 22+ is `max_hd=None`. + Find the band for a monster with + [`monster_save_band_label`][osrlib.core.tables.monster_save_band_label] and + [`CombatTables.save_band`][osrlib.core.tables.CombatTables.save_band]. A shipped + monster already has its own saving throws, so you need this for a monster you + wrote yourself or to check one you were given. """ model_config = ConfigDict(frozen=True) label: str + """The band as the SRD prints it: `"NH"` for a normal human, then `"1–3"` up to `"22 or more"`.""" + min_hd: int | None = None + """The lowest Hit Dice count in the band, or None on the normal-human row, which sits below 1 Hit Die.""" + max_hd: int | None = None + """The highest Hit Dice count in the band, or None on the open top row, which has no ceiling.""" + saves: SavingThrows + """The five saving throw numbers a monster in this band rolls against.""" class TurningResult(BaseModel): - """One turning-table cell, resolved. + """What the turning table says when a cleric of a given level faces undead of a given kind. - `outcome` follows the table legend: `fail` (—), `number` (succeeds when the 2d6 - turn roll meets `threshold`), `turn` (T — automatic), `destroy` (D — automatic, - and affected monsters are annihilated rather than turned). + [`TurningTable.result`][osrlib.core.tables.TurningTable.result] returns one. Act on + `outcome`: with `"number"` you roll 2d6 and compare it with `threshold`, and the other + three settle the attempt with no roll. """ model_config = ConfigDict(frozen=True) outcome: str + """What happens, as one of four words. + + `"fail"` means the cleric cannot touch these undead. `"number"` means roll 2d6 and + meet `threshold`. `"turn"` means the undead flee with no roll. `"destroy"` means they + are annihilated outright rather than driven off. + """ + threshold: int | None = None + """The 2d6 total the cleric must reach, on a `"number"` outcome only, and None on the other three.""" @model_validator(mode="after") def _threshold_only_on_number(self) -> TurningResult: @@ -169,15 +227,24 @@ def _threshold_only_on_number(self) -> TurningResult: class TurningRow(BaseModel): - """One cleric level's turning row: cells keyed by the monster-HD column label. + """One cleric level's row of the turning table, as printed. - Cell values are exactly as printed: `—`, a threshold number, `T`, or `D`. + Read these to draw the table. To resolve an attempt, call + [`TurningTable.result`][osrlib.core.tables.TurningTable.result], which reads the cell + and hands back something you can act on. """ model_config = ConfigDict(frozen=True) label: str + """The cleric level the row is for, as `"1"` through `"10"`, and `"11+"` for the open top row.""" + cells: dict[str, str] + """The row's cells, keyed by the column labels in [`TURNING_COLUMNS`][osrlib.core.tables.TURNING_COLUMNS]. + + Each value is exactly what the page prints: an em dash for no effect, a number to roll + against, `T` for an automatic turn, or `D` for automatic destruction. + """ @model_validator(mode="after") def _cells_cover_printed_columns(self) -> TurningRow: @@ -190,11 +257,17 @@ def _cells_cover_printed_columns(self) -> TurningRow: class TurningTable(BaseModel): - """The turning-undead table: 11 cleric-level rows (1–10, `11+`) × 8 HD columns.""" + """The turning-undead table: what a cleric of each level can do to undead of each kind. + + Get it from [`CombatTables.turning`][osrlib.core.tables.CombatTables] and call + [`result`][osrlib.core.tables.TurningTable.result]. Turning is a cleric's own ability, + so a magic-user or a fighter never reads this table. + """ model_config = ConfigDict(frozen=True) rows: tuple[TurningRow, ...] + """One row per cleric level, from level 1 up to the open `11+` row.""" @model_validator(mode="after") def _rows_cover_printed_levels(self) -> TurningTable: @@ -204,20 +277,45 @@ def _rows_cover_printed_levels(self) -> TurningTable: return self def result(self, cleric_level: int, column: str) -> TurningResult: - """Resolve the turning cell for a cleric level and monster-HD column. + """Say what happens when this cleric tries to turn these undead. - Levels above 10 clamp to the `11+` row (the printed table's own semantics). + Get the column from [`turning_column`][osrlib.core.tables.turning_column], which + returns None for undead too powerful to be turned at all. There's nothing to look + up in that case. A cleric above level 10 reads the `11+` row, which is what the + printed table intends. + + This is the lookup alone. [`turn_undead`][osrlib.core.spells.turn_undead] rolls + the 2d6 a `"number"` outcome calls for and works out how many undead are + affected. Args: - cleric_level: The turning cleric's level, 1 or higher. - column: The monster-HD column label, from - [`turning_column`][osrlib.core.tables.turning_column]. + cleric_level: The cleric's level, 1 or higher. + column: A column label from + [`TURNING_COLUMNS`][osrlib.core.tables.TURNING_COLUMNS]. Returns: - The resolved cell. + What the cell says, ready to act on. Raises: - ValueError: If the level is below 1 or the column label is unknown. + ValueError: If the level is below 1, or the column label isn't one the table + prints. + + Examples: + ```python + from osrlib.data import load_combat_tables + + turning = load_combat_tables().turning + + # A first-level cleric needs a 7 against one-Hit-Die undead. + attempt = turning.result(1, "1") + assert (attempt.outcome, attempt.threshold) == ("number", 7) + + # The same cleric cannot touch three-Hit-Dice undead. + assert turning.result(1, "3").outcome == "fail" + + # At level 12 the weakest undead are destroyed outright. + assert turning.result(12, "1").outcome == "destroy" + ``` """ if cleric_level < 1: raise ValueError(f"cleric level must be positive, got {cleric_level}") @@ -235,44 +333,85 @@ def result(self, cleric_level: int, column: str) -> TurningResult: class XpAwardRow(BaseModel): - """One XP-awards row: the printed HD label, base XP, and bonus XP per ability.""" + """One row of the experience-award table, saying what a monster of that size is worth. + + [`monster_xp`][osrlib.core.tables.monster_xp] does the whole calculation, including the + special abilities, so read a row directly only to show the table. + """ model_config = ConfigDict(frozen=True) label: str + """The Hit Dice band as the SRD prints it, from `"Less than 1"` up to `"21–21+"`. + + A trailing `+` marks a monster whose Hit Dice have a bonus, which is worth more than + the same count without one. + """ + base: int = Field(ge=0) + """The experience a monster in this band is worth before its special abilities are counted.""" + bonus: int = Field(ge=0) + """The extra experience for each special ability the monster has, which its stat block marks with an asterisk.""" class ReactionResult(StrEnum): - """The five monster reaction bands, from the OSE SRD's encounter rules. + """How a meeting with a monster starts, from the SRD's encounter rules. + + [`reaction_result`][osrlib.core.tables.reaction_result] returns one from a 2d6 total. + The result says what the monsters do about the party, and nothing more: fighting, + talking, and buying them off are what you do next. - The wire values are lowercase — they serialize into events and saves; changing - them is a `schema_version` bump. + The lowercase values serialize into events and saved games. Changing one is a + `schema_version` bump, the version stamp that marks a serialized model's shape. """ ATTACKS = "attacks" + """The monsters attack at once, on a total of 2 or less.""" + HOSTILE = "hostile" + """The monsters are hostile and may attack, on a total of 3 to 5.""" + UNCERTAIN = "uncertain" + """The monsters are uncertain and confused, on a total of 6 to 8.""" + INDIFFERENT = "indifferent" + """The monsters are indifferent and may negotiate, on a total of 9 to 11.""" + FRIENDLY = "friendly" + """The monsters are eager and friendly, on a total of 12 or more.""" class ReactionBand(BaseModel): - """One reaction-table band: the printed 2d6 range and its result. + """One band of the reaction table: a range of 2d6 totals and what it means. - `min_total` is `None` for the open "2 or less" band and `max_total` is `None` - for "12 or more" — the table's own clamping semantics: totals outside the - printed 2..12 span land in the outer bands. + Read these to show the table. To resolve a roll, call + [`reaction_result`][osrlib.core.tables.reaction_result]. """ model_config = ConfigDict(frozen=True) label: str + """The range as the SRD prints it, such as `"2 or less"`, `"3–5"`, or `"12 or more"`.""" + text: str + """The SRD's own wording for the band, such as `"Hostile, may attack"`. + + It's the printed English, so use it for a referee's display rather than as text for + players. Key your own player-facing wording off `result` instead. + """ + min_total: int | None = None + """The lowest 2d6 total in the band, or None on the bottom band, which has no floor. + + A charisma modifier can take a total below 2, and such a total still lands here. + """ + max_total: int | None = None + """The highest 2d6 total in the band, or None on the top band, which has no ceiling.""" + result: ReactionResult + """What the band means, as the value to switch on.""" @model_validator(mode="after") def _band_must_be_bounded_or_open(self) -> ReactionBand: @@ -284,11 +423,16 @@ def _band_must_be_bounded_or_open(self) -> ReactionBand: class ReactionTable(BaseModel): - """The monster reaction table: five contiguous 2d6 bands.""" + """The monster reaction table: how a 2d6 total decides the way a meeting starts. + + Get it from [`CombatTables.reaction`][osrlib.core.tables.CombatTables] and pass it to + [`reaction_result`][osrlib.core.tables.reaction_result] with your rolled total. + """ model_config = ConfigDict(frozen=True) bands: tuple[ReactionBand, ...] + """The five bands, worst reaction first, covering every total with no gap between them.""" @model_validator(mode="after") def _bands_must_be_contiguous(self) -> ReactionTable: @@ -303,18 +447,33 @@ def _bands_must_be_contiguous(self) -> ReactionTable: def reaction_result(table: ReactionTable, total: int) -> ReactionResult: - """Resolve a 2d6 reaction total against the table. + """Read a rolled reaction total off the table. - Totals below 2 and above 12 clamp into the outer bands — the table's own - "2 or less" / "12 or more" semantics, so a CHA-modified total of 1 or 14 is - never out of range. + Roll 2d6, add the party spokesman's charisma modifier from + [`AbilityTables.npc_reaction_modifier`][osrlib.core.abilities.AbilityTables.npc_reaction_modifier], + and pass the total here. A modified total below 2 or above 12 is fine: the outer bands + are open, so nothing falls off the ends of the table. Args: - table: The loaded reaction table. - total: The modified 2d6 total. + table: The reaction table, from + [`CombatTables.reaction`][osrlib.core.tables.CombatTables]. + total: The 2d6 total with any modifier already added. Returns: - The reaction result. + How the monsters react. + + Examples: + ```python + from osrlib.core.tables import ReactionResult, reaction_result + from osrlib.data import load_combat_tables + + table = load_combat_tables().reaction + + assert reaction_result(table, 7) is ReactionResult.UNCERTAIN + + # A charismatic spokesman can push the total past the printed top. + assert reaction_result(table, 14) is ReactionResult.FRIENDLY + ``` """ for band in table.bands: if band.min_total is not None and total < band.min_total: @@ -326,26 +485,40 @@ def reaction_result(table: ReactionTable, total: int) -> ReactionResult: class MonsterEncounterEntry(BaseModel): - """An encounter-table cell resolving to monster template ids. + """The part of an encounter-table row that says which monsters turn up. - `monster_ids` resolve against the session's effective catalog — shipped ids from - [`load_monsters`][osrlib.data.load_monsters] (see - [the monster id index][monsters-index]) or, in an adventure's inline wandering - table, ids the adventure bundles. A single id is the common case. - Multiple ids are a packed-variant pool (`Veteran` over `veteran_1..3`): the - tabletop table leaves the pick to the referee, so osrlib has each spawned - individual pick uniformly from the pool on the wandering stream — deterministic, - and matching the printed spreads on the monster pages. `variant_dice` marks the - hydra form: the printed HD dice roll once on the wandering stream and the total - selects the template — `monster_ids` are ordered so the dice minimum maps to - index 0. + Turn it into the actual monsters with + [`select_encounter_individuals`][osrlib.core.tables.select_encounter_individuals], + which handles all three shapes below, then spawn each id with + [`spawn_monster`][osrlib.core.monsters.spawn_monster]. """ model_config = ConfigDict(frozen=True) kind: Literal["monster"] = "monster" + """Always `"monster"`. It tells this entry apart from + [`NpcPartyEncounterEntry`][osrlib.core.tables.NpcPartyEncounterEntry] when you read a row.""" + monster_ids: tuple[str, ...] = Field(min_length=1) + """The monster template ids this row can produce, at least one. + + An id names a template in the catalog the game is playing with: one that ships with + osrlib, from [`load_monsters`][osrlib.data.load_monsters] and listed in + [the monster id index][monsters-index], or one an adventure brings with it. + + One id is the ordinary case. Several means the printed row covers a spread of one + monster's forms, such as a veteran at three different levels, and each individual is + picked from that pool separately. The tabletop game leaves the pick to the referee. + osrlib rolls it instead, so an encounter comes out the same way on a replay. + """ + variant_dice: str | None = None + """A dice expression that picks one form for the whole group, or None when each individual is picked separately. + + The hydra is what this exists for: the printed row rolls its Hit Dice once, and every + hydra in the group has that many heads. The ids are ordered so the expression's lowest + total names the first one. + """ @model_validator(mode="after") def _variant_dice_span_matches_pool(self) -> MonsterEncounterEntry: @@ -361,41 +534,74 @@ def _variant_dice_span_matches_pool(self) -> MonsterEncounterEntry: class NpcPartyEncounterEntry(BaseModel): - """An NPC adventuring party cell (Basic/Expert Adventurers). + """The part of an encounter-table row that says a rival adventuring party turns up. - The NPC generator builds the parties these rows call for; see - [`osrlib.core.npc`][osrlib.core.npc]. + A few rows of the printed tables call for other adventurers rather than monsters. + Generate the party with [`osrlib.core.npc`][osrlib.core.npc], which rolls its size, + each member's class and level, and their scores and gear. """ model_config = ConfigDict(frozen=True) kind: Literal["npc_party"] = "npc_party" + """Always `"npc_party"`. It tells this entry apart from + [`MonsterEncounterEntry`][osrlib.core.tables.MonsterEncounterEntry] when you read a row.""" + party_kind: Literal["basic", "expert"] + """Which of the two printed party kinds to generate: `"basic"` for low levels, `"expert"` for high.""" EncounterEntry = Annotated[ MonsterEncounterEntry | NpcPartyEncounterEntry, Field(discriminator="kind"), ] -"""Any encounter-table entry, discriminated by `kind`.""" +"""What an encounter-table row produces: either monsters or a rival adventuring party. + +Check `kind` to tell which you have, or test the type: + +```python +from osrlib.core.tables import MonsterEncounterEntry +from osrlib.data import load_encounter_tables + +entry = load_encounter_tables().for_level(1).rows[0].entry +assert entry.kind == "monster" +assert isinstance(entry, MonsterEncounterEntry) +``` +""" class EncounterTableRow(BaseModel): - """One d20 row of a dungeon encounter table. + """One d20 result on a dungeon encounter table: what appears, and how many. - `name` is the printed cell name (after any documented data overrides). The count - is the table's own parenthesized value and, per the SRD's note, overrides the - monster description's number-appearing: `count_dice` carries dice forms and - `count_fixed` the printed plain `1`, exactly one of the two. + Roll a d20, take `rows[roll - 1]`, roll the count, and turn the entry into monsters + with + [`select_encounter_individuals`][osrlib.core.tables.select_encounter_individuals]. """ model_config = ConfigDict(frozen=True) roll: int = Field(ge=1, le=20) + """The d20 result this row is for, from 1 to 20. Rows are stored in this order.""" + name: str = Field(min_length=1) + """The name printed in the table's cell, which is what to show the referee.""" + entry: EncounterEntry + """What turns up: monsters, or a rival adventuring party.""" + count_dice: str | None = None + """A dice expression for how many appear, or None when the row prints a flat number instead. + + The table's own count wins over the number a monster's description gives, which is + what the SRD's note about the dungeon tables says to do. Exactly one of this and + `count_fixed` is set. + """ + count_fixed: int | None = None + """A flat number of individuals, or None when the row rolls dice instead. + + Exactly one of this and `count_dice` is set. + """ @field_validator("count_dice") @classmethod @@ -412,20 +618,37 @@ def _dice_or_fixed(self) -> EncounterTableRow: class EncounterTable(BaseModel): - """One dungeon level column: twenty d20 rows. + """The wandering-monster table for one band of dungeon levels. - `min_level`/`max_level` bound the printed band (`max_level=None` is the open - `8+` column). + Get the one that fits a level with + [`EncounterTables.for_level`][osrlib.core.tables.EncounterTables.for_level], then roll + a d20 and read `rows[roll - 1]`. Deeper levels have nastier tables, which is how the + rules make depth dangerous. """ model_config = ConfigDict(frozen=True) id: str + """The table's identifier, such as `"level_1"` or `"level_8_plus"`.""" + label: str + """The table's printed heading, such as `"Level 4–5"`.""" + min_level: int = Field(ge=1) + """The shallowest dungeon level this table covers.""" + max_level: int | None = None + """The deepest dungeon level this table covers, or None on the last table, which covers everything below.""" + rows: tuple[EncounterTableRow, ...] + """The twenty rows, in d20 order, so `rows[roll - 1]` is the row for a roll.""" + overrides_applied: tuple[str, ...] = () + """Which fields were corrected when this table was compiled from the SRD text, as dotted paths. + + `"rows.1.name"` means the second row's name needed fixing. Empty when nothing did. + Read it when you're checking osrlib's data against the book. + """ @model_validator(mode="after") def _rows_cover_the_d20(self) -> EncounterTable: @@ -437,30 +660,51 @@ def _rows_cover_the_d20(self) -> EncounterTable: def _dice_minimum(expression: str) -> int: - """The lowest total a dice expression can roll — a variant row's first-template offset.""" + """Return the lowest total a dice expression can roll, which is the offset of a variant row's first id.""" parsed = parse(expression) return parsed.count + parsed.modifier def select_encounter_individuals(entry: MonsterEncounterEntry, count: int, stream: RngStream) -> list[str]: - """Resolve a monster row's individuals to template ids — the shared encounter-and-stocking draw. + """Turn an encounter row into one monster template id per individual that appears. + + Call it once you know how many appear: roll the row's `count_dice` with + [`roll`][osrlib.core.dice.roll], or take its `count_fixed`. Then spawn each id with + [`spawn_monster`][osrlib.core.monsters.spawn_monster] to get monsters you can fight. + + Which ids come back depends on the row. A row with one id repeats it. A row with + several picks for each individual separately, so a group of veterans can come out + mixed. A row with `variant_dice` rolls once and gives every individual the same form, + which is how every hydra in a group ends up with the same number of heads. - The wandering-encounter check and dungeon stocking select the same way, from - the same stream in the same order, so a stocked row yields exactly what a - wandering roll on that row would: a `variant_dice` row rolls once (the hydra - form, the printed HD dice selecting one template for the whole group), a - packed pool row picks uniformly per individual, and a single-id row repeats. + Stocking a dungeon and rolling a wandering encounter both come through here, drawing + in the same order from the same stream, so a room stocked from a row contains what a + wander onto that row would have produced. Args: entry: The row's monster entry. - count: The number of individuals — already rolled and clamped by the caller. - stream: The RNG stream every draw advances. + count: How many individuals appear. Roll it before you call. + stream: The stream the picks draw from, which advances. Returns: - One template id per individual, in draw order. + One template id per individual, in the order they were picked. The list is exactly + `count` long. + + Examples: + ```python + from osrlib.core.rng import RngStreams + from osrlib.core.tables import select_encounter_individuals + from osrlib.data import load_encounter_tables + + stream = RngStreams(master_seed=5).get("wandering") + + # A row with one monster repeats it. + acolytes = load_encounter_tables().for_level(1).rows[0].entry + assert select_encounter_individuals(acolytes, 3, stream) == ["acolyte", "acolyte", "acolyte"] + ``` """ # A list (never the model's variadic tuple) so index access is unconditioned by - # length narrowing; the entry model guarantees at least one id. + # length narrowing. The entry model guarantees at least one id. ids = list(entry.monster_ids) if entry.variant_dice is not None: total = roll(entry.variant_dice, stream).total @@ -471,18 +715,28 @@ def select_encounter_individuals(entry: MonsterEncounterEntry, count: int, strea class NpcClassLevelRow(BaseModel): - """One d8 row of the *NPC Adventurer Class and Level* table. + """One d8 result on the table that decides an NPC adventurer's class and level. - Rows are keyed by die result — results 4 and 5 are both Fighter with different - Expert level dice, per the survey. + [`osrlib.core.npc`][osrlib.core.npc] rolls on this table for you when it builds a + party, so read the rows only to show the table or to generate a party your own way. """ model_config = ConfigDict(frozen=True) roll: int = Field(ge=1, le=8) + """The d8 result this row is for. Two results give the same class with different level dice.""" + class_id: str + """The class this result generates, as an id such as `"cleric"` or `"fighter"`. + + See [the class id index][classes-index]. + """ + basic_dice: str + """The dice to roll for the NPC's level in a basic party, such as `"1d3"`.""" + expert_dice: str + """The dice to roll for the NPC's level in an expert party, such as `"1d6+3"`.""" @field_validator("basic_dice", "expert_dice") @classmethod @@ -492,22 +746,31 @@ def _dice_must_parse(cls, value: str) -> str: class NpcAlignmentBand(BaseModel): - """One d6 band of the *NPC Adventurer Alignment* table.""" + """One d6 band of the table that decides an NPC adventuring party's alignment.""" model_config = ConfigDict(frozen=True) roll_min: int = Field(ge=1, le=6) + """The lowest d6 result in this band.""" + roll_max: int = Field(ge=1, le=6) + """The highest d6 result in this band.""" + alignment: str + """The alignment this band gives, as the wire value of an + [`Alignment`][osrlib.core.alignment.Alignment], such as `"lawful"`.""" class NpcPartyComposition(BaseModel): - """One party kind's printed composition dice (Basic 1d4+4, Expert 1d6+3).""" + """How many adventurers an NPC party of one kind has.""" model_config = ConfigDict(frozen=True) kind: Literal["basic", "expert"] + """Which kind of party this is, `"basic"` or `"expert"`.""" + count_dice: str + """The dice to roll for the party's size: `"1d4+4"` for a basic party, `"1d6+3"` for an expert one.""" @field_validator("count_dice") @classmethod @@ -517,14 +780,30 @@ def _dice_must_parse(cls, value: str) -> str: class EncounterTables(BaseModel): - """The six dungeon encounter tables plus the NPC adventurer generation tables.""" + """Every dungeon encounter table, and the tables that build a rival adventuring party. + + Get it from [`load_encounter_tables`][osrlib.data.load_encounter_tables]. The way in is + [`for_level`][osrlib.core.tables.EncounterTables.for_level], which picks the table for + the dungeon level the party is on. + + A [`GameSession`][osrlib.crawl.session.GameSession] rolls wandering monsters for you, + and an adventure can bring its own table instead of these, so you reach for this + directly when you're stocking or wandering outside a session. + """ model_config = ConfigDict(frozen=True) tables: tuple[EncounterTable, ...] + """The level tables, shallowest first, together covering every level from 1 down with no gaps.""" + npc_class_levels: tuple[NpcClassLevelRow, ...] = () + """The d8 table that gives each NPC adventurer a class and a level.""" + npc_alignment: tuple[NpcAlignmentBand, ...] = () + """The d6 table that gives an NPC adventuring party its alignment.""" + npc_compositions: tuple[NpcPartyComposition, ...] = () + """How many adventurers a party has, one entry for the basic kind and one for the expert kind.""" @model_validator(mode="after") def _bands_must_be_contiguous_from_one(self) -> EncounterTables: @@ -539,19 +818,32 @@ def _bands_must_be_contiguous_from_one(self) -> EncounterTables: return self def for_level(self, level: int) -> EncounterTable: - """Return the table for a dungeon level, clamped into the printed bands. + """Return the wandering-monster table to roll on at this dungeon level. - A dungeon level's table is its level number clamped into the printed bands — - 1, 2, 3, 4–5, 6–7, and everything 8 or deeper on `8+`. + Levels 1, 2, and 3 each have their own table. Levels 4 and 5 share one, 6 and 7 + share another, and everything 8 or deeper rolls on the last, so no level is too + deep to have a table. Args: - level: The dungeon level number, 1-based. + level: The dungeon level, counting from 1 at the top. Returns: The table for that level. Raises: ValueError: If `level` is below 1. + + Examples: + ```python + from osrlib.data import load_encounter_tables + + tables = load_encounter_tables() + assert tables.for_level(1).id == "level_1" + assert tables.for_level(5).id == "level_4_5" + + # Nothing is too deep: the last table has no floor. + assert tables.for_level(99).id == "level_8_plus" + ``` """ if level < 1: raise ValueError(f"dungeon levels are 1-based, got {level}") @@ -562,27 +854,62 @@ def for_level(self, level: int) -> EncounterTable: class CombatTables(BaseModel): - """The loaded combat tables.""" + """The five tables combat resolution reads, loaded together. + + Get it from [`load_combat_tables`][osrlib.data.load_combat_tables], then reach into the + field you want or use one of the lookups in this module, most of which take these + tables as their first argument. + """ model_config = ConfigDict(frozen=True) attack_matrix: AttackMatrix + """What every attacker needs to roll against every armour class.""" + monster_saves: tuple[MonsterSaveBand, ...] + """The monster saving-throw bands, weakest first. Find one by label with + [`save_band`][osrlib.core.tables.CombatTables.save_band].""" + xp_awards: tuple[XpAwardRow, ...] + """What a defeated monster is worth, by Hit Dice band. Find one by label with + [`xp_row`][osrlib.core.tables.CombatTables.xp_row].""" + turning: TurningTable + """What a cleric can do to undead, by the cleric's level and the undead's Hit Dice.""" + reaction: ReactionTable + """How a 2d6 total decides the way a meeting with monsters starts.""" def save_band(self, label: str) -> MonsterSaveBand: - """Return the monster save band with `label`. + """Return the monster saving-throw band with this label. + + Get the label from + [`monster_save_band_label`][osrlib.core.tables.monster_save_band_label] rather than + writing it out, since the labels use en dashes. Args: - label: The band label, e.g. `"NH"` or `"4–6"`. + label: A band label such as `"NH"` or `"4–6"`. Returns: - The band. + The band, with its five saving throw numbers. Raises: ValueError: If no band has that label. + + Examples: + ```python + from osrlib.core.monsters import MonsterHitDice + from osrlib.core.tables import monster_save_band_label + from osrlib.data import load_combat_tables + + tables = load_combat_tables() + + # A troll, at 6+3 Hit Dice, saves on the 4 to 6 band. + troll = MonsterHitDice(count=6, modifier=3, asterisks=1) + band = tables.save_band(monster_save_band_label(troll)) + assert band.label == "4–6" + assert band.saves.death == 10 + ``` """ for band in self.monster_saves: if band.label == label: @@ -590,16 +917,28 @@ def save_band(self, label: str) -> MonsterSaveBand: raise ValueError(f"unknown monster save band {label!r}") def xp_row(self, label: str) -> XpAwardRow: - """Return the XP-awards row with `label`. + """Return the experience-award row with this label. + + Get the label from [`xp_band_label`][osrlib.core.tables.xp_band_label]. To get the + award itself, call [`monster_xp`][osrlib.core.tables.monster_xp], which reads the + row and counts the monster's special abilities for you. Args: - label: The row label, e.g. `"2+"` or `"7–7+"`. + label: A row label such as `"2+"` or `"7–7+"`. Returns: - The row. + The row, with its base and per-ability amounts. Raises: ValueError: If no row has that label. + + Examples: + ```python + from osrlib.data import load_combat_tables + + row = load_combat_tables().xp_row("2+") + assert (row.base, row.bonus) == (25, 10) + ``` """ for row in self.xp_awards: if row.label == label: @@ -608,36 +947,75 @@ def xp_row(self, label: str) -> XpAwardRow: def to_hit_ac(thac0: int, ac: int) -> int: - """Return the attack roll required to hit `ac` under the attack matrix. + """Return the d20 result an attacker needs to hit this armour class. + + This is the attack matrix as arithmetic, and it gives the printed answer for every + printed cell. Armour classes past the printed columns follow the same arithmetic, so a + defender at −7 is handled like any other. - Every printed matrix cell equals `clamp(THAC0 − AC, 2, 20)`, and armour classes - outside the printed −3..9 columns extend by the same formula: the printed bounds - are page layout, not a rules cliff. + You rarely call this in play, because + [`resolve_attack`][osrlib.core.combat.resolve_attack] rolls the attack, applies every + modifier, and works out whether it hit. Call this to show a player the number they + need, or to check the matrix. + + The answer is kept within 2 to 20, which is what makes a natural 1 always miss and a + natural 20 always hit. Turning on `thac0_arithmetic` in + [`Ruleset`][osrlib.core.ruleset.Ruleset] drops that clamping and uses the plain + subtraction instead. Args: - thac0: The attacker's THAC0. - ac: The defender's descending armour class. + thac0: The attacker's THAC0, the roll it needs to hit armour class 0. + ac: The defender's descending armour class, where lower is better armoured. Returns: - The required roll, clamped to 2..20. + The roll needed, from 2 to 20. + + Examples: + ```python + from osrlib.core.tables import to_hit_ac + + # A first-level fighter, THAC0 19, against an unarmoured target. + assert to_hit_ac(19, 9) == 10 + + # Against plate and shield, and against armour the page never prints. + assert to_hit_ac(19, 2) == 17 + assert to_hit_ac(19, -3) == 20 + ``` """ return max(2, min(20, thac0 - ac)) def thac0_for_hd(count: int, *, bonus_modifier: bool = False) -> tuple[int, int]: - """Return the attack-matrix THAC0 (and attack bonus) for a monster's Hit Dice. + """Return how well a monster of this many Hit Dice attacks. + + A monster that ships with osrlib already has its THAC0, so use this for a monster + you wrote yourself, to check a stat block you were given, or to work out how well a + monster attacks after something drained its Hit Dice. - Bonus hit-point modifiers attack as 1 HD higher; negative modifiers keep the - unmodified row. Fractional and sub-1 HD use the "Up to 1" row (19 [0]). - Monster stat blocks carry printed THAC0 — this lookup serves validation, custom - monsters, and drained instances re-deriving from reduced HD. + Bonus hit points make a monster attack as though it had one more Hit Die, which is + what `bonus_modifier` is for. A negative modifier changes nothing, so the goblin's 1-1 + still attacks as one Hit Die. Anything below one Hit Die attacks on the lowest row. Args: - count: The Hit Dice count. - bonus_modifier: True when the HD carry a positive hit-point modifier. + count: The monster's Hit Dice count. + bonus_modifier: True when the Hit Dice have a positive hit-point modifier, as the + troll's 6+3 does. Returns: - The `(thac0, attack_bonus)` pair. + The THAC0 and the same thing as an ascending-armour-class bonus, in that order. + + Examples: + ```python + from osrlib.core.tables import thac0_for_hd + + assert thac0_for_hd(1) == (19, 0) + + # A 4+1 monster attacks as a 5 Hit Dice one. + assert thac0_for_hd(4, bonus_modifier=True) == (15, 4) + + # The table tops out, so nothing attacks better than this. + assert thac0_for_hd(30) == (5, 14) + ``` """ effective = max(1, count) + (1 if bonus_modifier else 0) for max_hd, thac0 in _MATRIX_HD_ROWS: @@ -647,19 +1025,34 @@ def thac0_for_hd(count: int, *, bonus_modifier: bool = False) -> tuple[int, int] def monster_save_band_label(hit_dice: MonsterHitDice) -> str: - """Return the monster saving-throw band label for a monster's Hit Dice. + """Return which saving-throw band a monster of these Hit Dice belongs to. - Bonus hit-point modifiers round the effective HD up (a 6+3 troll saves as 6 — - the printed bands are whole numbers and the SRD's stat blocks agree); fractional - and fixed-hp forms save as NH... except that the SRD prints per-block save-as - notes, which ship on every stat block — this lookup serves custom monsters and - expansion resolutions. + Pass the label to [`CombatTables.save_band`][osrlib.core.tables.CombatTables.save_band] + to get the numbers. Every monster that ships with osrlib already carries its own saving + throws, taken from its stat block, so use this for a monster you wrote yourself or to + check one you were given. + + A bonus hit-point modifier doesn't move a monster up a band, because the bands are + counted in whole Hit Dice: a troll at 6+3 saves on the band for 4 to 6. A monster below + one Hit Die, or one with a flat hit-point total, saves as a normal human. Args: hit_dice: The monster's Hit Dice. Returns: - The band label: `"NH"`, `"1–3"`, ... `"22 or more"`. + A band label, from `"NH"` through `"1–3"` up to `"22 or more"`. + + Examples: + ```python + from osrlib.core.monsters import MonsterHitDice + from osrlib.core.tables import monster_save_band_label + + assert monster_save_band_label(MonsterHitDice(count=6, modifier=3)) == "4–6" + assert monster_save_band_label(MonsterHitDice(count=1, modifier=-1)) == "1–3" + + # Half a Hit Die is a d4, and saves as a normal human. + assert monster_save_band_label(MonsterHitDice(count=1, die=4)) == "NH" + ``` """ if hit_dice.count < 1 or hit_dice.die == 4: return "NH" @@ -682,19 +1075,39 @@ def monster_save_band_label(hit_dice: MonsterHitDice) -> str: def turning_column(hit_dice: MonsterHitDice) -> str | None: - """Return the turning-table column for a monster's Hit Dice, or `None` above 9. + """Return which turning-table column undead of these Hit Dice sit in. + + Pass the label to [`TurningTable.result`][osrlib.core.tables.TurningTable.result]. None + back means the undead are past the end of the printed table and cannot be turned at + all, so there's nothing to look up and nothing to roll. - The column is the HD *count* as a string, with three exceptions: count 2 with a - special ability (`asterisks > 0`) maps to `2*` (the table's own footnote), counts - 7–9 share `7-9`, and counts above 9 have no column — turning fails against them. - Modifiers don't shift columns (the mummy's 5+1 turns on column 5), and the - asterisk matters only at count 2 (the wight's `3*` is column 3). + The column is the Hit Dice count, with three exceptions the table prints: a two-Hit-Dice + monster with a special ability sits in `2*`, counts 7 through 9 share one column, and + anything above 9 has no column. A hit-point modifier never moves a monster between + columns, so a mummy's 5+1 turns on column 5, and a special ability matters only at count + 2, so a wight's 3* turns on column 3. Args: - hit_dice: The monster's Hit Dice. + hit_dice: The undead monster's Hit Dice. Returns: - The column label, or `None` when the monster is beyond the printed table. + A column label from [`TURNING_COLUMNS`][osrlib.core.tables.TURNING_COLUMNS], or None + when the monster is beyond the table. + + Examples: + ```python + from osrlib.core.monsters import MonsterHitDice + from osrlib.core.tables import turning_column + + # A skeleton, at 1 Hit Die. + assert turning_column(MonsterHitDice(count=1)) == "1" + + # A ghoul, at 2 Hit Dice with a special ability, has a column of its own. + assert turning_column(MonsterHitDice(count=2, asterisks=1)) == "2*" + + # Anything above 9 Hit Dice is past the table and cannot be turned. + assert turning_column(MonsterHitDice(count=10)) is None + ``` """ count = max(1, hit_dice.count) if count == 2 and hit_dice.asterisks > 0: @@ -707,19 +1120,36 @@ def turning_column(hit_dice: MonsterHitDice) -> str | None: def xp_band_label(hit_dice: MonsterHitDice) -> str: - """Return the XP-awards row label for a monster's Hit Dice. + """Return which experience-award row a monster of these Hit Dice belongs to. + + Call [`monster_xp`][osrlib.core.tables.monster_xp] instead when you want the award + itself. This is the row lookup behind it, which is what you want when you're drawing + the table. - Negative hit-point modifiers map to the *lower* band — the goblin's 1-1 HD awards - from the "Less than 1" row (the "attack as 1 HD higher" rule is for bonus - modifiers only). Fractional and fixed-hp forms are "Less than 1". Above 21 HD - every monster lands on the "21–21+" row and inflation applies (see - [`monster_xp`][osrlib.core.tables.monster_xp]). + A bonus modifier moves a monster to the `+` version of its row, which is worth more. A + negative modifier drops it to the row below, so a goblin at 1-1 Hit Dice is awarded from + the "Less than 1" row. The rule about attacking as one Hit Die higher is for bonuses + only. Anything below one Hit Die, or with a flat hit-point total, is also "Less than + 1". Above 21 Hit Dice everything lands on the last row, and `monster_xp` adds to it from + there. Args: hit_dice: The monster's Hit Dice. Returns: - The row label, e.g. `"Less than 1"`, `"2+"`, or `"9–10+"`. + A row label such as `"Less than 1"`, `"2+"`, or `"9–10+"`. + + Examples: + ```python + from osrlib.core.monsters import MonsterHitDice + from osrlib.core.tables import xp_band_label + + assert xp_band_label(MonsterHitDice(count=2)) == "2" + + # A bonus moves the monster up a row, a penalty down one. + assert xp_band_label(MonsterHitDice(count=6, modifier=3)) == "6+" + assert xp_band_label(MonsterHitDice(count=1, modifier=-1)) == "Less than 1" + ``` """ if hit_dice.die == 4: return "Less than 1" @@ -748,19 +1178,44 @@ def xp_band_label(hit_dice: MonsterHitDice) -> str: def monster_xp(tables: CombatTables, hit_dice: MonsterHitDice) -> int: - """Return the XP award for defeating a monster with the given Hit Dice. + """Return the experience a party earns for defeating one monster of these Hit Dice. - Base XP by HD row plus the asterisk count times the bonus column. Above 21 HD, - *both* the base and the bonus amounts first gain 250 per HD above 21 ("add 250 XP - to the Base and Bonus amounts") — the dragon turtle (HD 30*, XP 9,000) proves the - reading: (2,500 + 9×250) + 1 × (2,000 + 9×250) = 9,000. + Multiply by how many the party defeated, add the treasure they carried off, and + divide among the survivors. A session awards experience for you when a battle ends, on + the schedule the `xp_award_timing` flag on [`Ruleset`][osrlib.core.ruleset.Ruleset] + sets, so call this when you're tallying a fight yourself or costing out an encounter + you're designing. + + The award is the row's base amount, plus its per-ability amount for each special + ability the monster has. Past 21 Hit Dice both amounts grow by 250 for each Hit Die + above 21, which is what makes a dragon turtle at 30 Hit Dice and one special ability + worth 9,000. Args: - tables: The loaded combat tables. - hit_dice: The monster's Hit Dice, including the asterisk count. + tables: The combat tables, from + [`load_combat_tables`][osrlib.data.load_combat_tables]. + hit_dice: The monster's Hit Dice, with its count of special abilities. Returns: - The XP award. + The experience for one monster. + + Examples: + ```python + from osrlib.core.monsters import MonsterHitDice + from osrlib.core.tables import monster_xp + from osrlib.data import load_combat_tables + + tables = load_combat_tables() + + # A goblin at 1-1 Hit Dice with no special abilities. + assert monster_xp(tables, MonsterHitDice(count=1, modifier=-1)) == 5 + + # A troll at 6+3 with one special ability. + assert monster_xp(tables, MonsterHitDice(count=6, modifier=3, asterisks=1)) == 650 + + # A dragon turtle at 30, where the amounts grow past the end of the table. + assert monster_xp(tables, MonsterHitDice(count=30, asterisks=1)) == 9000 + ``` """ row = tables.xp_row(xp_band_label(hit_dice)) base, bonus = row.base, row.bonus diff --git a/src/osrlib/core/validation.py b/src/osrlib/core/validation.py index 0cc30b4..67351f8 100644 --- a/src/osrlib/core/validation.py +++ b/src/osrlib/core/validation.py @@ -1,13 +1,45 @@ -"""Structured rejection reasons returned by pure validators. +"""The refusal a validator hands back when the rules say no. -Kernel validators (class choice, the ability adjustment step, purchase and equip -legality) return lists of [`Rejection`][osrlib.core.validation.Rejection] values rather -than raising: an illegal *choice* is an in-fiction refusal, not a programmer error. -Session command rejections carry these values verbatim in the `CommandResult` envelope. +The module has one member, +[`Rejection`][osrlib.core.validation.Rejection]. You don't usually build one, you read +them. Every kernel validator returns a list of them, empty when the input is legal, and +so does every command a session refuses to run. Ask them what went wrong and turn them +into whatever your interface shows the player. -Calling an apply-step with input its validator rejects — applying an illegal -adjustment, equipping forbidden armour — is programmer misuse and raises stdlib -`ValueError`, per the errors convention in [`osrlib.errors`][osrlib.errors]. +Each rules area pairs a validator with the function that applies the change: +[`validate_adjustment`][osrlib.core.abilities.validate_adjustment] with +[`apply_adjustment`][osrlib.core.abilities.apply_adjustment], +[`validate_purchase`][osrlib.core.items.validate_purchase] with the purchase itself, +and so on. Call the validator, show the rejections if there are any, and call the apply +step only on an empty list. Applying a change its validator refuses is a mistake in your +code rather than a move the rules disallow, so the apply step raises `ValueError` +instead of returning a rejection. The exceptions osrlib raises for the other kinds of +mistake are in [`osrlib.errors`][osrlib.errors]. + +Validation runs as a pure pre-phase: a refused input draws no randomness, advances no +game time, and changes nothing, so you can offer a move, show why it was refused, and +let the player pick again with the game in exactly the state it was. + +Typical usage: + +```python +from osrlib.core.abilities import AbilityAdjustment, AbilityScore, validate_adjustment + +scores = { + AbilityScore.STR: 12, + AbilityScore.INT: 13, + AbilityScore.WIS: 9, + AbilityScore.DEX: 11, + AbilityScore.CON: 14, + AbilityScore.CHA: 10, +} + +# Lowering WIS below 9 is against the rules, so the validator refuses it. +adjustment = AbilityAdjustment(lowered={AbilityScore.WIS: 2}, raised={AbilityScore.STR: 1}) +rejections = validate_adjustment(scores, adjustment, prime_requisites=(AbilityScore.STR,)) +assert [rejection.code for rejection in rejections] == ["creation.adjustment.below_floor"] +assert rejections[0].params == {"ability": "wis", "score": 9, "amount": 2} +``` """ import re @@ -22,18 +54,47 @@ class Rejection(BaseModel): - """A structured reason a validator refused an input. + """One reason the rules refused an input, as a code plus the facts behind it. + + A validator returns these. Match on `code` to decide what to tell the player, and read + `params` for the numbers and names to put in the sentence. Match on the code rather + than on any text, because osrlib never puts English in a rejection. Write your own + wording for the codes your game can produce, and show the code itself for the rest. + [The rejection code reference][rejection-codes] lists every code the engine emits with + what it means. + + Build one yourself only when you're writing a validator of your own, for a house + rule or a custom item, and want it to refuse in the same shape the kernel does. - `code` is dotted snake_case namespaced by subsystem, like event message codes - (`creation.class.requirements_not_met`, `items.equip.armour_forbidden`). `params` - carries the structured facts a front end needs to render the refusal — never baked - English prose. + The model is frozen, so you can keep a rejection around and compare rejections for + equality. + + Examples: + ```python + from osrlib.core.validation import Rejection + + rejection = Rejection(code="items.equip.armour_forbidden", params={"class": "magic_user"}) + assert rejection.code.split(".")[0] == "items" + assert rejection.params["class"] == "magic_user" + ``` """ model_config = ConfigDict(frozen=True) code: str + """The refusal's identity: two or more snake_case segments joined by dots, the first naming the subsystem. + + `creation.class.requirements_not_met` and `items.equip.armour_forbidden` are both + codes. Any other shape raises a pydantic `ValidationError` when the model is built. + Match on this value to choose what to show the player. + """ + params: dict[str, int | str | tuple[int | str, ...]] = {} + """The facts the refusal turns on, such as the ability that was too low or the class that may not wear the armour. + + Values are integers, strings, or tuples of either. Which keys appear depends on the + code, so read the keys the codes you handle use and skip any you don't recognize. + """ @field_validator("code") @classmethod From f6b4faef2fcc8e6a87e011fa4974a2caf44ff335 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Sun, 13 Sep 2026 22:21:44 -0700 Subject: [PATCH 2/2] Correct five docstring claims found in review Force door: the session handler sets the noise flag, opens the door and springs its trap on a success, and alerts the area beyond on a failure. It advances no clock, so the time claim is gone. Formation width: the cap is the party's frontage divided by the five feet a combatant needs, at ten feet per cell, so two abreast in a one-cell corridor and four in a room two cells across. The flag defaults on, so the text now describes turning it off. Monster saves and XP bands: the code tests the Hit Dice count and the d4 hit die, not fixed hit points. A hydra takes the band for its count. Turning and ability checks: name the rule instead of calling undead powerful or a task easy, and quote the SRD's own difficulty wording. Claude-Session: https://claude.ai/code/session_01GL26QnA6dCrvUc3WmhzFSa --- src/osrlib/core/abilities.py | 18 ++++++++++-------- src/osrlib/core/ruleset.py | 10 ++++++---- src/osrlib/core/tables.py | 24 ++++++++++++++---------- 3 files changed, 30 insertions(+), 22 deletions(-) diff --git a/src/osrlib/core/abilities.py b/src/osrlib/core/abilities.py index 0cde3b1..5b7e70c 100644 --- a/src/osrlib/core/abilities.py +++ b/src/osrlib/core/abilities.py @@ -620,9 +620,10 @@ def ability_check(score: int, stream: RngStream, modifier: int = 0) -> AbilityCh the thief skills on the class definition for a thief's own work. The roll is a d20, and the check succeeds on a modified roll at or under the score, so - a higher score succeeds more often. The SRD suggests −4 for an easy task and +4 for a - difficult one. A natural 1 always succeeds and a natural 20 always fails, which is the - opposite way round from an attack roll. + a higher score succeeds more often. The SRD leaves the difficulty to the referee and + gives two numbers for it: "a –4 modifier for an easy task or +4 for a difficult task". + A natural 1 always succeeds and a natural 20 always fails, which is the opposite way + round from an attack roll. Args: score: The ability score to check against, from 3 to 18. @@ -669,12 +670,13 @@ def open_doors_check(chance: int, stream: RngStream) -> OpenDoorsResult: Get the chance from [`AbilityTables.open_doors_chance`][osrlib.core.abilities.AbilityTables.open_doors_chance] for the character's strength. A d6 at or under the chance opens the door. The function - rolls and reports, and nothing else: whether the door then opens, how much noise the - attempt made, and how much time it cost are yours to apply. + rolls and reports, and nothing else: opening the door, and whatever the attempt costs + the party, are yours to apply. - In a running game [`ForceDoor`][osrlib.crawl.commands.ForceDoor] does all of that for - you, with the events to match, so call this function when you're working outside a - session. + In a running game [`ForceDoor`][osrlib.crawl.commands.ForceDoor] applies those + consequences for you. It marks the party as having made noise, opens the door on a + success and springs any trap rigged to it, and on a failure alerts the area beyond, all + with the events to match. Call this function when you're working outside a session. Args: chance: The chance in 6, from 0 to 6. diff --git a/src/osrlib/core/ruleset.py b/src/osrlib/core/ruleset.py index d581a72..8c1fb33 100644 --- a/src/osrlib/core/ruleset.py +++ b/src/osrlib/core/ruleset.py @@ -247,8 +247,10 @@ class Ruleset(BaseModel): """Cap how many combatants can fight side by side, by how wide the passage is. An adaptation, on by default, an adaptation being a default osrlib supplies where the - tabletop game hands the decision to a referee. Turn it on and three may fight abreast - inside a keyed area, two in a corridor cell, following the SRD's note about two or - three characters fighting side by side in a ten-foot passage. Turn it off and the cap - lifts, so every combatant may melee. + tabletop game hands the decision to a referee. The cap is the party's frontage divided + by the five feet one combatant needs, and frontage is ten feet per dungeon cell, so two + may fight abreast in a one-cell corridor and four in a room two cells across. That + follows the SRD's note about two or three characters fighting side by side in a + ten-foot passage, taking the lower of the two numbers. Turn it off and the cap lifts, + so every combatant may melee. """ diff --git a/src/osrlib/core/tables.py b/src/osrlib/core/tables.py index d64b68b..c7ed140 100644 --- a/src/osrlib/core/tables.py +++ b/src/osrlib/core/tables.py @@ -280,9 +280,9 @@ def result(self, cleric_level: int, column: str) -> TurningResult: """Say what happens when this cleric tries to turn these undead. Get the column from [`turning_column`][osrlib.core.tables.turning_column], which - returns None for undead too powerful to be turned at all. There's nothing to look - up in that case. A cleric above level 10 reads the `11+` row, which is what the - printed table intends. + returns None when the undead's Hit Dice run past the table's last column. There's + nothing to look up in that case, and the attempt fails. A cleric above level 10 + reads the `11+` row, which is what the printed table intends. This is the lookup alone. [`turn_undead`][osrlib.core.spells.turn_undead] rolls the 2d6 a `"number"` outcome calls for and works out how many undead are @@ -1033,8 +1033,10 @@ def monster_save_band_label(hit_dice: MonsterHitDice) -> str: check one you were given. A bonus hit-point modifier doesn't move a monster up a band, because the bands are - counted in whole Hit Dice: a troll at 6+3 saves on the band for 4 to 6. A monster below - one Hit Die, or one with a flat hit-point total, saves as a normal human. + counted in whole Hit Dice: a troll at 6+3 saves on the band for 4 to 6. Two kinds of + monster save as a normal human: one whose Hit Dice count is below 1, and one whose hit + die is a d4, which is how the compiled data writes half a Hit Die. A monster with flat + hit points per Hit Die, such as a hydra, still saves on the band for its count. Args: hit_dice: The monster's Hit Dice. @@ -1127,11 +1129,13 @@ def xp_band_label(hit_dice: MonsterHitDice) -> str: the table. A bonus modifier moves a monster to the `+` version of its row, which is worth more. A - negative modifier drops it to the row below, so a goblin at 1-1 Hit Dice is awarded from - the "Less than 1" row. The rule about attacking as one Hit Die higher is for bonuses - only. Anything below one Hit Die, or with a flat hit-point total, is also "Less than - 1". Above 21 Hit Dice everything lands on the last row, and `monster_xp` adds to it from - there. + negative modifier drops it to the row below, so a goblin at 1-1 Hit Dice is awarded + from the "Less than 1" row. The rule about attacking as one Hit Die higher is for + bonuses only. A monster whose hit die is a d4, which is how the compiled data writes + half a Hit Die, is "Less than 1" whatever its count, and so is one whose count works out + below 1. A monster with flat hit points per Hit Die, such as a hydra, is awarded from + the row for its count. Above 21 Hit Dice everything lands on the last row, and + `monster_xp` adds to it from there. Args: hit_dice: The monster's Hit Dice.