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..5b7e70c 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,315 @@ 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 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, 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 +665,41 @@ 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: opening the door, and whatever the attempt costs + the party, are yours to apply. + + 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 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 +711,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 +767,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 +879,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..8c1fb33 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. +"""The house rules a game plays under, as one frozen set of 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. +[`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,204 @@ class XpAwardTiming(StrEnum): - """When the XP award fires; see the adventure award procedure. + """When a party is paid the experience points it has earned. - The wire values are `"on_return"` and `"immediate"` — lowercase, serialized - into saves; changing them is a `schema_version` bump. + 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 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. - The wire values are `"none"`, `"basic"`, and `"detailed"` — lowercase, serialized - into saves; changing them is a `schema_version` bump. + 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 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. 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 fb53672..c7ed140 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 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 + 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,36 @@ 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. 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. 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 +1077,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 +1122,38 @@ 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. 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. 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 +1182,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