From bc0f7a8956a43248196bee3ab3a7c72f28a8a2d3 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Sun, 13 Sep 2026 21:25:43 -0700 Subject: [PATCH 1/3] Write the API reference for core spells Rewrite every docstring in src/osrlib/core/spells.py for a developer who reads the published reference and never opens the source. Every model field, constant, and public function now says what it does, what you need first and where to get it, what to call next, and when not to use it, and every public function carries an example that runs under the docs examples harness. Claude-Session: https://claude.ai/code/session_01GL26QnA6dCrvUc3WmhzFSa --- src/osrlib/core/spells.py | 2021 +++++++++++++++++++++++++++++-------- 1 file changed, 1586 insertions(+), 435 deletions(-) diff --git a/src/osrlib/core/spells.py b/src/osrlib/core/spells.py index cee9266..b4d2696 100644 --- a/src/osrlib/core/spells.py +++ b/src/osrlib/core/spells.py @@ -1,48 +1,111 @@ """Spell memorization, casting, spell resolution, and turning undead. -Part of the core kernel: everything here runs standalone — no game session required — -and every random draw comes from a named, seeded RNG stream. +Casting sits between two things you already have. On one side are the caster's spell slots: the +per-spell-level counts on the progression row of the character's +[`ClassDefinition`][osrlib.core.classes.ClassDefinition], filled for the day by +[`memorize_spells`][osrlib.core.spells.memorize_spells] and spent one copy at a time by +[`cast_spell`][osrlib.core.spells.cast_spell], this module's entry point. On the other side is the +effects engine: whatever a cast leaves running, a condition, a bundle of stat modifiers, a rolled +duration, attaches to the [`EffectsLedger`][osrlib.core.effects.EffectsLedger] in +[`osrlib.core.effects`][osrlib.core.effects], which ticks it and releases it when it ends. The +functions here take the spell catalog [`load_spells`][osrlib.data.load_spells] returns, mutate the +caster and the ledger, and hand you back events. + +If you run a game session rather than the rules on their own, the crawl layer has already wrapped +this module and you call it instead: [`PrepareSpells`][osrlib.crawl.commands.PrepareSpells], +[`LearnSpell`][osrlib.crawl.commands.LearnSpell], [`CastSpell`][osrlib.crawl.commands.CastSpell], +and [`TurnUndead`][osrlib.crawl.commands.TurnUndead] call these functions once the session's own +gates have passed (a night's sleep before preparation, light to read by, the right session mode). +Call this module directly when you drive the rules yourself: everything here runs standalone with +no session, and every random draw comes from a named, seeded RNG stream you supply. The OSE SRD's spell pages compile into a catalog of frozen -[`SpellTemplate`][osrlib.core.spells.SpellTemplate] models loaded by -[`load_spells`][osrlib.data.load_spells]. A template carries the page's presentation +[`SpellTemplate`][osrlib.core.spells.SpellTemplate] models. A template has the page's presentation data (duration, range, prose) alongside structured mechanics: one -[`SpellMode`][osrlib.core.spells.SpellMode] per castable usage, each naming its -targeting, saving throw, and — for the automated subset — a -[`SpellEffect`][osrlib.core.spells.SpellEffect] the casting interpreter executes. -Modes the kernel doesn't automate ship `manual=True` with the SRD prose: casting one -is a supported operation (the slot is consumed, the event is emitted), and the game -or narrator resolves the fiction. - -The daily flow: [`memorize_spells`][osrlib.core.spells.memorize_spells] prepares a -caster's list (arcane casters choose from a spell book grown with -[`add_spell_to_book`][osrlib.core.spells.add_spell_to_book]), then +[`SpellMode`][osrlib.core.spells.SpellMode] per castable usage, each naming its targeting, its +saving throw, and, for the automated subset, a [`SpellEffect`][osrlib.core.spells.SpellEffect] that +casting executes. Modes osrlib does not automate are marked `manual=True` and keep the SRD prose. +Casting one is a supported operation, the slot is consumed and the event is emitted, and your game +or narrator resolves what happens. + +The daily flow is prepare, then cast. [`memorize_spells`][osrlib.core.spells.memorize_spells] +prepares a caster's list, and arcane casters choose from a spell book grown with +[`add_spell_to_book`][osrlib.core.spells.add_spell_to_book]. Then [`validate_cast`][osrlib.core.spells.validate_cast] checks legality and -[`cast_spell`][osrlib.core.spells.cast_spell] consumes the memorized copy and -resolves the mode. [`cast_from_scroll`][osrlib.core.spells.cast_from_scroll] -resolves an inscribed spell without a memorized copy, and -[`disrupt_casting`][osrlib.core.spells.disrupt_casting] loses one when a declared -cast is broken. Clerics also turn undead here: +[`cast_spell`][osrlib.core.spells.cast_spell] consumes the memorized copy and resolves the mode. +[`cast_from_scroll`][osrlib.core.spells.cast_from_scroll] resolves an inscribed spell with no +memorized copy behind it, and [`disrupt_casting`][osrlib.core.spells.disrupt_casting] loses a copy +when a declared cast is broken. Clerics also turn undead here: [`validate_turn_undead`][osrlib.core.spells.validate_turn_undead], then [`turn_undead`][osrlib.core.spells.turn_undead]. -Casters are [`Character`][osrlib.core.character.Character] objects; targets arrive -duck-typed per the combatant convention (see [`osrlib.core.combat`][osrlib.core.combat]) -as characters, [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] objects, or -location strings for effects a game attaches to places. - -Reversed forms are entry data, not separate catalog entries: the nine concepts -printed as separate cleric and magic-user pages compile as two entries with -`_c`/`_mu` id suffixes because the pairs differ mechanically, while a reversible -spell's reverse lives on its entry as a -[`ReversedForm`][osrlib.core.spells.ReversedForm]. - -Every draw inside spell resolution — targeting dice, damage dice, touch-attack -rolls, cast-time forced saves, dispel survival rolls, and both turning rolls — comes -from the [`MAGIC_STREAM`][osrlib.core.spells.MAGIC_STREAM] stream, so spell results -replay independently of combat draws and vice versa. Effect-internal draws (rolled -durations at attach, tick-time saves such as the charm re-save) stay on the -[`EFFECTS_STREAM`][osrlib.core.effects.EFFECTS_STREAM] stream. +Casters are [`Character`][osrlib.core.character.Character] objects. Targets arrive duck-typed per +the combatant convention (see [`osrlib.core.combat`][osrlib.core.combat]) as characters, +[`MonsterInstance`][osrlib.core.monsters.MonsterInstance] objects, or location strings for effects +a game attaches to places rather than to creatures. + +A reversible spell's reverse is entry data, not a separate catalog entry: it lives on its entry as +a [`ReversedForm`][osrlib.core.spells.ReversedForm]. The nine concepts the SRD prints as separate +cleric and magic-user pages are the exception, compiling as two entries with `_c` and `_mu` id +suffixes, because those pairs differ mechanically. + +Every draw inside spell resolution comes from the +[`MAGIC_STREAM`][osrlib.core.spells.MAGIC_STREAM] stream: targeting dice, damage dice, touch-attack +rolls, cast-time forced saves, dispel survival rolls, and both turning rolls. Spell results +therefore replay independently of combat draws and combat draws of spell results. Draws made inside +an effect, such as a duration rolled at attach time or the charm re-save rolled on a tick, stay on +the [`EFFECTS_STREAM`][osrlib.core.effects.EFFECTS_STREAM] stream. + +Typical usage: + +```python +from osrlib.core.alignment import Alignment +from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character +from osrlib.core.clock import GameClock +from osrlib.core.effects import EFFECTS_STREAM, EffectsLedger +from osrlib.core.monsters import IdAllocator +from osrlib.core.rng import RngStreams +from osrlib.core.ruleset import Ruleset +from osrlib.core.spells import MAGIC_STREAM, MemorizedSpell, cast_spell, caster_profile, memorize_spells +from osrlib.data import load_classes, load_spells + +rules = Ruleset() +streams = RngStreams(master_seed=7) +catalog = load_spells() +definition = load_classes().get("cleric") + +aldis = create_character( + name="Aldis", + class_id="cleric", + alignment=Alignment.LAWFUL, + ruleset=rules, + stream=streams.get(CHARACTER_CREATION_STREAM), +).character +aldis.id = "pc-1" +aldis.level = 2 # a 2nd-level cleric has one first-level slot + +# Prepare the day's list, then spend it healing the caster's own wounds. +prepared = memorize_spells(aldis, definition, catalog, [MemorizedSpell(spell_id="cure_light_wounds")]) +assert prepared.accepted +aldis.current_hp = 1 +result = cast_spell( + aldis, + catalog.get("cure_light_wounds"), + "heal", + profile=caster_profile(definition), + targets=[aldis], + ledger=EffectsLedger(), + clock=GameClock(), + allocator=IdAllocator(), + registry={"pc-1": aldis}, + ruleset=rules, + stream=streams.get(MAGIC_STREAM), + effects_stream=streams.get(EFFECTS_STREAM), +) +assert result.affected_ids == ("pc-1",) +assert aldis.current_hp == aldis.max_hp # healing never exceeds the normal maximum +assert aldis.memorized_spells == () # the cast spent the copy +``` """ # Import direction, mirroring the alignment.py lesson: the data loaders import these @@ -139,23 +202,85 @@ ] MAGIC_STREAM = "magic" -"""Stream key convention for spell-resolution draws: targeting, damage, cast-time saves, turning.""" +"""The name of the RNG stream every spell-resolution draw comes from. + +Pass `streams.get(MAGIC_STREAM)` as the `stream` argument of +[`cast_spell`][osrlib.core.spells.cast_spell], +[`cast_from_scroll`][osrlib.core.spells.cast_from_scroll], and +[`turn_undead`][osrlib.core.spells.turn_undead], where `streams` is an +[`RngStreams`][osrlib.core.rng.RngStreams]. The draws on it are targeting dice, damage dice, +touch-attack rolls, cast-time forced saves, dispel survival rolls, and both turning rolls. + +Keeping magic on its own stream is what lets a replay reproduce a spell result after the combat +draws around it have changed, and the reverse. Draws made inside an already-attached effect belong +to [`EFFECTS_STREAM`][osrlib.core.effects.EFFECTS_STREAM] instead, so pass that as `effects_stream` +rather than reusing this one. +""" EFFECT_KINDS = frozenset( {"damage", "heal", "cure", "condition", "modifiers", "kill", "restore_life", "dispel", "attach_only"} ) -"""The closed vocabulary of effect kinds the casting interpreter executes.""" +"""The effect kinds casting knows how to execute. + +[`SpellEffect.kind`][osrlib.core.spells.SpellEffect] is validated against this set, so a spell you +author yourself has to resolve into one of these behaviors or be marked +[`manual`][osrlib.core.spells.SpellMode] and left to your game. The vocabulary is closed on purpose: +every kind is a branch of the resolution code, and a new kind is a library change, not data. + +`damage` and `heal` roll dice against the selected targets, `cure` releases named conditions or +effect kinds, `condition` and `modifiers` attach to the effects ledger, `attach_only` attaches an +effect with no condition of its own (a light source, a ward, mirror images), `kill` applies a death +effect, `restore_life` is *raise dead*, and `dispel` is *dispel magic*. +""" class DurationSpec(BaseModel): - """A parsed spell duration. - - `kind="fixed"` durations are `amount` (or `dice`, rolled at attach) counts of - `unit`, plus `per_level` extra units per caster level (the additive bonus: - *light (MU)* is 6 turns +1 per level; *hold person (MU)*'s `1 turn per level` is - amount 0, per_level 1). `concentration` durations may carry a cap (`Concentration - (up to 1 day)`). Anything unparseable keeps `kind="special"` with the raw string - on the template — the parser never fails on prose. + """How long a spell lasts, parsed out of the printed duration line. + + You read one off [`SpellTemplate.duration_spec`][osrlib.core.spells.SpellTemplate], or off a + [`ReversedForm`][osrlib.core.spells.ReversedForm] when the reverse lasts a different length. You + never build one during play. Casting reads it for you and turns it into the duration of the + effect it attaches to the ledger, so you need this model only when you display a spell, sort or + filter a list by how long its spells run, or author a spell of your own. + + The printed string is kept beside it on the template as `duration`, and it is the authority for + anything you show a player: a duration the parser cannot make structure out of lands here as + `kind="special"` with nothing else filled in, because the parser never fails on prose. + + Attributes: + kind: Which of the five shapes this duration has. `instant` resolves and is over, + `permanent` never ends, `concentration` lasts while the caster concentrates and is + released by whoever is running the game, `fixed` is a length you can count in `unit`, + and `special` means the printed line was prose the parser left alone. + unit: The time unit a `fixed` duration counts in, as a + [`TimeUnit`][osrlib.core.clock.TimeUnit]: rounds, turns, hours, days. `None` on every + other kind. + amount: How many `unit` a `fixed` duration lasts, before the per-level bonus. `None` when + the length is rolled (`dice`) or is purely per-level. + dice: A dice expression rolled when the effect attaches, in place of a flat `amount`, such + as `"1d6"` for *confusion*. The roll happens on the effects stream, not the magic + stream. + per_level: Extra `unit` per caster level, added to `amount` or folded into the `dice` + modifier at cast time. *Light* prints `6 turns +1 per level`, so amount 6 and per_level 1. + A spell printed `1 turn per level` is amount `None` and per_level 1. + concentration_cap_unit: The unit of the outer limit on a `concentration` duration, when the + page prints one (`Concentration (up to 1 day)`). `None` when concentration is + open-ended. + concentration_cap_amount: How many `concentration_cap_unit` that limit runs to. + + Examples: + ```python + from osrlib.core.clock import TimeUnit + from osrlib.data import load_spells + + catalog = load_spells() + light = catalog.get("light_mu") + assert light.duration == "6 turns +1 per level" # the printed line + spec = light.duration_spec + assert (spec.kind, spec.unit, spec.amount, spec.per_level) == ("fixed", TimeUnit.TURN, 6, 1) + # So a 3rd-level caster's light burns for 6 + 1 * 3 turns. + assert catalog.get("cure_light_wounds").duration_spec.kind == "instant" + ``` """ model_config = ConfigDict(frozen=True) @@ -186,13 +311,42 @@ def _fixed_durations_carry_a_length(self) -> DurationSpec: class RangeSpec(BaseModel): - """A parsed spell range. + """How far a spell reaches, parsed out of the printed range line. + + You read one off [`SpellTemplate.range_spec`][osrlib.core.spells.SpellTemplate], and you never + build one during play. [`validate_cast`][osrlib.core.spells.validate_cast] reads it for you, but only + when you tell it how far away the target is through + [`CastContext.distance_feet`][osrlib.core.spells.CastContext]. osrlib has no map of its own, so + with no distance asserted there is no range check. Read this model yourself when you draw a + range indicator, filter a spell list by reach, or decide which targets to offer. + + The printed string is kept beside it on the template as `range` and is what you show a player. + Ranges the parser cannot make structure out of, such as the presence forms, land as + `kind="special"` with no distance. + + Attributes: + kind: Which shape the range has. `caster` affects the caster alone, `touch` reaches one + creature in reach and allows the caster to be that creature, `feet` and `yards` are + fixed distances, `per_level` grows with caster level, and `special` means the printed + line was prose the parser left alone. + feet: The distance in feet, for the `feet`, `yards`, and `per_level` kinds. Yards are + converted, so a range printed as 240 yards is 720 here. On a `per_level` range this is + the base before the per-level bonus, and it is `None` when the printed range is purely + per level. `None` on the other kinds. + per_level_feet: Extra feet of reach per caster level on a `per_level` range. A range printed + `60' +10' per level` is `feet` 60 and `per_level_feet` 10, so a 5th-level caster reaches + 110 feet. - `feet` carries the distance in feet for `feet`, `yards` (converted: `240 yards - around the caster` is 720), and `per_level` kinds; `per_level_feet` is the extra - feet per caster level (*cloudkill*-style `60' +10' per level` is feet 60, - per_level_feet 10). `touch` covers `The caster or a creature touched` — - self-targeting is allowed; presence forms and other prose are `special`. + Examples: + ```python + from osrlib.data import load_spells + + catalog = load_spells() + assert catalog.get("fire_ball").range == "240’" + fire_ball = catalog.get("fire_ball").range_spec + assert (fire_ball.kind, fire_ball.feet) == ("feet", 240) + assert catalog.get("cure_light_wounds").range_spec.kind == "touch" + ``` """ model_config = ConfigDict(frozen=True) @@ -203,14 +357,56 @@ class RangeSpec(BaseModel): class TargetingSpec(BaseModel): - """One mode's targeting: the shared combat targeting mode plus its parameters. - - `count`/`count_dice` size `up_to_n` modes (*hold person*'s group mode is 1d4); - `hd_budget_dice` sizes `hd_budget` modes (*sleep*'s 2d8); `hd_cap` bounds - eligibility by Hit Dice (*sleep* mode 2's "4 HD or less", *charm monster*'s - "3 HD or less"); `hd_min` bounds it from below (*charm monster*'s single mode - takes "more than 3 HD"). Area `shape` and `dimensions` ship as structured data - now; the battle machine maps its range-track geometry onto candidates. + """Who one castable usage of a spell can hit, and how many of them. + + You read one off [`SpellMode.targeting`][osrlib.core.spells.SpellMode] to know how many targets + to collect before you call [`cast_spell`][osrlib.core.spells.cast_spell], which is the question + a spell-targeting interface has to answer first. `mode` is the shared + [`TargetingMode`][osrlib.core.combat.TargetingMode] that + [`select_targets`][osrlib.core.combat.select_targets] understands, and the rest of the fields + are the per-spell numbers that size and bound it. + + The targets you pass to casting are candidates, not the final list. Casting drops the ones the + mode is not allowed to affect and then applies `mode` to the survivors, so an ineligible + creature in the list costs nothing: it consumes no Hit Dice budget and no group slot. That is + deliberate, and it is why an ineligible target is a resolution outcome rather than a rejection. + A cast that finds nothing eligible returns a + [`CastResult`][osrlib.core.spells.CastResult] with `no_effect` set, and the memorized copy is + still spent. + + Attributes: + mode: The targeting mode: `self` takes no targets, `single` takes exactly one, `up_to_n` a + bounded group, `hd_budget` as many creatures as a rolled pool of Hit Dice pays for, + `area` everything you supply as covered by the shape, and `gaze` the gaze-attack form. + count: The fixed size of an `up_to_n` group, when the page prints a number rather than dice. + count_dice: The dice rolled at cast time to size an `up_to_n` group. *Hold person*'s group + mode is `"1d4"`, *charm monster*'s is `"3d6"`. Rolled on the magic stream. + hd_budget_dice: The dice rolled to size a `hd_budget` pool. *Sleep*'s is `"2d8"`. Creatures + are affected cheapest first until the pool cannot pay for the next one, and the + remainder is wasted rather than spent elsewhere. + hd_cap: The most Hit Dice a creature may have and still be eligible. *Sleep*'s group mode + caps at 4, *charm monster*'s at 3. + hd_min: The fewest Hit Dice a creature must have to be eligible. *Charm monster*'s + single-target mode sets 4, which is the page's "more than 3 Hit Dice". + shape: The name of the area an `area` mode covers, such as `"sphere"`. `None` on every other + mode. + dimensions: The area's measurements in feet, keyed by name: *fire ball*'s sphere is + `{"radius_feet": 20}`. Which creatures stand inside it is your game's question, not + osrlib's. You decide who is caught and pass them as candidates. + + Examples: + ```python + from osrlib.core.combat import TargetingMode + from osrlib.data import load_spells + + sleep = load_spells().get("sleep").mode("hd_budget").targeting + assert sleep.mode is TargetingMode.HD_BUDGET + assert (sleep.hd_budget_dice, sleep.hd_cap) == ("2d8", 4) + + fire_ball = load_spells().get("fire_ball").mode("damage").targeting + assert fire_ball.mode is TargetingMode.AREA + assert (fire_ball.shape, fire_ball.dimensions) == ("sphere", {"radius_feet": 20}) + ``` """ model_config = ConfigDict(frozen=True) @@ -233,11 +429,35 @@ def _dice_must_parse(cls, value: str | None) -> str | None: class SaveSpec(BaseModel): - """One mode's saving throw: the category, a modifier, and what a pass means. + """The saving throw one castable usage of a spell allows its targets, and what passing it buys. + + You read one off [`SpellMode.save`][osrlib.core.spells.SpellMode], which is `None` when the mode + allows no save at all. Casting rolls the save for you, on the magic stream, against the target's + own save values. Read this model to tell a player what they are facing before they commit, or to + show why a target came through unharmed. + + Spell saves are always rolled as magical, so a target's wisdom adjustment applies. A target + immune to the spell's element passes without a roll, through the same save pipeline. + + Attributes: + category: Which column of the saving-throw table the target rolls on, as a + [`SaveCategory`][osrlib.core.combat.SaveCategory]. + modifier: The adjustment applied to the target's roll, negative against the target. + *Hold person*'s single-target mode is −2 and *feeblemind* is −4. + on_save: What a passed save buys. `negates` means the target takes nothing at all. `half` + means the target still takes half the damage, rounded down. - `modifier` is the target's adjustment (*hold person*'s single-target −2, - *feeblemind*'s −4). `on_save="negates"` means a passed save avoids the effect; - `"half"` halves damage, rounding down. + Examples: + ```python + from osrlib.core.combat import SaveCategory + from osrlib.data import load_spells + + catalog = load_spells() + assert catalog.get("magic_missile").mode("missiles").save is None # no save: it always hits + fire_ball = catalog.get("fire_ball").mode("damage").save + assert (fire_ball.category, fire_ball.on_save) == (SaveCategory.SPELLS, "half") + assert catalog.get("hold_person_mu").mode("individual").save.modifier == -2 + ``` """ model_config = ConfigDict(frozen=True) @@ -248,13 +468,47 @@ class SaveSpec(BaseModel): class SpellEffect(BaseModel): - """The structured effect a mode's resolution executes — the closed vocabulary. + """What one castable usage of a spell actually does to its targets. + + You read one off [`SpellMode.effect`][osrlib.core.spells.SpellMode]. Casting executes it for + you, so you need this model to describe a spell in an interface, to decide whether a spell is + worth casting on a given target, or to author a spell of your own. + + A mode marked [`manual`][osrlib.core.spells.SpellMode] has no effect at all: its `effect` is + `None`, and casting it spends the copy, emits the event, and leaves the outcome to you. Every + automated mode has one, and its `kind` is validated against + [`EFFECT_KINDS`][osrlib.core.spells.EFFECT_KINDS] when the catalog loads. + + Attributes: + kind: Which resolution behavior runs, one of + [`EFFECT_KINDS`][osrlib.core.spells.EFFECT_KINDS]. + condition: The [`Condition`][osrlib.core.effects.Condition] a `condition` effect attaches to + each affected target, such as blindness or charm. `None` on every other kind. + cures_conditions: The conditions a `cure` effect lifts. *Cure light wounds*' second usage + lifts paralysis. + cures_effect_kinds: The effect kinds a `cure` effect releases from the ledger by name, for + spells that cancel a named magic rather than a condition: *light*'s third usage releases + `"darkness"`. + modifiers: The [`ModifierSpec`][osrlib.core.effects.ModifierSpec] bundle a `modifiers` + effect grants, such as a bonus to armour class or to saves. They ride the attached + effect and lift when it ends. + params: The per-spell numbers the `kind` reads: damage dice, per-level scaling, eligibility + gates, revival windows, area radii. The keys differ by spell and by kind, so read them + against the mode you are looking at rather than expecting a fixed shape. - `kind` names the interpreter behavior; `params` carries the per-spell - scalars (damage dice, per-level scaling, exclusions, revival windows). - `condition` is the condition a `condition` effect attaches; `cures_conditions` - and `cures_effect_kinds` name what a `cure` effect releases; `modifiers` is the - stat-modifier bundle a `modifiers` effect grants through the effects engine. + Examples: + ```python + from osrlib.core.effects import Condition + from osrlib.data import load_spells + + catalog = load_spells() + fire_ball = catalog.get("fire_ball").mode("damage").effect + assert fire_ball.kind == "damage" + assert fire_ball.params == {"dice_per_level": "1d6", "element": "fire"} # 1d6 per caster level + + blind = catalog.get("light_mu").mode("blind").effect + assert (blind.kind, blind.condition) == ("condition", Condition.BLIND) + ``` """ model_config = ConfigDict(frozen=True) @@ -275,15 +529,50 @@ def _kind_must_be_known(cls, value: str) -> str: class SpellMode(BaseModel): - """One castable usage of a spell. - - Multi-usage pages (*cure light wounds*, *light*) carry one mode per numbered - usage. `key` is stable snake_case within the spell's form — casting names the - mode by it. `manual=True` marks modes the kernel doesn't execute: casting one - consumes the memorized copy and emits the cast event with the manual marker plus - the prose, and the game or narrator resolves the fiction. Manual modes may omit - `targeting` (the page carries no structured targeting); automated modes always - carry targeting and an effect. + """One castable usage of a spell: what it targets, what it allows, and what it does. + + Many SRD spell pages print more than one numbered usage. *Cure light wounds* heals or lifts + paralysis, and *light* illuminates, blinds, or cancels darkness. Each usage is a mode, and + casting picks one by its `key`, which is the `mode` argument of + [`cast_spell`][osrlib.core.spells.cast_spell]. Get the modes of a spell from + [`SpellTemplate.modes`][osrlib.core.spells.SpellTemplate], or one by key from + [`SpellTemplate.mode`][osrlib.core.spells.SpellTemplate.mode]. + + A mode is either automated or manual, and the difference decides what casting does for you. An + automated mode has both `targeting` and `effect`, and osrlib resolves it: it picks the targets, + rolls the saves and the dice, applies the outcome, and attaches whatever runs on. A manual mode + is marked `manual=True` and often has no targeting at all, because the SRD page gives it no + structure to work from. Casting a manual mode is still a supported operation: the memorized copy + is spent and the event is emitted with the manual marker and the mode's `prose`, and your game + or narrator resolves what happens. Check `manual` before you promise a player an outcome. + + Attributes: + key: The mode's name, snake_case and unique within its form. This is what you pass as `mode` + to [`cast_spell`][osrlib.core.spells.cast_spell] and + [`validate_cast`][osrlib.core.spells.validate_cast]. A spell with a single usage still + has one, such as *fire ball*'s `"damage"`. + targeting: Who the mode can hit and how many, as a + [`TargetingSpec`][osrlib.core.spells.TargetingSpec]. `None` only on manual modes. + save: The saving throw the targets get, as a [`SaveSpec`][osrlib.core.spells.SaveSpec], or + `None` when the mode allows none. + effect: What the mode does, as a [`SpellEffect`][osrlib.core.spells.SpellEffect]. `None` + only on manual modes. + manual: True when osrlib does the bookkeeping and leaves the outcome to your game. + prose: The SRD text for this usage. Show it to the player, and for a manual mode it is all + osrlib can tell you about the result. + + Examples: + ```python + from osrlib.data import load_spells + + light = load_spells().get("light_mu") + assert [mode.key for mode in light.modes] == ["illuminate", "blind", "cancel"] + assert not any(mode.manual for mode in light.modes) + + blind = light.mode("blind") + assert blind.save is not None # the target may save against being blinded + assert blind.prose.startswith("Blinding a creature:") + ``` """ model_config = ConfigDict(frozen=True) @@ -303,11 +592,44 @@ def _automated_modes_carry_structure(self) -> SpellMode: class ReversedForm(BaseModel): - """A reversible spell's reversed version — entry data, never a separate entry. + """The reverse of a reversible spell, kept on the spell's own entry. + + *Cure light wounds* reverses into *cause light wounds*, *light* into *darkness*. The reverse is + never a separate catalog entry, so you reach it through + [`SpellTemplate.reversed_form`][osrlib.core.spells.SpellTemplate], which is `None` on a spell + that does not reverse. To cast it, pass `reversed=True` to + [`cast_spell`][osrlib.core.spells.cast_spell] with a `mode` key from this form's own `modes`, + which are not the same keys as the normal form's. + + Who fixes the form, and when, differs by caster. An arcane caster chooses normal or reversed + when memorizing, so the choice rides on the + [`MemorizedSpell`][osrlib.core.spells.MemorizedSpell]. A divine caster memorizes the normal form + and decides at the moment of casting, by speaking the words backwards, so any memorized copy + will serve either way. + + Attributes: + name: The reverse's own name, as the SRD prints it, such as `"Cause Light Wounds"`. Show + this rather than the entry's `name` when a cast is reversed. + prose: The SRD text for the reversed version. + modes: One [`SpellMode`][osrlib.core.spells.SpellMode] per castable usage of the reverse, + with its own keys. At least one. + duration: The reverse's printed duration, when the page prints a different one, else `None` + and the normal form's duration applies. + duration_spec: The parsed form of `duration`, as a + [`DurationSpec`][osrlib.core.spells.DurationSpec]. `None` means the reverse lasts as + long as the normal form, which is the common case. A page that prints a dual line such + as `Instant / Permanent` splits it across the two forms. + + Examples: + ```python + from osrlib.data import load_spells - `duration_spec` overrides the normal form's duration when the page prints a - dual form (`Instant / Permanent (curse)` splits across the two); `None` means - the reverse shares the normal form's duration. + cure = load_spells().get("cure_light_wounds") + assert cure.reversed_form.name == "Cause Light Wounds" + assert [mode.key for mode in cure.modes] == ["heal", "cure_paralysis"] + assert [mode.key for mode in cure.reversed_form.modes] == ["harm"] # different keys + assert load_spells().get("magic_missile").reversed_form is None # not reversible + ``` """ model_config = ConfigDict(frozen=True) @@ -320,18 +642,66 @@ class ReversedForm(BaseModel): class SpellTemplate(BaseModel): - """A spell, compiled from its SRD page. - - Frozen SRD data: play never mutates a spell template. `id` is the slugified - primary name (`fire_ball`, `cure_light_wounds`), with `_c`/`_mu` suffixes for - the nine dual-page concepts. `spell_list` is an open, validated list id matched - against [`CasterProfile.spell_list`][osrlib.core.spells.CasterProfile] — the - Classic catalog carries `cleric` and `magic_user`, and Advanced lists are - additive data. `duration` and `range` keep the printed strings; the specs are - the parsed forms. `conjured_monsters` embeds stat blocks printed on the page - (*sticks to snakes*' snake, validated as a full monster template); - `conjured_monster_ids` references existing `monsters.json` entries (*conjure - elemental*'s four 16-HD elementals). + """One spell, compiled from its SRD page: the reference data behind every cast. + + Get one from [`SpellCatalog.get`][osrlib.core.spells.SpellCatalog.get] by id, or a whole class + list from [`SpellCatalog.by_list`][osrlib.core.spells.SpellCatalog.by_list]. The catalog itself + comes from [`load_spells`][osrlib.data.load_spells]. Pass the template straight to + [`cast_spell`][osrlib.core.spells.cast_spell], + [`cast_from_scroll`][osrlib.core.spells.cast_from_scroll], or + [`validate_cast`][osrlib.core.spells.validate_cast], and read its `modes` to know which mode + keys those calls accept. + + A template is frozen and shared. Play never mutates one. What changes during play is the + [`MemorizedSpell`][osrlib.core.spells.MemorizedSpell] copies a caster has prepared and the + effects a cast leaves on the ledger, and both name a template by id rather than containing one. + + Attributes: + id: The stable id you look the spell up by, slugified from its name: `"fire_ball"`, + `"cure_light_wounds"`. Nine concepts the SRD prints as a cleric page and a magic-user + page, which differ mechanically, take `_c` and `_mu` suffixes: `"light_c"` and + `"light_mu"`. For the ids the shipped catalog uses, see + [the spell id index][spells-index]. + name: The spell's printed name, such as `"Cure Light Wounds"`. Show this, not the id. + spell_list: Which class list the spell belongs to. The shipped catalog has `"cleric"` and + `"magic_user"`, and further lists are additive data. It must match the + [`CasterProfile.spell_list`][osrlib.core.spells.CasterProfile] of any caster who + memorizes or learns the spell. + level: The spell's level, 1 to 6. This is what the caster's slots are counted by, not the + caster's own level. + duration: The duration line as printed. Show this to a player. + duration_spec: The parsed form of `duration`, as a + [`DurationSpec`][osrlib.core.spells.DurationSpec]. Casting reads it to set the length of + what it attaches. + range: The range line as printed. + range_spec: The parsed form of `range`, as a [`RangeSpec`][osrlib.core.spells.RangeSpec]. + reversed_form: The spell's reverse, as a + [`ReversedForm`][osrlib.core.spells.ReversedForm], or `None` when it does not reverse. + modes: One [`SpellMode`][osrlib.core.spells.SpellMode] per numbered usage on the page, in + the page's order. At least one. Their keys are the `mode` argument casting takes. + intro: The page's opening text, above the numbered usages. On a multi-usage page it is the + lead-in, such as `"This spell has two usages:"`. On a single-usage page it is the + spell's own description, which the one mode's `prose` repeats. + conjured_monsters: Full monster stat blocks printed on the spell's own page rather than in + the monster catalog, as [`MonsterTemplate`][osrlib.core.monsters.MonsterTemplate] + models: *sticks to snakes* brings its own snake. Spawn them with + [`spawn_monster`][osrlib.core.monsters.spawn_monster] when you resolve the spell. + conjured_monster_ids: Ids of monsters the spell summons that already exist in the monster + catalog, for [`load_monsters`][osrlib.data.load_monsters] to look up. *Conjure elemental* + names its four elementals this way. + overrides_applied: The field paths a compiler correction touched when this entry was built + from the SRD page. Empty for an entry the parser read cleanly. It is a provenance record + and nothing in play reads it. + + Examples: + ```python + from osrlib.data import load_spells + + fire_ball = load_spells().get("fire_ball") + assert (fire_ball.name, fire_ball.spell_list, fire_ball.level) == ("Fire Ball", "magic_user", 3) + assert [mode.key for mode in fire_ball.modes] == ["damage"] + assert fire_ball.range == "240’" and fire_ball.duration == "Instant" + ``` """ model_config = ConfigDict(frozen=True) @@ -363,17 +733,38 @@ def _mode_keys_unique_per_form(self) -> SpellTemplate: return self def mode(self, key: str, *, reversed: bool = False) -> SpellMode: - """Return the mode with `key` on the normal or reversed form. + """Return one castable usage of the spell by its key. + + Use this when you already know which usage you want, to read its targeting, its save, or its + prose before you cast it. To offer a player a choice instead, iterate + [`modes`][osrlib.core.spells.SpellTemplate] and show each mode's `prose`. The key you pass + here is the same string casting takes as its `mode` argument. Args: - key: The mode key, e.g. `"damage"` or `"blind"`. - reversed: True to look on the reversed form. + key: The mode's key, such as `"damage"` or `"blind"`. Keys are unique within a form but + the two forms are independent, so a reversed form may reuse a key or use different + ones entirely. + reversed: True to look on the spell's reversed form instead of its normal one. Returns: - The mode. + The [`SpellMode`][osrlib.core.spells.SpellMode]. Raises: - ValueError: If the form or the key doesn't exist. + ValueError: If the spell has no reversed form and you asked for one, or if the form has + no mode by that key. The message names the spell and the key. + + Examples: + ```python + from osrlib.data import load_spells + + cure = load_spells().get("cure_light_wounds") + assert cure.mode("heal").effect.params["dice"] == "1d6+1" + assert cure.mode("harm", reversed=True).effect.kind == "damage" + try: + cure.mode("harm") # "harm" is a mode of the reverse, not of the normal form + except ValueError as error: + assert "no normal mode 'harm'" in str(error) + ``` """ if reversed: if self.reversed_form is None: @@ -389,7 +780,31 @@ def mode(self, key: str, *, reversed: bool = False) -> SpellMode: class SpellCatalog(BaseModel): - """The loaded spell list, with id lookup and per-list filtering.""" + """Every spell osrlib knows, with lookup by id and by class list. + + Get the shipped catalog from [`load_spells`][osrlib.data.load_spells], which validates it once + and caches it, so calling that loader again costs nothing and returns the same frozen object. + Every function in this module that needs spell data takes either this catalog or one + [`SpellTemplate`][osrlib.core.spells.SpellTemplate] out of it. + + Use [`get`][osrlib.core.spells.SpellCatalog.get] when you have an id, and + [`by_list`][osrlib.core.spells.SpellCatalog.by_list] when you are building a menu of what a + caster may choose. To know which list a given caster draws from, call + [`caster_profile`][osrlib.core.spells.caster_profile] on their class definition. + + Attributes: + spells: Every spell template, in id order. Iterate it to search on something the two lookup + methods do not cover, such as a name or an effect kind. + + Examples: + ```python + from osrlib.data import load_spells + + catalog = load_spells() + assert catalog.get("sleep").level == 1 + assert catalog.spells == tuple(sorted(catalog.spells, key=lambda spell: spell.id)) + ``` + """ model_config = ConfigDict(frozen=True) @@ -403,17 +818,37 @@ def _ids_must_be_unique(self) -> SpellCatalog: return self def get(self, spell_id: str) -> SpellTemplate: - """Return the spell template for `spell_id`. + """Return one spell by its id. + + This is how you turn a stored id back into castable data: a + [`MemorizedSpell`][osrlib.core.spells.MemorizedSpell], a caster's `spell_book`, a scroll, and + the events this module emits all name spells by id. Pass what you get back to + [`cast_spell`][osrlib.core.spells.cast_spell]. Args: - spell_id: The spell id, e.g. `"fire_ball"` or `"hold_person_c"` — see + spell_id: The spell's id, such as `"fire_ball"` or `"hold_person_c"`. Ids come from the + catalog itself, and the full set in the shipped catalog is [the spell id index][spells-index]. Returns: - The spell template. + The [`SpellTemplate`][osrlib.core.spells.SpellTemplate]. Raises: - ValueError: If no spell has that id. + ValueError: If no spell has that id. The message names the id you asked for. An id that + came from osrlib always resolves, so treat this as a signal that the id came from + somewhere else, such as a save written against a different catalog. + + Examples: + ```python + from osrlib.data import load_spells + + catalog = load_spells() + assert catalog.get("hold_person_c").name == "Hold Person" + try: + catalog.get("fireball") # the id is "fire_ball" + except ValueError as error: + assert str(error) == "unknown spell id 'fireball'" + ``` """ for template in self.spells: if template.id == spell_id: @@ -421,14 +856,41 @@ def get(self, spell_id: str) -> SpellTemplate: raise ValueError(f"unknown spell id {spell_id!r}") def by_list(self, spell_list: str, level: int | None = None) -> tuple[SpellTemplate, ...]: - """Return the spells on a class's list, optionally at one spell level. + """Return the spells a class may draw on, optionally narrowed to one spell level. + + This is the menu a caster chooses from: what an arcane caster may add to their spell book + with [`add_spell_to_book`][osrlib.core.spells.add_spell_to_book], and what a divine caster + may prepare with [`memorize_spells`][osrlib.core.spells.memorize_spells]. Narrow by `level` + to fill a particular slot, since a caster's slots are counted per spell level. + + Get the list id from [`caster_profile`][osrlib.core.spells.caster_profile] rather than + hard-coding it, so a class you add with a list of its own works without a change here. Args: - spell_list: The list id, e.g. `"cleric"` or `"magic_user"`. - level: A spell level to filter by, or `None` for the whole list. + spell_list: The list id, such as `"cleric"` or `"magic_user"`. An id no spell uses + returns nothing rather than raising. + level: A spell level, 1 to 6, to filter by. `None` returns the whole list. Returns: - The matching templates, in catalog (id) order. + The matching [`SpellTemplate`][osrlib.core.spells.SpellTemplate] models in id order, + which is the catalog's own order. Empty when nothing matches. + + Examples: + ```python + from osrlib.core.spells import caster_profile + from osrlib.data import load_classes, load_spells + + catalog = load_spells() + profile = caster_profile(load_classes().get("cleric")) + first_level = catalog.by_list(profile.spell_list, 1) + assert [spell.id for spell in first_level][:3] == [ + "cure_light_wounds", + "detect_evil_c", + "detect_magic_c", + ] + assert len(catalog.by_list(profile.spell_list)) > len(first_level) + assert catalog.by_list("druid") == () # no such list in the shipped catalog + ``` """ return tuple( template @@ -438,13 +900,32 @@ def by_list(self, spell_list: str, level: int | None = None) -> tuple[SpellTempl class MemorizedSpell(BaseModel): - """One memorized copy of a spell: the id and the form fixed at memorization. + """One spell a caster has ready to cast: which spell, and in which form. + + You build these to hand to [`memorize_spells`][osrlib.core.spells.memorize_spells], one per slot + you want filled, and you read them back off a character's `memorized_spells`, where they sit in + the order they were prepared. That order matters: casting spends the first copy that matches, + and a level drain forgets the newest first. + + A copy names a spell and fills a slot. What the spell can do comes from the template you get + with [`SpellCatalog.get`][osrlib.core.spells.SpellCatalog.get]. + + Attributes: + spell_id: The spell's id, from [`load_spells`][osrlib.data.load_spells]. For the ids the + shipped catalog uses, see [the spell id index][spells-index]. + reversed: True when this copy is prepared as the spell's reversed form. Only an arcane + caster sets it, because the SRD has arcane casters choose the form when the spell is + memorized. A divine caster memorizes the normal form and speaks it backwards at the + moment of casting, so divine copies are always False and preparing one with True is + rejected. + + Examples: + ```python + from osrlib.core.spells import MemorizedSpell - `spell_id` is a spell id from [`load_spells`][osrlib.data.load_spells] — see - [the spell id index][spells-index]. Arcane casters fix the normal or reversed - form when memorizing (the OSE SRD: "The normal or reversed form of a spell must - be selected when the spell is memorized"); divine casters always memorize the - normal form and choose at cast time, so their copies carry `reversed=False`. + prepared = [MemorizedSpell(spell_id="magic_missile"), MemorizedSpell(spell_id="light_mu", reversed=True)] + assert prepared[1].reversed # this copy casts *darkness*, not *light* + ``` """ model_config = ConfigDict(frozen=True) @@ -454,7 +935,25 @@ class MemorizedSpell(BaseModel): class CasterProfile(BaseModel): - """A class's casting nature, read from its `divine_magic`/`arcane_magic` tag.""" + """What kind of caster a class is, and which spell list it draws on. + + Get one from [`caster_profile`][osrlib.core.spells.caster_profile], which reads it off a class + definition, and you never construct one. Several functions here take it as an argument rather + than deriving it themselves, so a caller who already has the class definition does not pay for + the lookup twice. + + Attributes: + kind: `"divine"` for a class that prays for its spells and keeps no book, `"arcane"` for one + that studies from a spell book. The difference shows up in three places: only an arcane + caster has a book to grow with + [`add_spell_to_book`][osrlib.core.spells.add_spell_to_book], only an arcane caster fixes + a spell's reversed form at memorization, and only a divine caster may cast any memorized + copy in either form. + spell_list: The list id the class draws on, such as `"cleric"` or `"magic_user"`. It has to + match [`SpellTemplate.spell_list`][osrlib.core.spells.SpellTemplate] for the class to + memorize or learn a spell, and it is what you pass to + [`SpellCatalog.by_list`][osrlib.core.spells.SpellCatalog.by_list]. + """ model_config = ConfigDict(frozen=True) @@ -463,14 +962,38 @@ class CasterProfile(BaseModel): def caster_profile(definition: ClassDefinition) -> CasterProfile | None: - """Return a class definition's casting profile, or `None` for non-casters. + """Return how a class casts, or `None` if it casts nothing. + + Call this first when you are about to do anything magical with a character: it is how you find + out whether the class casts at all, and it produces the `profile` argument that + [`cast_spell`][osrlib.core.spells.cast_spell] and + [`validate_cast`][osrlib.core.spells.validate_cast] take. A `None` answer is how you keep magic + out of a fighter's interface, and the memorization and spell-book functions return a rejection + rather than raising when they get one. + + The answer comes from the class's own ability tags, so a class you author yourself casts as soon + as it has a `divine_magic` or `arcane_magic` tag naming a spell list. Nothing here is + hard-coded to the shipped classes. Args: - definition: The [`ClassDefinition`][osrlib.core.classes.ClassDefinition], - from [`load_classes`][osrlib.data.load_classes]. + definition: The class, as a [`ClassDefinition`][osrlib.core.classes.ClassDefinition] from + [`load_classes`][osrlib.data.load_classes]. A character names its class in `class_id`. Returns: - The profile, from the `divine_magic`/`arcane_magic` tag's `spell_list` param. + The [`CasterProfile`][osrlib.core.spells.CasterProfile], or `None` when the class has + neither casting tag. + + Examples: + ```python + from osrlib.core.spells import caster_profile + from osrlib.data import load_classes + + classes = load_classes() + magic_user = caster_profile(classes.get("magic_user")) + assert (magic_user.kind, magic_user.spell_list) == ("arcane", "magic_user") + assert caster_profile(classes.get("cleric")).kind == "divine" + assert caster_profile(classes.get("fighter")) is None + ``` """ for ability in getattr(definition, "abilities", ()): if ability.tag == "divine_magic": @@ -486,12 +1009,27 @@ def _entity_id(combatant: Any) -> str: def _target_ref(target: Any) -> str: - """An explicit string target is a location ref; anything else is an entity.""" + """An explicit string target is a location ref. Anything else is an entity.""" return target if isinstance(target, str) else _entity_id(target) class MemorizationResult(BaseModel): - """The outcome of a preparation: rejections, or the memorized event.""" + """What came of a call to [`memorize_spells`][osrlib.core.spells.memorize_spells]. + + One of the two fields is always empty. Either the preparation was legal, the caster's memorized + list was replaced and `events` contains the record of it, or something was wrong, `rejections` + says what, and nothing was changed at all. Check + [`accepted`][osrlib.core.spells.MemorizationResult.accepted] rather than testing either tuple + yourself. + + Attributes: + rejections: Why the preparation was refused, as + [`Rejection`][osrlib.core.validation.Rejection] models with structured `code` and + `params` you can turn into a message in your own words. Every problem found is reported, + not just the first, so a player fixing a list sees all of it at once. Empty on success. + events: The [`SpellsMemorizedEvent`][osrlib.core.events.SpellsMemorizedEvent] naming what was + prepared, when the preparation went through. Empty on a rejection. + """ model_config = ConfigDict(frozen=True) @@ -500,35 +1038,87 @@ class MemorizationResult(BaseModel): @property def accepted(self) -> bool: - """Whether the preparation was applied.""" + """Whether the caster's memorized list was actually replaced. + + False means nothing changed and `rejections` says why. + """ return not self.rejections def memorize_spells( caster: Any, definition: ClassDefinition, catalog: SpellCatalog, selections: Sequence[MemorizedSpell] ) -> MemorizationResult: - """Prepare a caster's daily spells — a full replacement of the memorized list. - - Models the daily preparation: the new list wholly replaces the old (partial - top-ups are not a B/X operation). Divine casters choose freely from their class - list and never fix the reversed form (they reverse at cast time, "by speaking - the words and performing the gestures backwards"); arcane casters choose from - their spell book and fix normal or reversed per copy at memorization. Duplicate - selections are legal per RAW ("may opt to memorize the same spell twice"). The - once-a-day/after-sleep/one-hour gates are exploration procedure owned by the - crawl layer — standalone callers may call this freely, by design. + """Fill a caster's spell slots for the day, replacing whatever was memorized before. + + This is the first half of the daily cycle, and a caster with an empty memorized list can cast + nothing. The list you pass replaces the old one entirely. There is no partial top-up, + because B/X has no such operation: a caster who spends one spell does not re-memorize that one + slot, they prepare the whole list again at the next opportunity. + + What a caster may choose depends on how they cast, which + [`caster_profile`][osrlib.core.spells.caster_profile] tells you. A divine caster chooses freely + from the whole class list and never marks a copy reversed, because they decide the form when + they cast it. An arcane caster chooses only from their own spell book, which + [`add_spell_to_book`][osrlib.core.spells.add_spell_to_book] grows, and fixes each copy's form + now. Either way the number of copies at each spell level must fit the slots on the caster's + current progression row, and preparing the same spell more than once is allowed. + + The rules about when a caster may do this, once a day, after an uninterrupted night's sleep, + over the course of an hour, are exploration procedure, and they live with + [`PrepareSpells`][osrlib.crawl.commands.PrepareSpells] in the crawl layer. Nothing here checks + them, so if you drive the rules yourself you decide when preparation is allowed. Args: - caster: The preparing caster: a [`Character`][osrlib.core.character.Character] - with a casting class; its `memorized_spells` tuple is replaced. - definition: The caster's [`ClassDefinition`][osrlib.core.classes.ClassDefinition]. - catalog: The loaded spell catalog, from [`load_spells`][osrlib.data.load_spells]. - selections: The prepared [`MemorizedSpell`][osrlib.core.spells.MemorizedSpell] - copies, in memorization order (order is load-bearing: casting consumes - the first matching copy and drain forgets newest-first). + caster: The caster preparing spells, a [`Character`][osrlib.core.character.Character] whose + `memorized_spells` this replaces. Nothing is written when the call is rejected. + definition: The caster's class, as a + [`ClassDefinition`][osrlib.core.classes.ClassDefinition] from + [`load_classes`][osrlib.data.load_classes]. Its progression row at the caster's level + supplies the slot counts. + catalog: The spell catalog, from [`load_spells`][osrlib.data.load_spells]. + selections: The [`MemorizedSpell`][osrlib.core.spells.MemorizedSpell] copies to prepare, in + the order you want them held. The order decides what goes first: casting spends the + first matching copy, and + [`forget_excess_memorized`][osrlib.core.spells.forget_excess_memorized] drops the last + ones first. Returns: - The rejections (nothing mutated) or the memorized event. + A [`MemorizationResult`][osrlib.core.spells.MemorizationResult]: the memorized event on + success, or every rejection found with the caster left untouched. + + Examples: + ```python + from osrlib.core.alignment import Alignment + from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character + from osrlib.core.rng import RngStreams + from osrlib.core.ruleset import Ruleset + from osrlib.core.spells import MemorizedSpell, memorize_spells + from osrlib.data import load_classes, load_spells + + streams = RngStreams(master_seed=3) + catalog = load_spells() + definition = load_classes().get("magic_user") + zelia = create_character( + name="Zelia", + class_id="magic_user", + alignment=Alignment.NEUTRAL, + ruleset=Ruleset(), + stream=streams.get(CHARACTER_CREATION_STREAM), + starting_spell_ids=["sleep"], + ).character + + prepared = memorize_spells(zelia, definition, catalog, [MemorizedSpell(spell_id="sleep")]) + assert prepared.accepted + assert zelia.memorized_spells == (MemorizedSpell(spell_id="sleep"),) + + # A 1st-level magic-user has one first-level slot, so asking for two is refused whole. + refused = memorize_spells( + zelia, definition, catalog, [MemorizedSpell(spell_id="sleep"), MemorizedSpell(spell_id="sleep")] + ) + assert not refused.accepted + assert [rejection.code for rejection in refused.rejections] == ["magic.memorize.slots_exceeded"] + assert zelia.memorized_spells == (MemorizedSpell(spell_id="sleep"),) # the old list stands + ``` """ profile = caster_profile(definition) if profile is None: @@ -585,7 +1175,20 @@ def memorize_spells( class SpellBookResult(BaseModel): - """The outcome of a spell-book addition: rejections, or the book event.""" + """What came of a call to [`add_spell_to_book`][osrlib.core.spells.add_spell_to_book]. + + One of the two fields is always empty: either the spell went into the book and `events` records + it, or it did not and `rejections` says why, with the book unchanged. Check + [`accepted`][osrlib.core.spells.SpellBookResult.accepted] rather than testing either tuple + yourself. + + Attributes: + rejections: Why the addition was refused, as + [`Rejection`][osrlib.core.validation.Rejection] models with structured `code` and + `params`. At most one: the first problem found ends the call. Empty on success. + events: The [`SpellBookUpdatedEvent`][osrlib.core.events.SpellBookUpdatedEvent] naming the + spell that was added. Empty on a rejection. + """ model_config = ConfigDict(frozen=True) @@ -594,29 +1197,72 @@ class SpellBookResult(BaseModel): @property def accepted(self) -> bool: - """Whether the spell was added.""" + """Whether the spell actually went into the book. + + False means the book is unchanged and `rejections` says why. + """ return not self.rejections def open_book_capacity(caster: Any, definition: ClassDefinition, catalog: SpellCatalog) -> tuple[int, ...]: - """Per-spell-level open spell-book slots: row capacity minus spells held, floored at zero. + """Return how many more spells fit in an arcane caster's book, at each spell level. + + Ask this before you offer a player a spell to learn, so the menu only shows levels with room in + them. [`add_spell_to_book`][osrlib.core.spells.add_spell_to_book] checks the same thing and + refuses when there is no room, so you can also skip this and read the rejection. The difference + is that this tells you in advance, without a refused call to explain. + + A book has room, at each spell level, for as many spells as the caster could memorize at that + level. Entry `i` of the answer is what is still free at spell level `i + 1`: the caster's current + slot count there, minus the spells the book already contains there, never below zero. - Entry `i` is the number of spells of spell level `i + 1` the caster's book can - still take: the current progression row's slot count at that level minus the - spells already held there, never below zero. The zero floor carries the - book-never-shrinks rule: a drained caster's book may sit over capacity, and an - over-full level simply reads as no openings until capacity catches up. Non-arcane - classes keep no book, so their answer is the empty tuple. + A book is a physical object and loses no pages when its owner loses levels, so a drained caster + can end up with a book that is over capacity. The floor at zero is what handles that: such a + level reads as no openings rather than as a negative number, and the caster adds nothing there + until their levels come back. Args: - caster: The caster: a [`Character`][osrlib.core.character.Character]; its - `spell_book` and `level` are read. - definition: The caster's [`ClassDefinition`][osrlib.core.classes.ClassDefinition]. - catalog: The loaded spell catalog, from [`load_spells`][osrlib.data.load_spells]. + caster: The caster, a [`Character`][osrlib.core.character.Character]. Its `spell_book` and + `level` are read and nothing is written. + definition: The caster's class, as a + [`ClassDefinition`][osrlib.core.classes.ClassDefinition] from + [`load_classes`][osrlib.data.load_classes]. + catalog: The spell catalog, from [`load_spells`][osrlib.data.load_spells], used to look up + the level of each spell in the book. Returns: - One open-slot count per spell level of the progression row, or `()` for - a class with no arcane book. + One count per spell level on the caster's progression row, lowest level first. An empty + tuple for a class that keeps no spell book, which is every divine caster and every + non-caster. + + Examples: + ```python + from osrlib.core.alignment import Alignment + from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character + from osrlib.core.rng import RngStreams + from osrlib.core.ruleset import Ruleset + from osrlib.core.spells import open_book_capacity + from osrlib.data import load_classes, load_spells + + streams = RngStreams(master_seed=3) + catalog = load_spells() + classes = load_classes() + definition = classes.get("magic_user") + zelia = create_character( + name="Zelia", + class_id="magic_user", + alignment=Alignment.NEUTRAL, + ruleset=Ruleset(), + stream=streams.get(CHARACTER_CREATION_STREAM), + starting_spell_ids=["sleep"], + ).character + + # One first-level slot, and the starting book already fills it. + assert open_book_capacity(zelia, definition, catalog) == (0, 0, 0, 0, 0, 0) + zelia.level = 3 # two first-level slots and one second-level + assert open_book_capacity(zelia, definition, catalog) == (1, 1, 0, 0, 0, 0) + assert open_book_capacity(zelia, classes.get("cleric"), catalog) == () # clerics keep no book + ``` """ profile = caster_profile(definition) if profile is None or profile.kind != "arcane": @@ -632,25 +1278,70 @@ def open_book_capacity(caster: Any, definition: ClassDefinition, catalog: SpellC def add_spell_to_book( caster: Any, definition: ClassDefinition, catalog: SpellCatalog, spell_id: str ) -> SpellBookResult: - """Add a spell to an arcane caster's book — the referee-level growth surface. + """Write a spell into an arcane caster's spell book. + + A spell book is what an arcane caster may prepare from, so this is how such a caster's range + grows: they gain a level, find a mentor, copy a captured book. Call + [`open_book_capacity`][osrlib.core.spells.open_book_capacity] first if you want to show only the + spells that will fit, and [`SpellCatalog.by_list`][osrlib.core.spells.SpellCatalog.by_list] to + build the menu of what the class may learn at all. Once a spell is in the book, + [`memorize_spells`][osrlib.core.spells.memorize_spells] can prepare it. - Covers mentoring and leveling; the fiction around it (the mentor's week, a lost - book's rewriting costs) belongs to the game. The book holds, per spell level, at - most the caster's current slot count at that level (the RAW "contains exactly - the number of spells that the character is capable of memorizing", read per - level). The book never auto-shrinks — it is a physical object; a drained - character may hold a book over capacity and simply cannot add more until - capacity catches up. + The book has room, at each spell level, for as many spells as the caster could memorize at that + level, and it never contains the same spell twice. It loses no pages on its own, so a caster who + loses levels keeps every page and adds nothing more until their capacity catches up. + + osrlib models only the writing. What it costs and how long it takes, the mentor's week, the + price of ink and a fresh book after a fire, belong to your game. Nothing here charges for it or spends + game time. In a session, [`LearnSpell`][osrlib.crawl.commands.LearnSpell] wraps this call and + also passes no time. Args: - caster: The learning caster: a [`Character`][osrlib.core.character.Character] - with an arcane class; its `spell_book` tuple grows. - definition: The caster's [`ClassDefinition`][osrlib.core.classes.ClassDefinition]. - catalog: The loaded spell catalog, from [`load_spells`][osrlib.data.load_spells]. - spell_id: The spell id to add — see [the spell id index][spells-index]. + caster: The caster learning the spell, a [`Character`][osrlib.core.character.Character] with + an arcane class. Its `spell_book` grows by one id. Nothing is written when the call is + rejected. + definition: The caster's class, as a + [`ClassDefinition`][osrlib.core.classes.ClassDefinition] from + [`load_classes`][osrlib.data.load_classes]. + catalog: The spell catalog, from [`load_spells`][osrlib.data.load_spells]. + spell_id: The spell to write in. For the ids the shipped catalog uses, see + [the spell id index][spells-index]. Returns: - The rejections (nothing mutated) or the book-updated event. + A [`SpellBookResult`][osrlib.core.spells.SpellBookResult]: the book-updated event on + success, or the rejection with the book left untouched. A class with no book, an id no spell + uses, a spell off the class's list, one the book already contains, and a level with no room + are each their own rejection code. + + Examples: + ```python + from osrlib.core.alignment import Alignment + from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character + from osrlib.core.rng import RngStreams + from osrlib.core.ruleset import Ruleset + from osrlib.core.spells import add_spell_to_book + from osrlib.data import load_classes, load_spells + + streams = RngStreams(master_seed=3) + catalog = load_spells() + definition = load_classes().get("magic_user") + zelia = create_character( + name="Zelia", + class_id="magic_user", + alignment=Alignment.NEUTRAL, + ruleset=Ruleset(), + stream=streams.get(CHARACTER_CREATION_STREAM), + starting_spell_ids=["sleep"], + ).character + zelia.level = 3 # room for one more first-level spell + + learned = add_spell_to_book(zelia, definition, catalog, "magic_missile") + assert learned.accepted + assert zelia.spell_book == ("sleep", "magic_missile") + + refused = add_spell_to_book(zelia, definition, catalog, "cure_light_wounds") + assert [rejection.code for rejection in refused.rejections] == ["magic.book.wrong_list"] + ``` """ profile = caster_profile(definition) if profile is None or profile.kind != "arcane": @@ -684,21 +1375,68 @@ def add_spell_to_book( def forget_excess_memorized(caster: Any, definition: ClassDefinition, catalog: SpellCatalog) -> list[Event]: - """Forget memorized copies in excess of the caster's (shrunk) slots. + """Drop memorized copies the caster no longer has the slots for. + + Call this after anything that lowers a caster's level, which in B/X means energy drain. Their + slot counts drop with the level, and the spells they had ready stop fitting. This call drops the + surplus. Nothing calls it for you, so a game that drains a caster and skips it leaves them with + spells they should not have. + + Nothing happens when the caster still has room, so the call is safe to make after any level + change rather than only after a drop. It looks at each spell level on its own: a caster who lost + a second-level slot forgets a second-level spell and keeps their first-level ones. - The drain interplay: after a level drop, for each spell level where the - memorized count exceeds the new slot count, excess copies are forgotten - newest-first (highest tuple index). RAW is silent on which copies go; osrlib - adopts newest-first because it keeps the rule deterministic without new state. + Which copy goes is osrlib's choice. The tabletop rules do not say, and this drops the most + recently prepared copies first, the ones at the end of the caster's `memorized_spells`, because + that is decidable from the list itself and gives the same answer on every replay. Args: - caster: The drained caster: a [`Character`][osrlib.core.character.Character]; - its `memorized_spells` tuple shrinks. - definition: The caster's [`ClassDefinition`][osrlib.core.classes.ClassDefinition]. - catalog: The loaded spell catalog, from [`load_spells`][osrlib.data.load_spells]. + caster: The caster who lost levels, a [`Character`][osrlib.core.character.Character]. Its + `memorized_spells` shrinks. A caster with nothing memorized is left alone. + definition: The caster's class, as a + [`ClassDefinition`][osrlib.core.classes.ClassDefinition] from + [`load_classes`][osrlib.data.load_classes]. Its row at the caster's new level supplies + the slot counts. + catalog: The spell catalog, from [`load_spells`][osrlib.data.load_spells], used to look up + the level of each memorized spell. Returns: - One forgotten event per dropped copy, newest first. + One [`SpellForgottenEvent`][osrlib.core.events.SpellForgottenEvent] per copy dropped, newest + first. Empty when everything still fits. + + Examples: + ```python + from osrlib.core.alignment import Alignment + from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character + from osrlib.core.rng import RngStreams + from osrlib.core.ruleset import Ruleset + from osrlib.core.spells import MemorizedSpell, forget_excess_memorized, memorize_spells + from osrlib.data import load_classes, load_spells + + streams = RngStreams(master_seed=3) + catalog = load_spells() + definition = load_classes().get("cleric") + aldis = create_character( + name="Aldis", + class_id="cleric", + alignment=Alignment.LAWFUL, + ruleset=Ruleset(), + stream=streams.get(CHARACTER_CREATION_STREAM), + ).character + aldis.level = 3 # two first-level slots + memorize_spells( + aldis, + definition, + catalog, + [MemorizedSpell(spell_id="cure_light_wounds"), MemorizedSpell(spell_id="light_c")], + ) + + aldis.level = 2 # drained back to one slot + forgotten = forget_excess_memorized(aldis, definition, catalog) + assert [event.spell_id for event in forgotten] == ["light_c"] # the copy prepared last + assert aldis.memorized_spells == (MemorizedSpell(spell_id="cure_light_wounds"),) + assert forget_excess_memorized(aldis, definition, catalog) == [] # nothing left over + ``` """ memorized = list(getattr(caster, "memorized_spells", ())) if not memorized: @@ -731,18 +1469,43 @@ def forget_excess_memorized(caster: Any, definition: ClassDefinition, catalog: S class CastContext(BaseModel): - """The caller-asserted situation a cast resolves under — the RAW referee surface. - - `in_combat` gates the touch-attack roll ("In combat, a melee attack roll is - required"; outside combat the touch lands without a roll). `bound`/`gagged` are - the OSE SRD's freedom restraints, which the kernel has no model for. - `rounds_since_death` and `days_since_death` are the caller's attestations for - *neutralize poison* and *raise dead* (the session supplies them from its death - records) — the kernel has no cause-of-death model, so supplying - `rounds_since_death` *is* the attestation that the target died of poison; omit - it for any other death. `strength_tiers` maps entity ids to - `"augmented"`/`"giant"` for *web*'s faster escape tiers (caller-asserted until - such effects exist). + """The facts about a cast's situation that only you know, asserted for the rules to use. + + Build one and pass it to [`validate_cast`][osrlib.core.spells.validate_cast], + [`cast_spell`][osrlib.core.spells.cast_spell], or + [`cast_from_scroll`][osrlib.core.spells.cast_from_scroll]. Every field is optional and the + default context asserts nothing, which is the right thing to pass when none of these questions + arises. + + These are the questions a referee at a table answers out loud, and osrlib cannot answer any of + them from its own state. It has no map, so it does not know how far away the target is. It has + no model of restraints, so it does not know the caster is tied up. It records no cause of death, + so it does not know the corpse died of poison. + + A field you leave unset means the rule that reads it does not fire. Range goes unchecked if you + assert no distance, and *raise dead* raises nobody if you assert no elapsed days. That is the + trade: rather than guess, osrlib leaves a rule alone until you supply what it needs. + + Attributes: + in_combat: True when the cast happens in a fight. A touch spell needs a melee attack roll in + combat and lands without one outside it, so this decides whether the touch can miss. + distance_feet: How far the target is from the caster. Supplying it turns on the range check + in validation, which rejects the cast when the distance is past what the spell's + [`RangeSpec`][osrlib.core.spells.RangeSpec] reaches at the caster's level. Leave it + unset and no range check happens. + bound: True when the caster is tied or held so that they cannot gesture. Casting is + rejected. + gagged: True when the caster cannot speak. Casting is rejected. + rounds_since_death: How many rounds ago the target died, for *neutralize poison*, which + revives a character killed by poison within the last ten rounds. Setting this field is + itself the assertion that poison was the cause, since osrlib records no cause of death. + Leave it unset for a death by any other means. + days_since_death: How many days ago the target died, for *raise dead*, which reaches back + four days per caster level above seventh. Leave it unset and nobody is raised. + strength_tiers: Entity ids mapped to `"augmented"` or `"giant"`, for *web*, which lets + stronger creatures tear free sooner. Anyone you do not name tears free at normal + strength. It is asserted here because osrlib has no effect that grants giant strength + yet. """ model_config = ConfigDict(frozen=True) @@ -768,12 +1531,12 @@ class CastContext(BaseModel): def _int_param(params: Mapping[str, Any], key: str, default: int = 0) -> int: - """Read an integer param — schema-validated data whose union the checker can't key by name.""" + """Read an integer param: schema-validated data whose union the checker can't key by name.""" return int(params.get(key, default)) def _mode_effect(mode: SpellMode) -> SpellEffect: - """The mode's effect — model-validated as present on every automated mode.""" + """The mode's effect, model-validated as present on every automated mode.""" if mode.effect is None: raise ValueError(f"mode {mode.key!r} is manual and carries no effect") return mode.effect @@ -799,9 +1562,9 @@ def _max_range_feet(spell: SpellTemplate, caster_level: int) -> int | None: def _memorized_index(caster: Any, spell: SpellTemplate, reversed: bool, profile: CasterProfile) -> int | None: """Return the index of the first matching memorized copy (lowest index). - Divine casters match any copy of the spell — the reversed flag is chosen freely - at cast, whatever their spell list; arcane casters fixed the form at - memorization, so the flag must match. + Divine casters match any copy of the spell, since the reversed flag is chosen freely at cast, + whatever their spell list. Arcane casters fixed the form at memorization, so the flag must + match. """ for index, copy in enumerate(getattr(caster, "memorized_spells", ())): if copy.spell_id != spell.id: @@ -822,42 +1585,103 @@ def validate_cast( context: CastContext | None = None, ledger: EffectsLedger | None = None, ) -> list[Rejection]: - """Validate a cast — the pure pre-phase: no RNG draws, no mutation. - - Checks caster capacity (dead, petrified, paralysed, asleep, silenced, - feebleminded, or weakened cannot cast; `bound`/`gagged` arrive as context flags; - an active *anti-magic shell* blocks the caster's own casting when a ledger is - supplied), a matching memorized copy, form and mode legality, target counts - (single takes one; *magic missile* takes exactly one target per missile), and - range — only when the context supplies a distance, mirroring the combat - convention. Category and immunity gates are **resolution** outcomes, never - validator rejections, by design: casting *charm person* at a disguised - doppelgänger must not be a zero-cost detector. A cleric's holy symbol is not - checked — the OSE SRD states carrying one as a class edict, not a mechanical - gate on any procedure. + """Ask whether a cast is legal, without casting it. + + Call this to decide whether to offer a cast at all, to grey out a spell in a menu, or to explain + to a player why they cannot do what they are trying to do. Then call + [`cast_spell`][osrlib.core.spells.cast_spell], which runs these same checks and raises if any + fail, so a cast you validated and then made cannot be refused. + + Nothing here draws from an RNG stream, changes the caster, or touches the ledger, so asking is + free and leaves no trace. That is also why the answer stops short of one thing you might expect. + Whether a target is the kind of creature the spell affects is settled during resolution, not + here, because a validator that rejected *charm person* aimed at a disguised doppelganger would + be a free way to find out what the doppelganger is. Such a cast is legal, resolves, spends the + copy, and affects nobody. + + What it does check: that the caster is in a state to cast at all, which rules out dead, + petrified, paralysed, asleep, silenced, feebleminded, and weakened casters as well as bound or + gagged ones and any caster standing in their own anti-magic shell. That they have a memorized + copy in the form they are asking for. That the spell has the form and the mode named. That the + number of targets suits the mode, which for *magic missile* means exactly one target per missile + the caster's level grants. And that the target is in range, but only if you asserted a distance. + + A cleric's holy symbol is not checked. The SRD tells clerics to carry one as a matter of their + class, not as a condition on any procedure, so a game that wants the stricter reading checks + inventory itself. Args: - caster: The casting caster: a [`Character`][osrlib.core.character.Character]. - spell: The [`SpellTemplate`][osrlib.core.spells.SpellTemplate] to cast. - mode: The mode key on the chosen form (see - [`SpellTemplate.mode`][osrlib.core.spells.SpellTemplate.mode]). - profile: The caster's [`CasterProfile`][osrlib.core.spells.CasterProfile], - from [`caster_profile`][osrlib.core.spells.caster_profile] on the - definition the caller holds — divine casters match any memorized copy - (the reversed form is chosen at cast). `None` skips the memorized-copy - check entirely, for scroll reads: the scroll is the copy. - reversed: True to cast the reversed form. - targets: The explicit target list, per the combatant convention (see + caster: The caster, a [`Character`][osrlib.core.character.Character]. Read, never written. + spell: The [`SpellTemplate`][osrlib.core.spells.SpellTemplate] to cast, from + [`SpellCatalog.get`][osrlib.core.spells.SpellCatalog.get]. + mode: Which usage of the spell, by its + [`SpellMode.key`][osrlib.core.spells.SpellMode]. A key the chosen form does not have is + a rejection, not an exception. + profile: The caster's [`CasterProfile`][osrlib.core.spells.CasterProfile], from + [`caster_profile`][osrlib.core.spells.caster_profile]. It decides how a memorized copy + has to match: a divine caster's copy serves for either form, an arcane caster's only for + the form it was prepared in. Pass `None` to skip the memorized-copy check entirely, for + a scroll read, where the scroll is the copy. + reversed: True to cast the spell's reversed form. + targets: The candidate targets, per the combatant convention (see [`osrlib.core.combat`][osrlib.core.combat]): [`Character`][osrlib.core.character.Character] or - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] objects, or - location strings for effects games attach to places. - context: The caller-asserted [`CastContext`][osrlib.core.spells.CastContext]. - ledger: The [`EffectsLedger`][osrlib.core.effects.EffectsLedger], consulted - for the caster's own blocking effects. + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] objects, or location strings + for spells your game attaches to a place rather than a creature. Only the count is + examined here. + context: The [`CastContext`][osrlib.core.spells.CastContext] with what you assert about the + situation. `None` asserts nothing. + ledger: The [`EffectsLedger`][osrlib.core.effects.EffectsLedger], consulted for effects on + the caster that block casting. Pass `None` and no such effect is found, so pass the + ledger you play with. Returns: - Structured rejections; empty when the cast may resolve. + Every reason the cast is illegal, as [`Rejection`][osrlib.core.validation.Rejection] models + with structured `code` and `params`. Empty when the cast may go ahead. Some checks stop the + call at the first problem, so treat the list as the reasons found rather than every reason + there is. + + Examples: + ```python + from osrlib.core.alignment import Alignment + from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character + from osrlib.core.monsters import MONSTER_SPAWN_STREAM, spawn_monster + from osrlib.core.rng import RngStreams + from osrlib.core.ruleset import Ruleset + from osrlib.core.spells import CastContext, MemorizedSpell, caster_profile, memorize_spells, validate_cast + from osrlib.data import load_classes, load_monsters, load_spells + + streams = RngStreams(master_seed=5) + catalog = load_spells() + definition = load_classes().get("magic_user") + zelia = create_character( + name="Zelia", + class_id="magic_user", + alignment=Alignment.NEUTRAL, + ruleset=Ruleset(), + stream=streams.get(CHARACTER_CREATION_STREAM), + starting_spell_ids=["magic_missile"], + ).character + memorize_spells(zelia, definition, catalog, [MemorizedSpell(spell_id="magic_missile")]) + template = load_monsters().get("goblin") + goblin = spawn_monster(template, id="monster-0001", stream=streams.get(MONSTER_SPAWN_STREAM)) + + profile = caster_profile(definition) + missile = catalog.get("magic_missile") + assert validate_cast(zelia, missile, "missiles", profile=profile, targets=[goblin]) == [] + + # The same cast at a goblin 200 feet away, which is past the spell's 150 feet. + refused = validate_cast( + zelia, + missile, + "missiles", + profile=profile, + targets=[goblin], + context=CastContext(distance_feet=200), + ) + assert [rejection.code for rejection in refused] == ["magic.cast.out_of_range"] + assert refused[0].params["range_feet"] == 150 + ``` """ context = context or CastContext() caster_id = _entity_id(caster) @@ -931,14 +1755,37 @@ def validate_cast( class CastResult(BaseModel): - """A cast's outcome: the consumed copy, what it affected, and the events. - - `manual=True` means the kernel did the bookkeeping (the copy is spent, the cast - event is emitted) and the game narrates the effect — `prose` carries the mode's - SRD text for that. `no_effect` marks a resolved cast that affected nothing (an - ineligible target, every save passed on a negating spell): the copy is still - spent, never refunded — validation rejections are free, and refunding a resolved - cast would leak hidden state such as an unseen target's immunity. + """What a cast did: which copy was spent, who it reached, and everything that happened. + + You get one back from [`cast_spell`][osrlib.core.spells.cast_spell] and from + [`cast_from_scroll`][osrlib.core.spells.cast_from_scroll]. A result always describes a cast that + happened. An illegal cast raises instead, so by the time you have one of these the copy is spent + and the caster and the ledger have already been changed. Your work with it is to tell the player + what happened: publish `events` to whatever consumes them, and read `manual`, `no_effect`, and + `affected_ids` to know what to say. + + Two of the outcomes need more than a description of what the spell did. A `manual` mode means + osrlib did the bookkeeping and stopped: nothing was resolved and `prose` is all it can tell you, + so your game or narrator says what happened. `no_effect` means the cast resolved and reached + nobody, because no candidate was eligible or every target saved. In both cases the copy is + gone. Nothing is + refunded once a cast resolves: a refund would tell the player something they had no way to know, + such as that the creature they aimed at was immune. + + Attributes: + spell_id: The id of the spell that was cast, the same one you would pass to + [`SpellCatalog.get`][osrlib.core.spells.SpellCatalog.get]. + mode: The [`SpellMode.key`][osrlib.core.spells.SpellMode] that resolved. + reversed: True when the reversed form was the one cast. + manual: True when the mode was one osrlib does not resolve. Read `prose` and narrate it. + no_effect: True when the cast resolved and changed nothing. The copy is still spent. + prose: The SRD text of the mode that was cast, ready to show a player. + affected_ids: The entity id of everything the cast actually reached, in the order it was + reached, without repeats. A location-bound cast contains the location string you passed + as a target instead of an entity id. Empty when `no_effect` or `manual` is set. + events: Everything that happened, in order, starting with the + [`SpellCastEvent`][osrlib.core.events.SpellCastEvent] and then each saving throw, each + wound, each effect attached. This is what your game publishes and what a replay reads. """ model_config = ConfigDict(frozen=True) @@ -983,59 +1830,79 @@ def cast_spell( stream: RngStream, effects_stream: RngStream, ) -> CastResult: - """Cast a memorized spell: consume the copy, resolve the mode, return the events. - - Consumes the first matching memorized copy (lowest tuple index) whether or not - the resolution ends up affecting anything — including a touch attack that misses - (nothing in RAW holds the charge). Divine casters consume any copy of the spell - and choose the form at cast time; arcane casters fixed the form at memorization. - Casting anything releases the caster's own *invisibility* (RAW: attacking or - casting breaks it). Manual modes emit the cast event with the manual marker and - the kernel stops there — the game or narrator resolves the fiction. - - Spell-resolution draws (targeting dice, damage dice, touch-attack rolls, - cast-time forced saves, dispel survival rolls) come from `stream` — the - [`MAGIC_STREAM`][osrlib.core.spells.MAGIC_STREAM] convention; attach-time draws - (rolled durations, *web* escape dice) come from `effects_stream` per the - effects-engine convention, so the two subsystems replay independently. + """Cast a memorized spell: spend the copy, resolve the mode, and hand back what happened. + + This is the module's entry point. Before you can call it the caster needs a prepared copy, which + [`memorize_spells`][osrlib.core.spells.memorize_spells] gives them, and you need the profile + that [`caster_profile`][osrlib.core.spells.caster_profile] returns. After it, publish the + [`CastResult`][osrlib.core.spells.CastResult]'s events and keep the ledger you passed, because + anything the spell left running now lives there and wants ticking by + [`osrlib.core.effects`][osrlib.core.effects]. To cast an inscribed spell with no memorized copy + behind it, use [`cast_from_scroll`][osrlib.core.spells.cast_from_scroll] instead. + + The copy is spent whatever comes of the cast. It is spent when every target saves, when nothing + was eligible, and when a touch attack misses, because B/X has no rule for holding a spell back + once it is cast. Ask [`validate_cast`][osrlib.core.spells.validate_cast] first if you want a free + answer: an illegal cast raises here rather than returning a refusal, because you had a way to + ask. + + Which copy goes depends on how the caster casts. A divine caster spends any copy of the spell and + picks the form as they cast. An arcane caster fixed the form when they prepared it, so the copy + has to match what you are asking for. Either way the first matching copy in the caster's list is + the one that goes. + + Casting anything breaks the caster's own invisibility, before the new spell resolves, the same + way attacking does. + + Two RNG streams go in, and they stay separate so that each replays on its own. Everything the + cast itself rolls, targeting dice, damage dice, the touch attack, the saves it forces, comes from + `stream`. Everything an attached effect rolls, such as a duration rolled as it attaches, comes + from `effects_stream`. Args: - caster: The casting caster: a [`Character`][osrlib.core.character.Character] - with a matching memorized copy; its `memorized_spells` tuple shrinks by - one copy. + caster: The caster, a [`Character`][osrlib.core.character.Character] with a matching + memorized copy. Its `memorized_spells` loses that copy. spell: The [`SpellTemplate`][osrlib.core.spells.SpellTemplate] to cast, from - the catalog [`load_spells`][osrlib.data.load_spells] returns. - mode: The mode key on the chosen form (see - [`SpellTemplate.mode`][osrlib.core.spells.SpellTemplate.mode]). - profile: The caster's [`CasterProfile`][osrlib.core.spells.CasterProfile], - from [`caster_profile`][osrlib.core.spells.caster_profile] on the - definition the caller holds. - reversed: True to cast the reversed form. - targets: The explicit target list, in the caller's order, per the combatant - convention (see [`osrlib.core.combat`][osrlib.core.combat]): + [`SpellCatalog.get`][osrlib.core.spells.SpellCatalog.get]. + mode: Which usage of the spell, by its [`SpellMode.key`][osrlib.core.spells.SpellMode]. + profile: The caster's [`CasterProfile`][osrlib.core.spells.CasterProfile], from + [`caster_profile`][osrlib.core.spells.caster_profile]. + reversed: True to cast the spell's reversed form. + targets: The candidate targets in your own order, per the combatant convention (see + [`osrlib.core.combat`][osrlib.core.combat]): [`Character`][osrlib.core.character.Character] or - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] objects, or - location strings for effects games attach to places. - context: The caller-asserted [`CastContext`][osrlib.core.spells.CastContext]. - ledger: The [`EffectsLedger`][osrlib.core.effects.EffectsLedger] durations ride. - clock: The [`GameClock`][osrlib.core.clock.GameClock]. - allocator: The id allocator for attached effects: an - [`IdAllocator`][osrlib.core.monsters.IdAllocator]. - registry: Live combatants by entity id — + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] objects, or location strings + for spells your game attaches to a place. Casting drops the ineligible ones and then + applies the mode's targeting to the rest, so passing more candidates than the spell can + take is normal for an area or group mode. + context: The [`CastContext`][osrlib.core.spells.CastContext] with what you assert about the + situation. `None` asserts nothing. + ledger: The [`EffectsLedger`][osrlib.core.effects.EffectsLedger] that ongoing effects attach + to. Pass the one your game keeps, not a fresh one, or the spell's duration is lost. + clock: The [`GameClock`][osrlib.core.clock.GameClock], read to stamp when attached effects + began and when they end. + allocator: The [`IdAllocator`][osrlib.core.monsters.IdAllocator] that names each attached + effect. Pass the one your game keeps, so ids stay unique across the session. + registry: Every live combatant by entity id, as [`Character`][osrlib.core.character.Character] and - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] objects the - resolution may mutate. - ruleset: The [`Ruleset`][osrlib.core.ruleset.Ruleset] in play. - stream: The magic stream (the `"magic"` [`RngStream`][osrlib.core.rng.RngStream]). - effects_stream: The effects stream, for attach-time dice. + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] objects. Resolution reaches + through it to change creatures the targets alone do not name, such as when an effect + lifts. + ruleset: The [`Ruleset`][osrlib.core.ruleset.Ruleset] in play, read for the optional rules + that touch attack rolls and damage. + stream: The [`RngStream`][osrlib.core.rng.RngStream] every draw the cast makes comes from, + conventionally [`MAGIC_STREAM`][osrlib.core.spells.MAGIC_STREAM]. + effects_stream: The stream that attaching effects draw from, conventionally + [`EFFECTS_STREAM`][osrlib.core.effects.EFFECTS_STREAM]. Returns: - The cast outcome with its events. + The [`CastResult`][osrlib.core.spells.CastResult]: what was reached, and every event, in + order. Raises: - ValueError: If the cast is invalid — validate with - [`validate_cast`][osrlib.core.spells.validate_cast] first; casting an - unmemorized spell is programmer misuse. + ValueError: If the cast is illegal. Ask + [`validate_cast`][osrlib.core.spells.validate_cast] first. Reaching this means the cast + was not legal to offer. Examples: ```python @@ -1067,7 +1934,7 @@ def cast_spell( prepared = memorize_spells(zelia, definition, catalog, [MemorizedSpell(spell_id="magic_missile")]) assert prepared.accepted - # One goblin target; the registry maps entity ids to the live objects. + # One goblin target. The registry maps entity ids to the live objects. template = load_monsters().get("goblin") goblin = spawn_monster(template, id="monster-0001", stream=streams.get(MONSTER_SPAWN_STREAM)) outcome = cast_spell( @@ -1086,7 +1953,7 @@ def cast_spell( ) assert outcome.spell_id == "magic_missile" and not outcome.no_effect assert outcome.affected_ids == ("monster-0001",) - assert zelia.memorized_spells == () # the cast consumed the memorized copy + assert zelia.memorized_spells == () # the cast spent the memorized copy assert goblin.max_hp - goblin.current_hp == 3 # 1d6+1 missile damage, stable under this seed ``` """ @@ -1136,7 +2003,7 @@ def _perform_cast( stream: RngStream, effects_stream: RngStream, ) -> CastResult: - """Resolve a validated cast whose cost is already paid — shared by memory and scroll.""" + """Resolve a validated cast whose cost is already paid, shared by memory and scroll.""" caster_id = _entity_id(caster) state = _CastState() # Casting breaks the caster's own invisibility, before the new spell resolves. @@ -1203,9 +2070,9 @@ def _perform_cast( class _ScrollReader: """A duck-typed caster proxy: the reader's body at the scroll's caster level. - Attribute reads and writes pass through to the reader, so conditions and - modifiers land on the real character; only `level` is overridden — scroll - spells resolve at the minimum class level able to cast the spell. + Attribute reads and writes pass through to the reader, so conditions and modifiers land on the + real character. Only `level` is overridden, because a scroll spell resolves at the minimum class + level able to cast it. """ __slots__ = ("_level", "_reader") @@ -1226,20 +2093,41 @@ def __setattr__(self, name: str, value: object) -> None: def minimum_caster_level(spell: SpellTemplate) -> int: - """Return the minimum class level able to cast a spell, per the compiled progressions. + """Return the lowest class level that could cast a spell at all. + + This is the caster level a scroll resolves at, so + [`cast_from_scroll`][osrlib.core.spells.cast_from_scroll] calls it for you and you rarely need + it yourself. Call it directly when you want to show what a scroll will do before anyone reads + it, since caster level is what scales a spell's damage, duration, and reach. + + The answer is the lowest level at which any class drawing on the spell's list first has a slot + of that spell's level, read off the compiled class progressions. So the answer moves if you add + a class whose progression reaches that spell level sooner. - RAW is silent on a scroll's caster level; osrlib adopts the least-power reading - (a documented adaptation): the lowest level at which any class on the spell's - list has a slot of the spell's level. + The tabletop rules do not say what level a scroll's spell was inscribed at. osrlib takes the + weakest reading that still works, which keeps a found scroll from outdoing the caster who finds + it. A game that wants scrolls to have their own caster level resolves them with + [`cast_spell`][osrlib.core.spells.cast_spell] against a caster of that level instead. Args: - spell: The [`SpellTemplate`][osrlib.core.spells.SpellTemplate]. + spell: The [`SpellTemplate`][osrlib.core.spells.SpellTemplate] to look up. Returns: - The minimum caster level. + The caster level, 1 or higher. Raises: - ValueError: If no class on the spell's list ever gains a slot of that level. + ValueError: If no class on the spell's list ever gains a slot of its level, which means the + spell and the classes that should cast it disagree. + + Examples: + ```python + from osrlib.core.spells import minimum_caster_level + from osrlib.data import load_spells + + catalog = load_spells() + assert minimum_caster_level(catalog.get("magic_missile")) == 1 + assert minimum_caster_level(catalog.get("fire_ball")) == 5 # a fire ball scroll burns for 5d6 + ``` """ from osrlib.data import load_classes @@ -1273,52 +2161,108 @@ def cast_from_scroll( stream: RngStream, effects_stream: RngStream, ) -> CastResult: - """Cast an inscribed spell from a scroll: the scroll is the copy, and it burns. - - Reuses [`validate_cast`][osrlib.core.spells.validate_cast]'s legality gates and - the full mode-resolution and effects machinery, but skips the memorized-copy - consume — "when a scroll is read, the words disappear", so the caller marks - the inscribed spell spent. The spell resolves at the minimum class level able - to cast it (the least-power reading — see - [`minimum_caster_level`][osrlib.core.spells.minimum_caster_level]). Class-list - gating (arcane readers for arcane scrolls, the thief's scroll-use ability) and - the light requirement are the crawl's validation; the kernel resolves a legal - read. + """Cast a spell off a scroll, with the scroll standing in for the memorized copy. + + Use this rather than [`cast_spell`][osrlib.core.spells.cast_spell] whenever the spell comes off a + page instead of out of the reader's memory. The reader needs no memorized copy, no slot, and no + ability to cast the spell of their own. Everything else is the same: the same legality checks, + the same targeting, the same resolution, the same + [`CastResult`][osrlib.core.spells.CastResult]. + + The scroll itself is your responsibility. Reading one uses it up, since the words disappear from + the page, and osrlib has no model of the scroll, so mark the inscribed spell spent in your own + inventory after this returns. Two other checks are yours as well, or the crawl layer's if you use it: + whether this reader may read this scroll at all, which is where a thief's scroll-use ability and + the arcane and divine divide come in, and whether there is light to read by. + + The spell resolves at the level [`minimum_caster_level`][osrlib.core.spells.minimum_caster_level] + gives for it, not the reader's own level, which is usually lower and sometimes much higher. + Everything the spell does to the reader, a condition, a modifier, a wound, still lands on the + reader. Args: - reader: The reading character: a - [`Character`][osrlib.core.character.Character]. Conditions and modifiers - from the resolution land on the reader; only its effective level is - proxied. - spell: The inscribed [`SpellTemplate`][osrlib.core.spells.SpellTemplate]. - mode: The mode key on the chosen form (see - [`SpellTemplate.mode`][osrlib.core.spells.SpellTemplate.mode]). - reversed: True to cast the reversed form (divine readers choose at cast). - targets: The explicit target list, in the caller's order, per the combatant - convention (see [`osrlib.core.combat`][osrlib.core.combat]): + reader: The character reading the scroll, a + [`Character`][osrlib.core.character.Character]. Nothing is taken from their memorized + spells. + spell: The inscribed [`SpellTemplate`][osrlib.core.spells.SpellTemplate], from + [`SpellCatalog.get`][osrlib.core.spells.SpellCatalog.get]. + mode: Which usage of the spell, by its [`SpellMode.key`][osrlib.core.spells.SpellMode]. + reversed: True to cast the spell's reversed form. + targets: The candidate targets in your own order, per the combatant convention (see + [`osrlib.core.combat`][osrlib.core.combat]): [`Character`][osrlib.core.character.Character] or - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] objects, or - location strings. - context: The caller-asserted [`CastContext`][osrlib.core.spells.CastContext]. - ledger: The [`EffectsLedger`][osrlib.core.effects.EffectsLedger] durations ride. - clock: The [`GameClock`][osrlib.core.clock.GameClock]. - allocator: The id allocator for attached effects: an - [`IdAllocator`][osrlib.core.monsters.IdAllocator]. - registry: Live combatants by entity id — + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] objects, or location strings + for spells your game attaches to a place. + context: The [`CastContext`][osrlib.core.spells.CastContext] with what you assert about the + situation. `None` asserts nothing. + ledger: The [`EffectsLedger`][osrlib.core.effects.EffectsLedger] that ongoing effects attach + to. Pass the one your game keeps. + clock: The [`GameClock`][osrlib.core.clock.GameClock], read to stamp attached effects. + allocator: The [`IdAllocator`][osrlib.core.monsters.IdAllocator] that names each attached + effect. + registry: Every live combatant by entity id, as [`Character`][osrlib.core.character.Character] and - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] objects the - resolution may mutate. + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] objects. ruleset: The [`Ruleset`][osrlib.core.ruleset.Ruleset] in play. - stream: The magic stream (the `"magic"` [`RngStream`][osrlib.core.rng.RngStream]). - effects_stream: The effects stream, for attach-time dice. + stream: The [`RngStream`][osrlib.core.rng.RngStream] the cast's own draws come from, + conventionally [`MAGIC_STREAM`][osrlib.core.spells.MAGIC_STREAM]. + effects_stream: The stream that attaching effects draw from, conventionally + [`EFFECTS_STREAM`][osrlib.core.effects.EFFECTS_STREAM]. Returns: - The cast outcome with its events. + The [`CastResult`][osrlib.core.spells.CastResult]: what was reached, and every event, in + order. Raises: - ValueError: If the cast is invalid — validate with - [`validate_cast`][osrlib.core.spells.validate_cast] (`profile=None`) - first. + ValueError: If the read is illegal. Ask + [`validate_cast`][osrlib.core.spells.validate_cast] with `profile=None` first, which is + what tells it to skip the memorized-copy check. + + Examples: + ```python + from osrlib.core.alignment import Alignment + from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character + from osrlib.core.clock import GameClock + from osrlib.core.effects import EFFECTS_STREAM, EffectsLedger + from osrlib.core.monsters import MONSTER_SPAWN_STREAM, IdAllocator, spawn_monster + from osrlib.core.rng import RngStreams + from osrlib.core.ruleset import Ruleset + from osrlib.core.spells import MAGIC_STREAM, cast_from_scroll + from osrlib.data import load_monsters, load_spells + + rules = Ruleset() + streams = RngStreams(master_seed=5) + catalog = load_spells() + + # A 1st-level magic-user who could never memorize *fire ball* reads one off a scroll. + zelia = create_character( + name="Zelia", + class_id="magic_user", + alignment=Alignment.NEUTRAL, + ruleset=rules, + stream=streams.get(CHARACTER_CREATION_STREAM), + starting_spell_ids=["read_magic"], + ).character + template = load_monsters().get("goblin") + goblin = spawn_monster(template, id="monster-0001", stream=streams.get(MONSTER_SPAWN_STREAM)) + + outcome = cast_from_scroll( + zelia, + catalog.get("fire_ball"), + "damage", + targets=[goblin], + ledger=EffectsLedger(), + clock=GameClock(), + allocator=IdAllocator(), + registry={"monster-0001": goblin}, + ruleset=rules, + stream=streams.get(MAGIC_STREAM), + effects_stream=streams.get(EFFECTS_STREAM), + ) + assert outcome.affected_ids == ("monster-0001",) + assert goblin.current_hp == 0 # 5d6 at the scroll's caster level, and the goblin failed its save + assert zelia.memorized_spells == () # nothing was spent from memory + ``` """ context = context or CastContext() rejections = validate_cast( @@ -1352,26 +2296,57 @@ def cast_from_scroll( def disrupt_casting(caster: Any, spell_id: str, *, reversed: bool = False) -> list[Event]: - """Disrupt a declared casting: the copy is lost "as if it had been cast". + """Take away a spell a caster declared but never got to cast. - The trigger — lost initiative, then successfully attacked or failed a save - before acting — is detected by the battle machine; the kernel resolves a - disruption when told it happened. The exactly-matching copy is removed first; - failing that, any copy of the spell (a divine caster's reversed declaration - consumes a normal copy, mirroring the cast-time rule). + A caster who announces a spell and is then hit, or fails a save, before their turn comes round + loses the spell anyway, as though they had cast it. Call this when that happens. Working out + that it happened is your game's job, or the battle layer's: it is the caster losing initiative + and then being successfully attacked before they act. + + Nothing is resolved and nothing is rolled. One memorized copy goes and one event comes back. The + copy chosen is the one matching the declared form, and failing that any copy of the spell at all, + which is what lets a divine caster's declared reversal cost them a normally prepared copy. Args: - caster: The disrupted caster: a - [`Character`][osrlib.core.character.Character]; its `memorized_spells` - tuple shrinks by one copy. - spell_id: The declared spell's id — see [the spell id index][spells-index]. - reversed: Whether the declared cast was the reversed form. + caster: The caster who was interrupted, a [`Character`][osrlib.core.character.Character]. + Its `memorized_spells` loses one copy. + spell_id: The id of the spell they had declared. For the ids the shipped catalog uses, see + [the spell id index][spells-index]. + reversed: True when the declared cast was of the reversed form. Returns: - The disruption event. + A single [`SpellDisruptedEvent`][osrlib.core.events.SpellDisruptedEvent], in a list, for you + to publish alongside whatever caused the disruption. Raises: - ValueError: If no copy of the spell is memorized (programmer misuse). + ValueError: If the caster has no memorized copy of that spell. Reaching this means the + declaration was tracked wrongly, since a caster cannot declare what they never memorized. + + Examples: + ```python + from osrlib.core.alignment import Alignment + from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character + from osrlib.core.rng import RngStreams + from osrlib.core.ruleset import Ruleset + from osrlib.core.spells import MemorizedSpell, disrupt_casting, memorize_spells + from osrlib.data import load_classes, load_spells + + streams = RngStreams(master_seed=5) + definition = load_classes().get("magic_user") + zelia = create_character( + name="Zelia", + class_id="magic_user", + alignment=Alignment.NEUTRAL, + ruleset=Ruleset(), + stream=streams.get(CHARACTER_CREATION_STREAM), + starting_spell_ids=["magic_missile"], + ).character + memorize_spells(zelia, definition, load_spells(), [MemorizedSpell(spell_id="magic_missile")]) + + events = disrupt_casting(zelia, "magic_missile") + assert [event.code for event in events] == ["magic.cast.disrupted"] + assert zelia.memorized_spells == () # gone, the same as if she had cast it + ``` """ memorized = list(getattr(caster, "memorized_spells", ())) index = next( @@ -1393,7 +2368,7 @@ def _is_undead(target: Any) -> bool: def _is_person(target: Any) -> bool: - """The *hold/charm person* gate: any character, or a monster bearing the `person` category.""" + """The *hold/charm person* gate: any character, or a monster with the `person` category.""" if getattr(target, "definition", None) is not None: return True template = getattr(target, "template", None) @@ -1401,7 +2376,7 @@ def _is_person(target: Any) -> bool: def _is_arcane_caster(target: Any) -> bool: - """The *feeblemind* gate: a target whose class bears the `arcane_magic` tag.""" + """The *feeblemind* gate: a target whose class has the `arcane_magic` tag.""" definition = getattr(target, "definition", None) if definition is None: return False @@ -1415,7 +2390,7 @@ def _monster_hit_dice(target: Any) -> Any | None: def _eligible(target: Any, mode: SpellMode) -> bool: - """Resolve a mode's eligibility gates — resolution outcomes, never rejections.""" + """Resolve a mode's eligibility gates: resolution outcomes, never rejections.""" if isinstance(target, str): return True params = mode.effect.params if mode.effect is not None else {} @@ -1449,9 +2424,9 @@ def _select_cast_targets( ) -> tuple[list[object], list[Event]]: """Filter eligibility, then resolve the targeting mode over the survivors. - Eligibility filtering happens inside resolution (never as a rejection), so - ineligible candidates consume no HD budget and no group-count slot — a wight in - a *sleep* candidate list simply isn't selected. + Eligibility filtering happens inside resolution, never as a rejection, so ineligible candidates + consume no HD budget and no group-count slot. A wight in a *sleep* candidate list isn't + selected. """ targeting = mode.targeting if targeting is None: @@ -1482,11 +2457,10 @@ def _select_cast_targets( def _spell_save( target: Any, mode: SpellMode, caster: Any, stream: RngStream, *, element: str | None = None ) -> tuple[bool, list[Event]]: - """Roll a mode's saving throw; returns `(passed, events)`. + """Roll a mode's saving throw and return `(passed, events)`. - Spell saves always pass `magical=True` (the WIS modifier applies) and carry the - mode's modifier and the effect's element (energy `auto_save` defenses resolve - through the existing pipeline unchanged). + Spell saves pass `magical=True`, so the WIS modifier applies, along with the mode's modifier and + the effect's element. An energy `auto_save` defense resolves through the same pipeline. """ save = mode.save if save is None: @@ -1516,7 +2490,7 @@ def _touch_attack( def _per_level_dice(expression: str, caster_level: int) -> str: - """Scale a per-level dice expression (`1d6` per level at level 5 → `5d6`).""" + """Scale a per-level dice expression: `1d6` per level at level 5 becomes `5d6`.""" parsed = parse(expression) count = parsed.count * caster_level modifier = parsed.modifier * caster_level @@ -1529,14 +2503,13 @@ def _resolved_duration( ) -> dict[str, Any]: """Build an effect definition's duration fields from the spell and overrides. - Per-level durations are computed at cast: fixed amounts gain - `per_level × caster level`, and dice durations fold the bonus into the dice - modifier (`1d6 turns +1 per level` at level 3 attaches `1d6+3`), keeping the - rolled-at-attach behavior on the effects stream. Concentration - durations attach indefinite — concentration effects are released by the caller. - Effect-param overrides win: `permanent`, `indefinite`, or explicit - `duration_dice`/`duration_amount`/`duration_unit` (*cause disease*'s 2d12 - days). + Per-level durations are computed at cast. A fixed amount gains `per_level × caster level`, and a + dice duration folds the bonus into the dice modifier, so `1d6 turns +1 per level` at level 3 + attaches `1d6+3` and keeps its roll-at-attach behavior on the effects stream. A concentration + duration attaches indefinite, since the caller releases concentration effects. + + An effect param overrides all of that: `permanent`, `indefinite`, or an explicit + `duration_dice`, `duration_amount`, and `duration_unit` (*cause disease*'s 2d12 days). """ if params.get("permanent"): return {"permanent": True} @@ -1567,10 +2540,10 @@ def _resolved_duration( def _charm_interval_rounds(target: Any) -> int: - """The charm re-save interval by INT band: month = 30 days, week = 7. + """The charm re-save interval in rounds, by the target's INT band. - Monsters have no INT score and default to the middle weekly band - (override-correctable per monster). + The page's monthly band counts as 30 days and its weekly band as 7. A monster has no INT score + and falls to the middle, weekly band, which an override can correct per monster. """ scores = getattr(target, "scores", None) if scores is None: @@ -1587,7 +2560,7 @@ def _charm_interval_rounds(target: Any) -> int: def _effect_params(params: Mapping[str, Any]) -> dict[str, Any]: - """The params carried onto the attached effect: the per-spell data, minus consumed keys.""" + """The params copied onto the attached effect: the per-spell data, minus the consumed keys.""" consumed = { "permanent", "indefinite", @@ -1685,7 +2658,7 @@ def _resolve_effect( stream: RngStream, effects_stream: RngStream, ) -> None: - """Dispatch one automated mode's resolution — the casting interpreter.""" + """Dispatch one automated mode's resolution: the casting interpreter.""" kind = _mode_effect(mode).kind if kind == "damage": _resolve_damage(caster, spell, mode, selected, state, context, ruleset=ruleset, stream=stream, clock=clock) @@ -1998,12 +2971,12 @@ def _resolve_dispel( registry: dict[str, Any], stream: RngStream, ) -> None: - """*Dispel magic*: release dispellable effects; higher-level effects may survive. + """*Dispel magic*: release dispellable effects, though a higher-level effect may survive. - Per effect, when the recorded caster level exceeds the dispelling caster's, the - effect survives on a d100 roll at or under 5% per level of deficit (RAW: "a 5% - chance per level difference of *not* being dispelled"). Monster-inflicted - effects are non-dispellable by construction; magic items are exempt. + Per effect, when the recorded caster level exceeds the dispelling caster's, the effect survives + on a d100 roll at or under 5% per level of deficit (RAW: "a 5% chance per level difference of + *not* being dispelled"). A monster-inflicted effect is non-dispellable by construction, and + magic items are exempt. """ pct_per_level = _int_param(_mode_effect(mode).params, "survival_pct_per_level", 5) released: list[str] = [] @@ -2046,13 +3019,13 @@ def _resolve_restore_life( ) -> None: """*Raise dead*'s restore-life usage. - Restores a dead human or demihuman — any `Character` qualifies (all four Classic - races), never a monster — dead no longer than 4 days × (caster level − 7), which - is 0 days at level 7 (RAW-faithful). Revival sets 1 hp, removes `dead`, and - attaches the weakness effect: cannot attack or cast, half movement, fixed at 14 - elapsed days as a simplification of RAW's "two full weeks of bed rest" (rest - tracking is crawl procedure; games wanting strict bed-rest semantics extend or - release via the ledger). Magical healing doesn't shorten it, per the page. + Restores a dead human or demihuman, which means any `Character`, all four Classic races + included, and never a monster. The subject must have been dead no longer than 4 days × (caster + level − 7), which is 0 days at level 7, following RAW. Revival sets 1 hp, removes `dead`, and + attaches the weakness effect: cannot attack or cast, half movement, fixed at 14 elapsed days as + a simplification of RAW's "two full weeks of bed rest". Rest tracking is crawl procedure, so a + game wanting strict bed-rest semantics extends or releases the effect through the ledger. + Magical healing doesn't shorten it, per the page. """ params = _mode_effect(mode).params target = selected[0] if selected else None @@ -2094,24 +3067,91 @@ def _resolve_restore_life( def pop_mirror_image( ledger: EffectsLedger, target_ref: str, *, registry: dict[str, Any], clock: GameClock ) -> list[Event]: - """Destroy one mirror image — called by the game or battle machine per incoming attack. + """Destroy one of a caster's mirror images. + + *Mirror image* surrounds its caster with illusory duplicates, and an attack on the caster + destroys one of them whether or not the attack lands. Nothing in this module notices attacks, so + call this once for every attack aimed at a caster who has the spell running, before or after you + resolve the attack itself. - "Attacks on the caster destroy one of the mirror images (even if the attack - misses)." When the last image pops, the effect is released. + It is safe to call on anyone. A target with no mirror images active returns nothing, so you do + not have to check first. When the last image goes, the effect is released from the ledger and + that release's own events come back with the pop. Args: - ledger: The [`EffectsLedger`][osrlib.core.effects.EffectsLedger]. - target_ref: The mirrored caster's entity id. - registry: Live combatants by entity id — + ledger: The [`EffectsLedger`][osrlib.core.effects.EffectsLedger] the images live on, the one + the cast attached them to. + target_ref: The entity id of the caster being attacked. + registry: Every live combatant by entity id, as [`Character`][osrlib.core.character.Character] and - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] objects the - release may mutate. - clock: The [`GameClock`][osrlib.core.clock.GameClock], stamped on the - bookkeeping event. + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] objects. Read when the last + image goes and the effect lifts. + clock: The [`GameClock`][osrlib.core.clock.GameClock], read to stamp the event with the + current round. Returns: - The pop (and final release) events; empty when no mirror-image effect is - active. + An [`EffectTickedEvent`][osrlib.core.events.EffectTickedEvent] for the image destroyed, plus + the release events when that was the last one. Empty when the target has no images. + + Examples: + ```python + from osrlib.core.alignment import Alignment + from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character + from osrlib.core.clock import GameClock + from osrlib.core.effects import EFFECTS_STREAM, EffectsLedger + from osrlib.core.monsters import IdAllocator + from osrlib.core.rng import RngStreams + from osrlib.core.ruleset import Ruleset + from osrlib.core.spells import ( + MAGIC_STREAM, + MemorizedSpell, + cast_spell, + caster_profile, + memorize_spells, + pop_mirror_image, + ) + from osrlib.data import load_classes, load_spells + + rules = Ruleset() + streams = RngStreams(master_seed=5) + catalog = load_spells() + definition = load_classes().get("magic_user") + zelia = create_character( + name="Zelia", + class_id="magic_user", + alignment=Alignment.NEUTRAL, + ruleset=rules, + stream=streams.get(CHARACTER_CREATION_STREAM), + starting_spell_ids=["magic_missile"], + ).character + zelia.id = "pc-1" + zelia.level = 3 + zelia.spell_book = ("magic_missile", "mirror_image") + memorize_spells(zelia, definition, catalog, [MemorizedSpell(spell_id="mirror_image")]) + + ledger = EffectsLedger() + clock = GameClock() + cast_spell( + zelia, + catalog.get("mirror_image"), + "images", + profile=caster_profile(definition), + ledger=ledger, + clock=clock, + allocator=IdAllocator(), + registry={"pc-1": zelia}, + ruleset=rules, + stream=streams.get(MAGIC_STREAM), + effects_stream=streams.get(EFFECTS_STREAM), + ) + effect = ledger.active_on("pc-1", "mirror_image")[0] + assert effect.state["images"] == 2 # 1d4 images, stable under this seed + + popped = pop_mirror_image(ledger, "pc-1", registry={"pc-1": zelia}, clock=clock) + assert [event.code for event in popped] == ["effects.effect.ticked"] + assert effect.state["images"] == 1 + assert pop_mirror_image(ledger, "pc-2", registry={"pc-1": zelia}, clock=clock) == [] + ``` """ effects = ledger.active_on(target_ref, "mirror_image") if not effects: @@ -2127,7 +3167,30 @@ def pop_mirror_image( class TurnUndeadResult(BaseModel): - """A turning attempt's outcome: the rolls, per-type verdicts, and who was affected.""" + """What came of a turning attempt: the dice, the verdict on each kind of undead, and who fled. + + You get one back from [`turn_undead`][osrlib.core.spells.turn_undead]. The attempt always + happened, so there is no accepted flag here. Read `outcomes` to explain the result and + `affected_ids` to know who to move. + + Attributes: + roll: The 2d6 the cleric rolled to turn. Compared against the table threshold for each kind + of undead present, so one roll can turn some kinds and fail against others. + hd_pool: The second 2d6, rolled only when at least one kind was turned, giving the Hit Dice + worth of undead the attempt can affect. `None` when nothing was turned and no second + roll was made. + outcomes: One [`TurningTypeOutcome`][osrlib.core.events.TurningTypeOutcome] per kind of + monster among the candidates, in the order the kinds first appeared. Its `outcome` is + `turn` for a kind that flees, `destroy` for one annihilated outright, `fail` for one that + held, and `unaffected` for a candidate that was not undead at all. + affected_ids: The entity ids of the individual monsters the attempt actually reached, which + is as many as `hd_pool` paid for. + destroyed_ids: The entity ids of those among them that were destroyed rather than turned. + They are dead permanently, past the reach of *raise dead*. + events: The [`UndeadTurnedEvent`][osrlib.core.events.UndeadTurnedEvent] and then the + consequences: a death for each monster destroyed, an attached `turned` condition for + each one that fled. Publish these. + """ model_config = ConfigDict(frozen=True) @@ -2140,21 +3203,56 @@ class TurnUndeadResult(BaseModel): def validate_turn_undead(cleric: Any, definition: ClassDefinition) -> list[Rejection]: - """Validate a turning attempt — the pure pre-phase. + """Ask whether a character may attempt to turn undead, without rolling. + + Call this to decide whether to offer turning as an action at all. Then call + [`turn_undead`][osrlib.core.spells.turn_undead], which runs the same checks and raises if any + fail. Nothing here rolls dice or changes anything. - Turning is gated by the `turn_undead` class-ability tag; an incapacitated cleric - (dead, petrified, paralysed, asleep) cannot present the symbol, and a `weakened` - one cannot turn — the raise-dead weakness bans class abilities ("cannot attack, - cast spells, or use other class abilities"). The holy symbol itself is *not* a - precondition: the OSE SRD states carrying one as a class edict, not a mechanical - gate; games wanting the stricter reading check inventory themselves. + Two things can stop an attempt. The character's class may not turn undead at all, which it does + only if it has the `turn_undead` ability tag, so a class you author gains the ability by adding + that tag. Or the character may be in no state to present a holy symbol: dead, petrified, + paralysed, or asleep, or weakened, which is the state *raise dead* leaves someone in and which + bars class abilities outright. + + Whether the character is actually carrying a holy symbol is not checked. The SRD tells clerics + to carry one as a matter of their class rather than as a condition on the procedure, so a game + that wants the stricter reading checks inventory itself. Args: - cleric: The turning character: a [`Character`][osrlib.core.character.Character]. - definition: The character's [`ClassDefinition`][osrlib.core.classes.ClassDefinition]. + cleric: The character attempting the turning, a + [`Character`][osrlib.core.character.Character]. Read, never written. + definition: Their class, as a [`ClassDefinition`][osrlib.core.classes.ClassDefinition] from + [`load_classes`][osrlib.data.load_classes]. Returns: - Structured rejections; empty when the attempt may be rolled. + Why the attempt cannot be made, as [`Rejection`][osrlib.core.validation.Rejection] models + with structured `code` and `params`. Empty when the attempt may go ahead. At most one: the + first problem found ends the call. + + Examples: + ```python + from osrlib.core.alignment import Alignment + from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character + from osrlib.core.rng import RngStreams + from osrlib.core.ruleset import Ruleset + from osrlib.core.spells import validate_turn_undead + from osrlib.data import load_classes + + streams = RngStreams(master_seed=5) + classes = load_classes() + aldis = create_character( + name="Aldis", + class_id="cleric", + alignment=Alignment.LAWFUL, + ruleset=Ruleset(), + stream=streams.get(CHARACTER_CREATION_STREAM), + ).character + + assert validate_turn_undead(aldis, classes.get("cleric")) == [] + refused = validate_turn_undead(aldis, classes.get("fighter")) + assert [rejection.code for rejection in refused] == ["magic.turning.not_a_turner"] + ``` """ if not any(ability.tag == "turn_undead" for ability in getattr(definition, "abilities", ())): return [Rejection(code="magic.turning.not_a_turner", params={"class": definition.id})] @@ -2181,49 +3279,102 @@ def turn_undead( registry: dict[str, Any], stream: RngStream, ) -> TurnUndeadResult: - """Turn undead — the full procedure, one call. - - One 2d6 turn roll (the magic stream) is compared per candidate *type* against - that type's turning-table cell — `—` types are unaffected, number types succeed - when the roll meets the threshold, `T`/`D` succeed automatically. If any type - succeeded, one 2d6 HD-pool roll follows; eligible monsters are affected - lowest-HD-first (stable input order on ties), each costing its HD count - (minimum 1, fixed bonuses dropped — the *sleep* convention); the pool stops at - the first unaffordable monster (RAW: excess Hit Dice "are wasted", not - reallocated); at least one undead is always affected on a successful turn even - when the pool rolls short (RAW minimum effect), resolved as the cheapest - eligible monster. Affected monsters whose column says `D` die permanently - ("instantly and permanently annihilated"); the rest gain the `turned` condition - via an indefinite, non-dispellable effect the encounter releases (flee behavior - is the battle machine's). - - Only monsters bearing the `undead` category are candidates; non-undead in the - list resolve as unaffected rather than rejecting, so a turning attempt never - doubles as a free undead detector. + """Drive off or destroy undead with a cleric's holy symbol, the whole procedure in one call. + + Turning is not a spell and costs no slot, so nothing here touches the caster's memorized list. + Ask [`validate_turn_undead`][osrlib.core.spells.validate_turn_undead] first, because an attempt + the character cannot make raises rather than returning a refusal. Afterwards, publish the + result's events and read `affected_ids` to move the undead that fled: what fleeing looks like on + your map is your game's business, and all that happens here is that those monsters gain the + `turned` condition. + + The procedure runs in two rolls. First one 2d6 is compared against the turning table, once for + each kind of monster among the candidates rather than once per monster, since a kind either + turns or it does not. Some kinds turn automatically, some are destroyed outright, some are + beyond the cleric's power at their level. + + If any kind turned, a second 2d6 gives a pool of Hit Dice, and the individual monsters are + affected cheapest first until the pool cannot pay for the next one. Ties keep the order you + passed them in. The remainder of the pool is wasted rather than spent on something else, and a + successful turn always reaches at least one undead even when the pool rolls short. + + Monsters of a kind marked for destruction die permanently, and *raise dead* cannot bring them + back. The rest gain the `turned` condition through an effect that does not expire on its own and + cannot be dispelled, so release it from the ledger when the encounter ends. + + Pass any monsters you like as candidates. A candidate that is not undead resolves as unaffected + rather than rejecting the attempt, which keeps a turning attempt from doubling as a free way to + find out what is undead. Args: - cleric: The turning character: a [`Character`][osrlib.core.character.Character]. - definition: The character's [`ClassDefinition`][osrlib.core.classes.ClassDefinition] - (the `turn_undead` tag). - candidates: The encounter's monsters, in stable order: - [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] objects. - ledger: The [`EffectsLedger`][osrlib.core.effects.EffectsLedger] the - `turned` condition attaches through. - clock: The [`GameClock`][osrlib.core.clock.GameClock]. - allocator: The id allocator for the attached effect: an - [`IdAllocator`][osrlib.core.monsters.IdAllocator]. - registry: Live combatants by entity id — + cleric: The character turning, a [`Character`][osrlib.core.character.Character]. + definition: Their class, as a [`ClassDefinition`][osrlib.core.classes.ClassDefinition] from + [`load_classes`][osrlib.data.load_classes]. It must have the `turn_undead` tag. + candidates: The monsters present, as + [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] objects, in a stable order. + Order decides ties when the Hit Dice pool runs out, so pass the same order every time if + you want the same result on a replay. + ledger: The [`EffectsLedger`][osrlib.core.effects.EffectsLedger] the `turned` condition + attaches to. Pass the one your game keeps. + clock: The [`GameClock`][osrlib.core.clock.GameClock], read to stamp the attached effects. + allocator: The [`IdAllocator`][osrlib.core.monsters.IdAllocator] that names each attached + effect. + registry: Every live combatant by entity id, as [`Character`][osrlib.core.character.Character] and [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] objects. - stream: The magic stream — the player rolls turning dice in B/X, so both - rolls are player-visible on the event. + stream: The [`RngStream`][osrlib.core.rng.RngStream] both 2d6 rolls come from, + conventionally [`MAGIC_STREAM`][osrlib.core.spells.MAGIC_STREAM]. Both rolls are on the + player-visible event, because in B/X the player rolls them. Returns: - The turning outcome with its events. + The [`TurnUndeadResult`][osrlib.core.spells.TurnUndeadResult]: the dice, the verdict per + kind, who was reached, and every event. Raises: - ValueError: If the character cannot turn — validate with + ValueError: If the character cannot turn undead at all. Ask [`validate_turn_undead`][osrlib.core.spells.validate_turn_undead] first. + + Examples: + ```python + from osrlib.core.alignment import Alignment + from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character + from osrlib.core.clock import GameClock + from osrlib.core.effects import Condition, EffectsLedger, has_condition + from osrlib.core.monsters import MONSTER_SPAWN_STREAM, IdAllocator, spawn_monster + from osrlib.core.rng import RngStreams + from osrlib.core.ruleset import Ruleset + from osrlib.core.spells import MAGIC_STREAM, turn_undead + from osrlib.data import load_classes, load_monsters + + streams = RngStreams(master_seed=12) + definition = load_classes().get("cleric") + aldis = create_character( + name="Aldis", + class_id="cleric", + alignment=Alignment.LAWFUL, + ruleset=Ruleset(), + stream=streams.get(CHARACTER_CREATION_STREAM), + ).character + + template = load_monsters().get("skeleton") + spawn = streams.get(MONSTER_SPAWN_STREAM) + skeletons = [spawn_monster(template, id=f"monster-000{n}", stream=spawn) for n in (1, 2, 3)] + result = turn_undead( + aldis, + definition, + skeletons, + ledger=EffectsLedger(), + clock=GameClock(), + allocator=IdAllocator(), + registry={monster.id: monster for monster in skeletons}, + stream=streams.get(MAGIC_STREAM), + ) + assert (result.roll, result.hd_pool) == (7, 4) # met the threshold, then 4 Hit Dice of effect + assert [outcome.outcome for outcome in result.outcomes] == ["turn"] + assert result.affected_ids == ("monster-0001", "monster-0002", "monster-0003") + assert result.destroyed_ids == () # turned, not destroyed + assert all(has_condition(monster, Condition.TURNED) for monster in skeletons) + ``` """ rejections = validate_turn_undead(cleric, definition) if rejections: From ee5944d4baed1024590e5df2b1fe1c737466d34d Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Sun, 13 Sep 2026 21:40:33 -0700 Subject: [PATCH 2/3] Address review of the core spells docstrings Move every model's field prose out of its Attributes: section and into a PEP 224 attribute docstring, so the rendered field rows carry the prose instead of showing bare names under a table. Correct four claims against the code: cast_from_scroll now says which steps run at the scroll's caster level and which at the reader's own, SpellTemplate.intro says the mode's prose is the full text rather than a repeat, _resolve_dispel keeps only what the dispellable flag supports, and TurnUndeadResult.hd_pool says the second roll happens on a destroy as well as a turn. Drop the numeric count of dual-page spells, say the catalog's id order is a property of the shipped data, and reflow one broken sentence. Claude-Session: https://claude.ai/code/session_01GL26QnA6dCrvUc3WmhzFSa --- src/osrlib/core/spells.py | 694 ++++++++++++++++++++++++-------------- 1 file changed, 436 insertions(+), 258 deletions(-) diff --git a/src/osrlib/core/spells.py b/src/osrlib/core/spells.py index b4d2696..4bfe210 100644 --- a/src/osrlib/core/spells.py +++ b/src/osrlib/core/spells.py @@ -45,9 +45,9 @@ a game attaches to places rather than to creatures. A reversible spell's reverse is entry data, not a separate catalog entry: it lives on its entry as -a [`ReversedForm`][osrlib.core.spells.ReversedForm]. The nine concepts the SRD prints as separate -cleric and magic-user pages are the exception, compiling as two entries with `_c` and `_mu` id -suffixes, because those pairs differ mechanically. +a [`ReversedForm`][osrlib.core.spells.ReversedForm]. The exception is a spell the SRD prints twice, +once as a cleric page and once as a magic-user page, where the two differ mechanically. Each such +pair compiles as two entries, the cleric one suffixed `_c` and the magic-user one `_mu`. Every draw inside spell resolution comes from the [`MAGIC_STREAM`][osrlib.core.spells.MAGIC_STREAM] stream: targeting dice, damage dice, touch-attack @@ -247,27 +247,6 @@ class DurationSpec(BaseModel): anything you show a player: a duration the parser cannot make structure out of lands here as `kind="special"` with nothing else filled in, because the parser never fails on prose. - Attributes: - kind: Which of the five shapes this duration has. `instant` resolves and is over, - `permanent` never ends, `concentration` lasts while the caster concentrates and is - released by whoever is running the game, `fixed` is a length you can count in `unit`, - and `special` means the printed line was prose the parser left alone. - unit: The time unit a `fixed` duration counts in, as a - [`TimeUnit`][osrlib.core.clock.TimeUnit]: rounds, turns, hours, days. `None` on every - other kind. - amount: How many `unit` a `fixed` duration lasts, before the per-level bonus. `None` when - the length is rolled (`dice`) or is purely per-level. - dice: A dice expression rolled when the effect attaches, in place of a flat `amount`, such - as `"1d6"` for *confusion*. The roll happens on the effects stream, not the magic - stream. - per_level: Extra `unit` per caster level, added to `amount` or folded into the `dice` - modifier at cast time. *Light* prints `6 turns +1 per level`, so amount 6 and per_level 1. - A spell printed `1 turn per level` is amount `None` and per_level 1. - concentration_cap_unit: The unit of the outer limit on a `concentration` duration, when the - page prints one (`Concentration (up to 1 day)`). `None` when concentration is - open-ended. - concentration_cap_amount: How many `concentration_cap_unit` that limit runs to. - Examples: ```python from osrlib.core.clock import TimeUnit @@ -286,12 +265,47 @@ class DurationSpec(BaseModel): model_config = ConfigDict(frozen=True) kind: Literal["instant", "permanent", "concentration", "fixed", "special"] + """Which of the five shapes this duration has. + + `instant` resolves and is over, `permanent` never ends, `concentration` lasts while the caster + concentrates and is released by whoever is running the game, `fixed` is a length you can count + in `unit`, and `special` means the printed line was prose the parser left alone. + """ + unit: TimeUnit | None = None + """The time unit a `fixed` duration counts in, as a [`TimeUnit`][osrlib.core.clock.TimeUnit]. + + Rounds, turns, hours, or days. `None` on every other kind. + """ + amount: int | None = None + """How many `unit` a `fixed` duration lasts, before the per-level bonus. + + `None` when the length is rolled (`dice`) or is purely per-level. + """ + dice: str | None = None + """A dice expression rolled when the effect attaches, in place of a flat `amount`. + + *Confusion* uses `"1d6"`. The roll happens on the effects stream, not the magic stream. + """ + per_level: int = 0 + """Extra `unit` per caster level, added to `amount` or folded into the `dice` modifier at cast. + + *Light* prints `6 turns +1 per level`, so amount 6 and per_level 1. A spell printed `1 turn per + level` is amount `None` and per_level 1. + """ + concentration_cap_unit: TimeUnit | None = None + """The unit of the outer limit on a `concentration` duration, when the page prints one. + + A page reading `Concentration (up to 1 day)` sets this to days. `None` when concentration is + open-ended. + """ + concentration_cap_amount: int | None = None + """How many `concentration_cap_unit` the limit on a `concentration` duration runs to.""" @field_validator("dice") @classmethod @@ -314,8 +328,8 @@ class RangeSpec(BaseModel): """How far a spell reaches, parsed out of the printed range line. You read one off [`SpellTemplate.range_spec`][osrlib.core.spells.SpellTemplate], and you never - build one during play. [`validate_cast`][osrlib.core.spells.validate_cast] reads it for you, but only - when you tell it how far away the target is through + build one during play. [`validate_cast`][osrlib.core.spells.validate_cast] reads it for you, but + only when you tell it how far away the target is through [`CastContext.distance_feet`][osrlib.core.spells.CastContext]. osrlib has no map of its own, so with no distance asserted there is no range check. Read this model yourself when you draw a range indicator, filter a spell list by reach, or decide which targets to offer. @@ -324,19 +338,6 @@ class RangeSpec(BaseModel): Ranges the parser cannot make structure out of, such as the presence forms, land as `kind="special"` with no distance. - Attributes: - kind: Which shape the range has. `caster` affects the caster alone, `touch` reaches one - creature in reach and allows the caster to be that creature, `feet` and `yards` are - fixed distances, `per_level` grows with caster level, and `special` means the printed - line was prose the parser left alone. - feet: The distance in feet, for the `feet`, `yards`, and `per_level` kinds. Yards are - converted, so a range printed as 240 yards is 720 here. On a `per_level` range this is - the base before the per-level bonus, and it is `None` when the printed range is purely - per level. `None` on the other kinds. - per_level_feet: Extra feet of reach per caster level on a `per_level` range. A range printed - `60' +10' per level` is `feet` 60 and `per_level_feet` 10, so a 5th-level caster reaches - 110 feet. - Examples: ```python from osrlib.data import load_spells @@ -352,8 +353,27 @@ class RangeSpec(BaseModel): model_config = ConfigDict(frozen=True) kind: Literal["caster", "touch", "feet", "yards", "per_level", "special"] + """Which shape the range has. + + `caster` affects the caster alone, `touch` reaches one creature in reach and allows the caster + to be that creature, `feet` and `yards` are fixed distances, `per_level` grows with caster + level, and `special` means the printed line was prose the parser left alone. + """ + feet: int | None = None + """The distance in feet, for the `feet`, `yards`, and `per_level` kinds. + + Yards are converted, so a range printed as 240 yards is 720 here. On a `per_level` range this is + the base before the per-level bonus, and it is `None` when the printed range is purely per + level. `None` on the other kinds. + """ + per_level_feet: int | None = None + """Extra feet of reach per caster level on a `per_level` range. + + A range printed `60' +10' per level` is `feet` 60 and `per_level_feet` 10, so a 5th-level caster + reaches 110 feet. + """ class TargetingSpec(BaseModel): @@ -374,26 +394,6 @@ class TargetingSpec(BaseModel): [`CastResult`][osrlib.core.spells.CastResult] with `no_effect` set, and the memorized copy is still spent. - Attributes: - mode: The targeting mode: `self` takes no targets, `single` takes exactly one, `up_to_n` a - bounded group, `hd_budget` as many creatures as a rolled pool of Hit Dice pays for, - `area` everything you supply as covered by the shape, and `gaze` the gaze-attack form. - count: The fixed size of an `up_to_n` group, when the page prints a number rather than dice. - count_dice: The dice rolled at cast time to size an `up_to_n` group. *Hold person*'s group - mode is `"1d4"`, *charm monster*'s is `"3d6"`. Rolled on the magic stream. - hd_budget_dice: The dice rolled to size a `hd_budget` pool. *Sleep*'s is `"2d8"`. Creatures - are affected cheapest first until the pool cannot pay for the next one, and the - remainder is wasted rather than spent elsewhere. - hd_cap: The most Hit Dice a creature may have and still be eligible. *Sleep*'s group mode - caps at 4, *charm monster*'s at 3. - hd_min: The fewest Hit Dice a creature must have to be eligible. *Charm monster*'s - single-target mode sets 4, which is the page's "more than 3 Hit Dice". - shape: The name of the area an `area` mode covers, such as `"sphere"`. `None` on every other - mode. - dimensions: The area's measurements in feet, keyed by name: *fire ball*'s sphere is - `{"radius_feet": 20}`. Which creatures stand inside it is your game's question, not - osrlib's. You decide who is caught and pass them as candidates. - Examples: ```python from osrlib.core.combat import TargetingMode @@ -412,13 +412,51 @@ class TargetingSpec(BaseModel): model_config = ConfigDict(frozen=True) mode: TargetingMode + """Which targeting mode the usage takes. + + `self` takes no targets, `single` takes exactly one, `up_to_n` a bounded group, `hd_budget` as + many creatures as a rolled pool of Hit Dice pays for, `area` everything you supply as covered by + the shape, and `gaze` the gaze-attack form. + """ + count: int | None = None + """The fixed size of an `up_to_n` group, when the page prints a number rather than dice.""" + count_dice: str | None = None + """The dice rolled at cast time to size an `up_to_n` group. + + *Hold person*'s group mode is `"1d4"` and *charm monster*'s is `"3d6"`. Rolled on the magic + stream. + """ + hd_budget_dice: str | None = None + """The dice rolled to size a `hd_budget` pool, which is `"2d8"` for *sleep*. + + Creatures are affected cheapest first until the pool cannot pay for the next one, and the + remainder is wasted rather than spent elsewhere. + """ + hd_cap: int | None = None + """The most Hit Dice a creature may have and still be eligible. + + *Sleep*'s group mode caps at 4 and *charm monster*'s at 3. + """ + hd_min: int | None = None + """The fewest Hit Dice a creature must have to be eligible. + + *Charm monster*'s single-target mode sets 4, which is the page's "more than 3 Hit Dice". + """ + shape: str | None = None + """The name of the area an `area` mode covers, such as `"sphere"`. `None` on every other mode.""" + dimensions: dict[str, int] = {} + """The area's measurements in feet, keyed by name: *fire ball*'s sphere is `{"radius_feet": 20}`. + + Which creatures stand inside it is your game's question, not osrlib's. You decide who is caught + and pass them as candidates. + """ @field_validator("count_dice", "hd_budget_dice") @classmethod @@ -439,14 +477,6 @@ class SaveSpec(BaseModel): Spell saves are always rolled as magical, so a target's wisdom adjustment applies. A target immune to the spell's element passes without a roll, through the same save pipeline. - Attributes: - category: Which column of the saving-throw table the target rolls on, as a - [`SaveCategory`][osrlib.core.combat.SaveCategory]. - modifier: The adjustment applied to the target's roll, negative against the target. - *Hold person*'s single-target mode is −2 and *feeblemind* is −4. - on_save: What a passed save buys. `negates` means the target takes nothing at all. `half` - means the target still takes half the damage, rounded down. - Examples: ```python from osrlib.core.combat import SaveCategory @@ -463,8 +493,23 @@ class SaveSpec(BaseModel): model_config = ConfigDict(frozen=True) category: SaveCategory + """Which column of the saving-throw table the target rolls on. + + A [`SaveCategory`][osrlib.core.combat.SaveCategory]. + """ + modifier: int = 0 + """The adjustment applied to the target's roll, negative against the target. + + *Hold person*'s single-target mode is −2 and *feeblemind* is −4. + """ + on_save: Literal["negates", "half"] = "negates" + """What a passed save buys. + + `negates` means the target takes nothing at all. `half` means the target still takes half the + damage, rounded down. + """ class SpellEffect(BaseModel): @@ -479,23 +524,6 @@ class SpellEffect(BaseModel): automated mode has one, and its `kind` is validated against [`EFFECT_KINDS`][osrlib.core.spells.EFFECT_KINDS] when the catalog loads. - Attributes: - kind: Which resolution behavior runs, one of - [`EFFECT_KINDS`][osrlib.core.spells.EFFECT_KINDS]. - condition: The [`Condition`][osrlib.core.effects.Condition] a `condition` effect attaches to - each affected target, such as blindness or charm. `None` on every other kind. - cures_conditions: The conditions a `cure` effect lifts. *Cure light wounds*' second usage - lifts paralysis. - cures_effect_kinds: The effect kinds a `cure` effect releases from the ledger by name, for - spells that cancel a named magic rather than a condition: *light*'s third usage releases - `"darkness"`. - modifiers: The [`ModifierSpec`][osrlib.core.effects.ModifierSpec] bundle a `modifiers` - effect grants, such as a bonus to armour class or to saves. They ride the attached - effect and lift when it ends. - params: The per-spell numbers the `kind` reads: damage dice, per-level scaling, eligibility - gates, revival windows, area radii. The keys differ by spell and by kind, so read them - against the mode you are looking at rather than expecting a fixed shape. - Examples: ```python from osrlib.core.effects import Condition @@ -514,11 +542,38 @@ class SpellEffect(BaseModel): model_config = ConfigDict(frozen=True) kind: str + """Which resolution behavior runs, one of [`EFFECT_KINDS`][osrlib.core.spells.EFFECT_KINDS].""" + condition: Condition | None = None + """The [`Condition`][osrlib.core.effects.Condition] a `condition` effect attaches to each target. + + Blindness, charm, and the rest. `None` on every other kind. + """ + cures_conditions: tuple[Condition, ...] = () + """The conditions a `cure` effect lifts. *Cure light wounds*' second usage lifts paralysis.""" + cures_effect_kinds: tuple[str, ...] = () + """The effect kinds a `cure` effect releases from the ledger by name. + + This is for spells that cancel a named magic rather than a condition: *light*'s third usage + releases `"darkness"`. + """ + modifiers: tuple[ModifierSpec, ...] = () + """The [`ModifierSpec`][osrlib.core.effects.ModifierSpec] bundle a `modifiers` effect grants. + + A bonus to armour class or to saves, for example. They ride the attached effect and lift when it + ends. + """ + params: dict[str, int | str | bool | tuple[int | str, ...]] = {} + """The per-spell numbers the `kind` reads. + + Damage dice, per-level scaling, eligibility gates, revival windows, area radii. The keys differ + by spell and by kind, so read them against the mode you are looking at rather than expecting a + fixed shape. + """ @field_validator("kind") @classmethod @@ -546,21 +601,6 @@ class SpellMode(BaseModel): is spent and the event is emitted with the manual marker and the mode's `prose`, and your game or narrator resolves what happens. Check `manual` before you promise a player an outcome. - Attributes: - key: The mode's name, snake_case and unique within its form. This is what you pass as `mode` - to [`cast_spell`][osrlib.core.spells.cast_spell] and - [`validate_cast`][osrlib.core.spells.validate_cast]. A spell with a single usage still - has one, such as *fire ball*'s `"damage"`. - targeting: Who the mode can hit and how many, as a - [`TargetingSpec`][osrlib.core.spells.TargetingSpec]. `None` only on manual modes. - save: The saving throw the targets get, as a [`SaveSpec`][osrlib.core.spells.SaveSpec], or - `None` when the mode allows none. - effect: What the mode does, as a [`SpellEffect`][osrlib.core.spells.SpellEffect]. `None` - only on manual modes. - manual: True when osrlib does the bookkeeping and leaves the outcome to your game. - prose: The SRD text for this usage. Show it to the player, and for a manual mode it is all - osrlib can tell you about the result. - Examples: ```python from osrlib.data import load_spells @@ -578,11 +618,39 @@ class SpellMode(BaseModel): model_config = ConfigDict(frozen=True) key: str = Field(min_length=1) + """The mode's name, snake_case and unique within its form. + + This is what you pass as `mode` to [`cast_spell`][osrlib.core.spells.cast_spell] and + [`validate_cast`][osrlib.core.spells.validate_cast]. A spell with a single usage still has one, + such as *fire ball*'s `"damage"`. + """ + targeting: TargetingSpec | None = None + """Who the mode can hit and how many, as a [`TargetingSpec`][osrlib.core.spells.TargetingSpec]. + + `None` only on manual modes. + """ + save: SaveSpec | None = None + """The saving throw the targets get, as a [`SaveSpec`][osrlib.core.spells.SaveSpec]. + + `None` when the mode allows none. + """ + effect: SpellEffect | None = None + """What the mode does, as a [`SpellEffect`][osrlib.core.spells.SpellEffect]. + + `None` only on manual modes. + """ + manual: bool = False + """True when osrlib does the bookkeeping and leaves the outcome to your game.""" + prose: str = "" + """The SRD text for this usage. + + Show it to the player. For a manual mode it is all osrlib can tell you about the result. + """ @model_validator(mode="after") def _automated_modes_carry_structure(self) -> SpellMode: @@ -607,19 +675,6 @@ class ReversedForm(BaseModel): and decides at the moment of casting, by speaking the words backwards, so any memorized copy will serve either way. - Attributes: - name: The reverse's own name, as the SRD prints it, such as `"Cause Light Wounds"`. Show - this rather than the entry's `name` when a cast is reversed. - prose: The SRD text for the reversed version. - modes: One [`SpellMode`][osrlib.core.spells.SpellMode] per castable usage of the reverse, - with its own keys. At least one. - duration: The reverse's printed duration, when the page prints a different one, else `None` - and the normal form's duration applies. - duration_spec: The parsed form of `duration`, as a - [`DurationSpec`][osrlib.core.spells.DurationSpec]. `None` means the reverse lasts as - long as the normal form, which is the common case. A page that prints a dual line such - as `Instant / Permanent` splits it across the two forms. - Examples: ```python from osrlib.data import load_spells @@ -635,10 +690,32 @@ class ReversedForm(BaseModel): model_config = ConfigDict(frozen=True) name: str = Field(min_length=1) + """The reverse's own name, as the SRD prints it, such as `"Cause Light Wounds"`. + + Show this rather than the entry's `name` when a cast is reversed. + """ + prose: str = "" + """The SRD text for the reversed version.""" + modes: tuple[SpellMode, ...] = Field(min_length=1) + """One [`SpellMode`][osrlib.core.spells.SpellMode] per castable usage of the reverse. + + Each has its own key, and there is at least one. + """ + duration: str | None = None + """The reverse's printed duration, when the page prints a different one. + + `None` means the normal form's duration applies. + """ + duration_spec: DurationSpec | None = None + """The parsed form of `duration`, as a [`DurationSpec`][osrlib.core.spells.DurationSpec]. + + `None` means the reverse lasts as long as the normal form, which is the common case. A page that + prints a dual line such as `Instant / Permanent` splits it across the two forms. + """ class SpellTemplate(BaseModel): @@ -656,43 +733,6 @@ class SpellTemplate(BaseModel): [`MemorizedSpell`][osrlib.core.spells.MemorizedSpell] copies a caster has prepared and the effects a cast leaves on the ledger, and both name a template by id rather than containing one. - Attributes: - id: The stable id you look the spell up by, slugified from its name: `"fire_ball"`, - `"cure_light_wounds"`. Nine concepts the SRD prints as a cleric page and a magic-user - page, which differ mechanically, take `_c` and `_mu` suffixes: `"light_c"` and - `"light_mu"`. For the ids the shipped catalog uses, see - [the spell id index][spells-index]. - name: The spell's printed name, such as `"Cure Light Wounds"`. Show this, not the id. - spell_list: Which class list the spell belongs to. The shipped catalog has `"cleric"` and - `"magic_user"`, and further lists are additive data. It must match the - [`CasterProfile.spell_list`][osrlib.core.spells.CasterProfile] of any caster who - memorizes or learns the spell. - level: The spell's level, 1 to 6. This is what the caster's slots are counted by, not the - caster's own level. - duration: The duration line as printed. Show this to a player. - duration_spec: The parsed form of `duration`, as a - [`DurationSpec`][osrlib.core.spells.DurationSpec]. Casting reads it to set the length of - what it attaches. - range: The range line as printed. - range_spec: The parsed form of `range`, as a [`RangeSpec`][osrlib.core.spells.RangeSpec]. - reversed_form: The spell's reverse, as a - [`ReversedForm`][osrlib.core.spells.ReversedForm], or `None` when it does not reverse. - modes: One [`SpellMode`][osrlib.core.spells.SpellMode] per numbered usage on the page, in - the page's order. At least one. Their keys are the `mode` argument casting takes. - intro: The page's opening text, above the numbered usages. On a multi-usage page it is the - lead-in, such as `"This spell has two usages:"`. On a single-usage page it is the - spell's own description, which the one mode's `prose` repeats. - conjured_monsters: Full monster stat blocks printed on the spell's own page rather than in - the monster catalog, as [`MonsterTemplate`][osrlib.core.monsters.MonsterTemplate] - models: *sticks to snakes* brings its own snake. Spawn them with - [`spawn_monster`][osrlib.core.monsters.spawn_monster] when you resolve the spell. - conjured_monster_ids: Ids of monsters the spell summons that already exist in the monster - catalog, for [`load_monsters`][osrlib.data.load_monsters] to look up. *Conjure elemental* - names its four elementals this way. - overrides_applied: The field paths a compiler correction touched when this entry was built - from the SRD page. Empty for an entry the parser read cleanly. It is a provenance record - and nothing in play reads it. - Examples: ```python from osrlib.data import load_spells @@ -707,19 +747,87 @@ class SpellTemplate(BaseModel): model_config = ConfigDict(frozen=True) id: str = Field(min_length=1) + """The stable id you look the spell up by, slugified from its name: `"fire_ball"`. + + A handful of concepts appear in the SRD as a cleric page and a magic-user page that differ + mechanically. Each such pair compiles as two entries, the cleric one suffixed `_c` and the + magic-user one `_mu`: `"light_c"` and `"light_mu"`. For the ids the shipped catalog uses, see + [the spell id index][spells-index]. + """ + name: str = Field(min_length=1) + """The spell's printed name, such as `"Cure Light Wounds"`. Show this, not the id.""" + spell_list: str = Field(pattern=r"^[a-z][a-z0-9_]*$") + """Which class list the spell belongs to. + + The shipped catalog has `"cleric"` and `"magic_user"`, and further lists are additive data. It + must match the [`CasterProfile.spell_list`][osrlib.core.spells.CasterProfile] of any caster who + memorizes or learns the spell. + """ + level: int = Field(ge=1, le=6) + """The spell's level, 1 to 6. + + This is what the caster's slots are counted by, not the caster's own level. + """ + duration: str = Field(min_length=1) + """The duration line as printed. Show this to a player.""" + duration_spec: DurationSpec + """The parsed form of `duration`, as a [`DurationSpec`][osrlib.core.spells.DurationSpec]. + + Casting reads it to set the length of what it attaches. + """ + range: str = Field(min_length=1) + """The range line as printed. Show this to a player.""" + range_spec: RangeSpec + """The parsed form of `range`, as a [`RangeSpec`][osrlib.core.spells.RangeSpec].""" + reversed_form: ReversedForm | None = None + """The spell's reverse, as a [`ReversedForm`][osrlib.core.spells.ReversedForm]. + + `None` when the spell does not reverse. + """ + modes: tuple[SpellMode, ...] = Field(min_length=1) + """One [`SpellMode`][osrlib.core.spells.SpellMode] per numbered usage on the page. + + In the page's order, and at least one. Their keys are the `mode` argument casting takes. + """ + intro: str = "" + """The page's opening text, above the numbered usages. + + On a multi-usage page it is the lead-in, such as `"This spell has two usages:"`. On a + single-usage page it is the opening of the spell's description, and that one mode's `prose` is + the full text, which usually runs longer. + """ + conjured_monsters: tuple[MonsterTemplate, ...] = () + """Full monster stat blocks printed on the spell's own page rather than in the monster catalog. + + [`MonsterTemplate`][osrlib.core.monsters.MonsterTemplate] models: *sticks to snakes* brings its + own snake. Spawn them with [`spawn_monster`][osrlib.core.monsters.spawn_monster] when you + resolve the spell. + """ + conjured_monster_ids: tuple[str, ...] = () + """Ids of monsters the spell summons that already exist in the monster catalog. + + Look them up with [`load_monsters`][osrlib.data.load_monsters]. *Conjure elemental* names its + four elementals this way. + """ + overrides_applied: tuple[str, ...] = () + """The field paths a compiler correction touched when this entry was built from the SRD page. + + Empty for an entry the parser read cleanly. It is a provenance record, and nothing in play reads + it. + """ @model_validator(mode="after") def _mode_keys_unique_per_form(self) -> SpellTemplate: @@ -792,10 +900,6 @@ class SpellCatalog(BaseModel): caster may choose. To know which list a given caster draws from, call [`caster_profile`][osrlib.core.spells.caster_profile] on their class definition. - Attributes: - spells: Every spell template, in id order. Iterate it to search on something the two lookup - methods do not cover, such as a name or an effect kind. - Examples: ```python from osrlib.data import load_spells @@ -809,6 +913,12 @@ class SpellCatalog(BaseModel): model_config = ConfigDict(frozen=True) spells: tuple[SpellTemplate, ...] + """Every spell template the catalog holds. + + The shipped catalog is in id order. The model checks only that the ids are unique, so a catalog + you build yourself keeps whatever order you gave it. Iterate this to search on something the two + lookup methods do not cover, such as a name or an effect kind. + """ @model_validator(mode="after") def _ids_must_be_unique(self) -> SpellCatalog: @@ -872,8 +982,8 @@ def by_list(self, spell_list: str, level: int | None = None) -> tuple[SpellTempl level: A spell level, 1 to 6, to filter by. `None` returns the whole list. Returns: - The matching [`SpellTemplate`][osrlib.core.spells.SpellTemplate] models in id order, - which is the catalog's own order. Empty when nothing matches. + The matching [`SpellTemplate`][osrlib.core.spells.SpellTemplate] models in the catalog's + own order, which for the shipped catalog is id order. Empty when nothing matches. Examples: ```python @@ -910,15 +1020,6 @@ class MemorizedSpell(BaseModel): A copy names a spell and fills a slot. What the spell can do comes from the template you get with [`SpellCatalog.get`][osrlib.core.spells.SpellCatalog.get]. - Attributes: - spell_id: The spell's id, from [`load_spells`][osrlib.data.load_spells]. For the ids the - shipped catalog uses, see [the spell id index][spells-index]. - reversed: True when this copy is prepared as the spell's reversed form. Only an arcane - caster sets it, because the SRD has arcane casters choose the form when the spell is - memorized. A divine caster memorizes the normal form and speaks it backwards at the - moment of casting, so divine copies are always False and preparing one with True is - rejected. - Examples: ```python from osrlib.core.spells import MemorizedSpell @@ -931,7 +1032,18 @@ class MemorizedSpell(BaseModel): model_config = ConfigDict(frozen=True) spell_id: str = Field(min_length=1) + """The spell's id, from [`load_spells`][osrlib.data.load_spells]. + + For the ids the shipped catalog uses, see [the spell id index][spells-index]. + """ + reversed: bool = False + """True when this copy is prepared as the spell's reversed form. + + Only an arcane caster sets it, because the SRD has arcane casters choose the form when the spell + is memorized. A divine caster memorizes the normal form and speaks it backwards at the moment of + casting, so divine copies are always False and preparing one with True is rejected. + """ class CasterProfile(BaseModel): @@ -941,24 +1053,26 @@ class CasterProfile(BaseModel): definition, and you never construct one. Several functions here take it as an argument rather than deriving it themselves, so a caller who already has the class definition does not pay for the lookup twice. - - Attributes: - kind: `"divine"` for a class that prays for its spells and keeps no book, `"arcane"` for one - that studies from a spell book. The difference shows up in three places: only an arcane - caster has a book to grow with - [`add_spell_to_book`][osrlib.core.spells.add_spell_to_book], only an arcane caster fixes - a spell's reversed form at memorization, and only a divine caster may cast any memorized - copy in either form. - spell_list: The list id the class draws on, such as `"cleric"` or `"magic_user"`. It has to - match [`SpellTemplate.spell_list`][osrlib.core.spells.SpellTemplate] for the class to - memorize or learn a spell, and it is what you pass to - [`SpellCatalog.by_list`][osrlib.core.spells.SpellCatalog.by_list]. """ model_config = ConfigDict(frozen=True) kind: Literal["divine", "arcane"] + """`"divine"` for a class that prays for its spells, `"arcane"` for one that studies from a book. + + The difference shows up in three places: only an arcane caster has a book to grow with + [`add_spell_to_book`][osrlib.core.spells.add_spell_to_book], only an arcane caster fixes a + spell's reversed form at memorization, and only a divine caster may cast any memorized copy in + either form. + """ + spell_list: str + """The list id the class draws on, such as `"cleric"` or `"magic_user"`. + + It has to match [`SpellTemplate.spell_list`][osrlib.core.spells.SpellTemplate] for the class to + memorize or learn a spell, and it is what you pass to + [`SpellCatalog.by_list`][osrlib.core.spells.SpellCatalog.by_list]. + """ def caster_profile(definition: ClassDefinition) -> CasterProfile | None: @@ -1022,19 +1136,23 @@ class MemorizationResult(BaseModel): [`accepted`][osrlib.core.spells.MemorizationResult.accepted] rather than testing either tuple yourself. - Attributes: - rejections: Why the preparation was refused, as - [`Rejection`][osrlib.core.validation.Rejection] models with structured `code` and - `params` you can turn into a message in your own words. Every problem found is reported, - not just the first, so a player fixing a list sees all of it at once. Empty on success. - events: The [`SpellsMemorizedEvent`][osrlib.core.events.SpellsMemorizedEvent] naming what was - prepared, when the preparation went through. Empty on a rejection. """ model_config = ConfigDict(frozen=True) rejections: tuple[Rejection, ...] = () + """Why the preparation was refused, as [`Rejection`][osrlib.core.validation.Rejection] models. + + Each has a structured `code` and `params` you can turn into a message in your own words. Every + problem found is reported, not just the first, so a player fixing a list sees all of it at once. + Empty on success. + """ + events: tuple[Event, ...] = () + """The [`SpellsMemorizedEvent`][osrlib.core.events.SpellsMemorizedEvent] naming what was prepared. + + Empty on a rejection. + """ @property def accepted(self) -> bool: @@ -1182,18 +1300,22 @@ class SpellBookResult(BaseModel): [`accepted`][osrlib.core.spells.SpellBookResult.accepted] rather than testing either tuple yourself. - Attributes: - rejections: Why the addition was refused, as - [`Rejection`][osrlib.core.validation.Rejection] models with structured `code` and - `params`. At most one: the first problem found ends the call. Empty on success. - events: The [`SpellBookUpdatedEvent`][osrlib.core.events.SpellBookUpdatedEvent] naming the - spell that was added. Empty on a rejection. """ model_config = ConfigDict(frozen=True) rejections: tuple[Rejection, ...] = () + """Why the addition was refused, as [`Rejection`][osrlib.core.validation.Rejection] models. + + Each has a structured `code` and `params`. At most one: the first problem found ends the call. + Empty on success. + """ + events: tuple[Event, ...] = () + """The [`SpellBookUpdatedEvent`][osrlib.core.events.SpellBookUpdatedEvent] naming the new spell. + + Empty on a rejection. + """ @property def accepted(self) -> bool: @@ -1486,37 +1608,52 @@ class CastContext(BaseModel): assert no distance, and *raise dead* raises nobody if you assert no elapsed days. That is the trade: rather than guess, osrlib leaves a rule alone until you supply what it needs. - Attributes: - in_combat: True when the cast happens in a fight. A touch spell needs a melee attack roll in - combat and lands without one outside it, so this decides whether the touch can miss. - distance_feet: How far the target is from the caster. Supplying it turns on the range check - in validation, which rejects the cast when the distance is past what the spell's - [`RangeSpec`][osrlib.core.spells.RangeSpec] reaches at the caster's level. Leave it - unset and no range check happens. - bound: True when the caster is tied or held so that they cannot gesture. Casting is - rejected. - gagged: True when the caster cannot speak. Casting is rejected. - rounds_since_death: How many rounds ago the target died, for *neutralize poison*, which - revives a character killed by poison within the last ten rounds. Setting this field is - itself the assertion that poison was the cause, since osrlib records no cause of death. - Leave it unset for a death by any other means. - days_since_death: How many days ago the target died, for *raise dead*, which reaches back - four days per caster level above seventh. Leave it unset and nobody is raised. - strength_tiers: Entity ids mapped to `"augmented"` or `"giant"`, for *web*, which lets - stronger creatures tear free sooner. Anyone you do not name tears free at normal - strength. It is asserted here because osrlib has no effect that grants giant strength - yet. """ model_config = ConfigDict(frozen=True) in_combat: bool = False + """True when the cast happens in a fight. + + A touch spell needs a melee attack roll in combat and lands without one outside it, so this + decides whether the touch can miss. + """ + distance_feet: int | None = None + """How far the target is from the caster. + + Supplying it turns on the range check in validation, which rejects the cast when the distance is + past what the spell's [`RangeSpec`][osrlib.core.spells.RangeSpec] reaches at the level being + used. Leave it unset and no range check happens. + """ + bound: bool = False + """True when the caster is tied or held so that they cannot gesture. Casting is rejected.""" + gagged: bool = False + """True when the caster cannot speak. Casting is rejected.""" + rounds_since_death: int | None = None + """How many rounds ago the target died, for *neutralize poison*. + + That spell revives a character killed by poison within the last ten rounds. Setting this field + is itself the assertion that poison was the cause, since osrlib records no cause of death. Leave + it unset for a death by any other means. + """ + days_since_death: int | None = None + """How many days ago the target died, for *raise dead*. + + That spell reaches back four days per caster level above seventh. Leave it unset and nobody is + raised. + """ + strength_tiers: dict[str, str] = {} + """Entity ids mapped to `"augmented"` or `"giant"`, for *web*. + + A stronger creature tears free sooner. Anyone you do not name tears free at normal strength. It + is asserted here because osrlib has no effect that grants giant strength yet. + """ _CANNOT_CAST_CONDITIONS = ( @@ -1767,37 +1904,48 @@ class CastResult(BaseModel): Two of the outcomes need more than a description of what the spell did. A `manual` mode means osrlib did the bookkeeping and stopped: nothing was resolved and `prose` is all it can tell you, so your game or narrator says what happened. `no_effect` means the cast resolved and reached - nobody, because no candidate was eligible or every target saved. In both cases the copy is - gone. Nothing is - refunded once a cast resolves: a refund would tell the player something they had no way to know, - such as that the creature they aimed at was immune. + nobody, because no candidate was eligible or every target saved. In both cases the copy is gone. + Nothing is refunded once a cast resolves: a refund would tell the player something they had no + way to know, such as that the creature they aimed at was immune. - Attributes: - spell_id: The id of the spell that was cast, the same one you would pass to - [`SpellCatalog.get`][osrlib.core.spells.SpellCatalog.get]. - mode: The [`SpellMode.key`][osrlib.core.spells.SpellMode] that resolved. - reversed: True when the reversed form was the one cast. - manual: True when the mode was one osrlib does not resolve. Read `prose` and narrate it. - no_effect: True when the cast resolved and changed nothing. The copy is still spent. - prose: The SRD text of the mode that was cast, ready to show a player. - affected_ids: The entity id of everything the cast actually reached, in the order it was - reached, without repeats. A location-bound cast contains the location string you passed - as a target instead of an entity id. Empty when `no_effect` or `manual` is set. - events: Everything that happened, in order, starting with the - [`SpellCastEvent`][osrlib.core.events.SpellCastEvent] and then each saving throw, each - wound, each effect attached. This is what your game publishes and what a replay reads. """ model_config = ConfigDict(frozen=True) spell_id: str + """The id of the spell that was cast. + + The same one you would pass to [`SpellCatalog.get`][osrlib.core.spells.SpellCatalog.get]. + """ + mode: str + """The [`SpellMode.key`][osrlib.core.spells.SpellMode] that resolved.""" + reversed: bool = False + """True when the reversed form was the one cast.""" + manual: bool = False + """True when the mode was one osrlib does not resolve. Read `prose` and narrate it.""" + no_effect: bool = False + """True when the cast resolved and changed nothing. The copy is still spent.""" + prose: str = "" + """The SRD text of the mode that was cast, ready to show a player.""" + affected_ids: tuple[str, ...] = () + """The entity id of everything the cast reached, in the order it was reached, without repeats. + + A location-bound cast contains the location string you passed as a target instead of an entity + id. Empty when `no_effect` or `manual` is set. + """ + events: tuple[Event, ...] = () + """Everything that happened, in order. + + The [`SpellCastEvent`][osrlib.core.events.SpellCastEvent] first, then each saving throw, each + wound, each effect attached. This is what your game publishes and what a replay reads. + """ class _CastState: @@ -2095,10 +2243,12 @@ def __setattr__(self, name: str, value: object) -> None: def minimum_caster_level(spell: SpellTemplate) -> int: """Return the lowest class level that could cast a spell at all. - This is the caster level a scroll resolves at, so + This is the caster level a scroll's resolution runs at, so [`cast_from_scroll`][osrlib.core.spells.cast_from_scroll] calls it for you and you rarely need it yourself. Call it directly when you want to show what a scroll will do before anyone reads - it, since caster level is what scales a spell's damage, duration, and reach. + it, since caster level is what scales a spell's damage and duration. It is not the level a + scroll read is validated at: that check uses the reader's own level, as + [`cast_from_scroll`][osrlib.core.spells.cast_from_scroll] describes. The answer is the lowest level at which any class drawing on the spell's list first has a slot of that spell's level, read off the compiled class progressions. So the answer moves if you add @@ -2171,14 +2321,27 @@ def cast_from_scroll( The scroll itself is your responsibility. Reading one uses it up, since the words disappear from the page, and osrlib has no model of the scroll, so mark the inscribed spell spent in your own - inventory after this returns. Two other checks are yours as well, or the crawl layer's if you use it: - whether this reader may read this scroll at all, which is where a thief's scroll-use ability and - the arcane and divine divide come in, and whether there is light to read by. - - The spell resolves at the level [`minimum_caster_level`][osrlib.core.spells.minimum_caster_level] - gives for it, not the reader's own level, which is usually lower and sometimes much higher. - Everything the spell does to the reader, a condition, a modifier, a wound, still lands on the - reader. + inventory after this returns. Two other checks are yours as well, or the crawl layer's if you + use it: whether this reader may read this scroll at all, which is where a thief's scroll-use + ability and the arcane and divine divide come in, and whether there is light to read by. + + Two caster levels are in play, and which one applies depends on the step. Resolution runs at the + level [`minimum_caster_level`][osrlib.core.spells.minimum_caster_level] gives for the spell, so + a *fire ball* off a scroll always burns for 5d6 and a per-level duration is figured from that + same level, whatever level the reader is. The legality checks run at the reader's own level, + because they are the ones + [`validate_cast`][osrlib.core.spells.validate_cast] makes against the reader. That splits two + things you might expect to follow the scroll: + + - How many targets a mode demands. *Magic missile* wants one target per missile, and the missile + count comes from the reader's level, so a 6th-level reader must supply three targets and the + resolution then strikes all three, even though the scroll's own level is 1. + - How far the spell reaches, for a spell whose printed range grows per level. That reach is + figured from the reader's level when you assert a `distance_feet` in the + [`CastContext`][osrlib.core.spells.CastContext]. + + Everything the spell does to the reader, a condition, a modifier, a wound, lands on the reader + either way. Args: reader: The character reading the scroll, a @@ -2975,8 +3138,8 @@ def _resolve_dispel( Per effect, when the recorded caster level exceeds the dispelling caster's, the effect survives on a d100 roll at or under 5% per level of deficit (RAW: "a 5% chance per level difference of - *not* being dispelled"). A monster-inflicted effect is non-dispellable by construction, and - magic items are exempt. + *not* being dispelled"). Only effects whose definition sets `dispellable` are considered at all, + and that flag defaults to False. """ pct_per_level = _int_param(_mode_effect(mode).params, "survival_pct_per_level", 5) released: list[str] = [] @@ -3173,33 +3336,47 @@ class TurnUndeadResult(BaseModel): happened, so there is no accepted flag here. Read `outcomes` to explain the result and `affected_ids` to know who to move. - Attributes: - roll: The 2d6 the cleric rolled to turn. Compared against the table threshold for each kind - of undead present, so one roll can turn some kinds and fail against others. - hd_pool: The second 2d6, rolled only when at least one kind was turned, giving the Hit Dice - worth of undead the attempt can affect. `None` when nothing was turned and no second - roll was made. - outcomes: One [`TurningTypeOutcome`][osrlib.core.events.TurningTypeOutcome] per kind of - monster among the candidates, in the order the kinds first appeared. Its `outcome` is - `turn` for a kind that flees, `destroy` for one annihilated outright, `fail` for one that - held, and `unaffected` for a candidate that was not undead at all. - affected_ids: The entity ids of the individual monsters the attempt actually reached, which - is as many as `hd_pool` paid for. - destroyed_ids: The entity ids of those among them that were destroyed rather than turned. - They are dead permanently, past the reach of *raise dead*. - events: The [`UndeadTurnedEvent`][osrlib.core.events.UndeadTurnedEvent] and then the - consequences: a death for each monster destroyed, an attached `turned` condition for - each one that fled. Publish these. """ model_config = ConfigDict(frozen=True) roll: int + """The 2d6 the cleric rolled to turn. + + Compared against the table threshold for each kind of undead present, so one roll can turn some + kinds and fail against others. + """ + hd_pool: int | None = None + """The second 2d6, giving the Hit Dice worth of undead the attempt can affect. + + Rolled when at least one kind came out `turn` or `destroy`. `None` when no kind succeeded and no + second roll was made. + """ + outcomes: tuple[TurningTypeOutcome, ...] = () + """One [`TurningTypeOutcome`][osrlib.core.events.TurningTypeOutcome] per kind of monster present. + + In the order the kinds first appeared among the candidates. Each `outcome` is `turn` for a kind + that flees, `destroy` for one annihilated outright, `fail` for one that held, and `unaffected` + for a candidate that was not undead at all. + """ + affected_ids: tuple[str, ...] = () + """The entity ids of the individual monsters the attempt reached, as many as `hd_pool` paid for.""" + destroyed_ids: tuple[str, ...] = () + """The entity ids of those among the affected that were destroyed rather than turned. + + They are dead permanently, and *raise dead* cannot bring them back. + """ + events: tuple[Event, ...] = () + """The [`UndeadTurnedEvent`][osrlib.core.events.UndeadTurnedEvent] and then the consequences. + + A death for each monster destroyed, an attached `turned` condition for each one that fled. + Publish these. + """ def validate_turn_undead(cleric: Any, definition: ClassDefinition) -> list[Rejection]: @@ -3293,8 +3470,9 @@ def turn_undead( turns or it does not. Some kinds turn automatically, some are destroyed outright, some are beyond the cleric's power at their level. - If any kind turned, a second 2d6 gives a pool of Hit Dice, and the individual monsters are - affected cheapest first until the pool cannot pay for the next one. Ties keep the order you + If any kind came out turned or destroyed, a second 2d6 gives a pool of Hit Dice, and the + individual monsters of those kinds are affected cheapest first until the pool cannot pay for the + next one. Ties keep the order you passed them in. The remainder of the pool is wasted rather than spent on something else, and a successful turn always reaches at least one undead even when the pool rolls short. From fd51ef0d0a558bfd0213e3aa4a947f2df8b9cc46 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Sun, 13 Sep 2026 22:26:47 -0700 Subject: [PATCH 3/3] Bring the core spells comments into the same register Rewrite every `#` comment in spells.py the way the docstrings were rewritten: no dashes, no semicolon splices, one idea per sentence. Each "pinned" becomes the choice it stood for, stated as what the code does, so a maintainer reads the rule rather than a marker. The import-direction note drops its reference to an earlier review and keeps the constraint. Claude-Session: https://claude.ai/code/session_01GL26QnA6dCrvUc3WmhzFSa --- src/osrlib/core/spells.py | 69 +++++++++++++++++++-------------------- 1 file changed, 34 insertions(+), 35 deletions(-) diff --git a/src/osrlib/core/spells.py b/src/osrlib/core/spells.py index 4bfe210..abb67cf 100644 --- a/src/osrlib/core/spells.py +++ b/src/osrlib/core/spells.py @@ -108,11 +108,10 @@ ``` """ -# Import direction, mirroring the alignment.py lesson: the data loaders import these -# models and character.py imports the loaders, so this module never imports -# character.py — casting, memorization, and turning take caster objects duck-typed -# (the combatant convention), and character.py imports MemorizedSpell from here, -# never the reverse. +# Import direction: the data loaders import these models and character.py imports +# the loaders, so this module must never import character.py. Casting, memorization, +# and turning therefore take caster objects duck-typed, per the combatant +# convention, and character.py imports MemorizedSpell from here, never the reverse. from collections.abc import Mapping, Sequence from typing import Any, Literal @@ -2567,8 +2566,8 @@ def _eligible(target: Any, mode: SpellMode) -> bool: return False hit_dice = _monster_hit_dice(target) if params.get("hd_bonus_required"): - # *Sleep* mode 1: "a single creature with 4+1 Hit Dice" — pinned as a - # monster with HD count `hd_count` and a positive fixed modifier. + # *Sleep* mode 1 reads "a single creature with 4+1 Hit Dice" as a monster + # whose HD count equals `hd_count` and whose HD modifier is positive. if hit_dice is None or hit_dice.count != _int_param(params, "hd_count", 4) or hit_dice.modifier <= 0: return False if params.get("excludes_hd_4_plus") and hit_dice is not None and hit_dice.count == 4 and hit_dice.modifier > 0: @@ -2606,8 +2605,8 @@ def _select_cast_targets( return select_targets( TargetingMode.UP_TO_N, eligible, stream=stream, count=targeting.count, count_dice=targeting.count_dice ) - # Area modes: every supplied candidate; a radius ward centered on the caster - # (*protection from evil 10' radius*) covers the caster too. + # Area modes take every supplied candidate. A radius ward centered on the caster, + # such as *protection from evil 10' radius*, covers the caster too. if ( mode.effect is not None and mode.effect.params.get("includes_caster") @@ -2775,10 +2774,10 @@ def _condition_definition( fields["expiry"] = str(params["expiry"]) if "escape_dice" in params: if isinstance(target, str): - # A location-bound web (cast at a cell): the cell keeps the - # spell's own duration — the web sits there — and the escape params - # ride the effect for the crawl's enter hook, which attaches the - # per-creature entangled countdown on entry (pinned). + # A web cast at a cell keeps the spell's own duration, since the web + # stays put, and carries the escape params on the effect. The crawl + # layer's enter hook reads them and attaches the per-creature entangled + # countdown when someone walks in. fields["params"] = { **fields["params"], "escape_dice": str(params["escape_dice"]), @@ -2786,9 +2785,9 @@ def _condition_definition( } fields["condition"] = None else: - # *Web*'s escape countdown by STR, pinned: normal strength rolls the - # escape dice; the augmented and giant tiers are caller/context - # assertions. + # *Web*'s escape countdown goes by strength. Normal strength rolls the + # escape dice. The augmented and giant tiers come from the caller's + # context rather than from any effect osrlib grants. tier = context.strength_tiers.get(_target_ref(target)) if tier == "augmented": fields.update(duration_unit=TimeUnit.ROUND, duration_amount=int(params["augmented_strength_rounds"])) @@ -2883,8 +2882,8 @@ def _resolve_damage( params = effect.params caster_id = _entity_id(caster) element = str(params["element"]) if "element" in params else None - # Spell damage is magical and presents the `magic` key: a wight's - # silver-or-magic gate admits *magic missile*, a gargoyle's magic-only gate + # Spell damage is magical and presents the `magic` key, so a wight's + # silver-or-magic gate admits *magic missile* and a gargoyle's magic-only gate # admits *fire ball*. source = DamageSource( keys=("magic",), @@ -2894,10 +2893,10 @@ def _resolve_damage( destructive=bool(params.get("destructive", False)), ) if "missiles_base" in params: - # *Magic missile*: one supplied target per missile (repeats stack); each - # missile hits unerringly — no attack roll, no save (pinned) — and rolls - # its own damage, resolved instantly at cast (the 1-turn duration is - # holding prose, pinned). + # *Magic missile* takes one supplied target per missile, and repeating a + # target stacks the missiles on it. Each missile hits without an attack roll + # and allows no save, and rolls its own damage. The whole thing resolves at + # cast and attaches nothing, so the printed 1-turn duration never applies. for target in selected: if check_immunity(target, source, ruleset=ruleset, attacker=caster): state.events.extend(_absorbed_events(target, caster_id, source)) @@ -2963,7 +2962,7 @@ def _resolve_damage( result = roll(dice, stream) amount = result.total if passed and save is not None and save.on_save == "half": - amount //= 2 # halving floors (pinned) + amount //= 2 # integer division, so a halved total rounds down if amount < 1: continue state.events.extend( @@ -3015,8 +3014,8 @@ def _resolve_cure( if not matches: continue if "magical_fear_save" in params and definition.condition is Condition.AFRAID: - # *Remove fear* versus magical fear: the subject saves with +1 per - # caster level to shake it; a failed save keeps the fear. + # Against magical fear, *remove fear* lets the subject save with +1 + # per caster level to shake it. A failed save keeps the fear. result = saving_throw( target, SaveCategory(str(params["magical_fear_save"])), @@ -3031,19 +3030,19 @@ def _resolve_cure( state.affect(target) if params.get("revives_poison_dead") and not isinstance(target, str): window = _int_param(params, "revive_window_rounds", 10) - # The page's revival usage is titled "Characters" — only a Character is - # revivable (pinned). The kernel has no cause-of-death model: supplying - # `rounds_since_death` IS the caller's attestation that the target died - # of poison within that many rounds (the session supplies it from its - # death records); omit it for any other death. + # The page titles its revival usage "Characters", so only a Character is + # revivable here. osrlib records no cause of death, so supplying + # `rounds_since_death` is itself the caller's assertion that the target + # died of poison that many rounds ago. The session takes it from its own + # death records, and omits it for a death by any other means. if ( getattr(target, "definition", None) is not None and has_condition(target, Condition.DEAD) and context.rounds_since_death is not None and context.rounds_since_death <= window ): - # Revival, pinned: the poison death is undone and the subject - # stands at 1 hp (RAW names no hit point total). + # Revival undoes the poison death and stands the subject up at 1 hp. + # RAW names no hit point total, so osrlib sets the lowest one. state.events.extend(remove_condition(target, Condition.DEAD, None)) target.current_hp = 1 state.events.append( @@ -3092,8 +3091,8 @@ def _resolve_attachment( if effect is None: continue if "images_dice" in params: - # *Mirror image*'s 1d4 images live in effect state — attach-time - # randomness, drawn from the effects stream per the convention. + # *Mirror image*'s 1d4 images live in effect state. The count is rolled + # as the effect attaches, so it comes from the effects stream. effect.state["images"] = roll(str(params["images_dice"]), effects_stream).total state.affect(target) @@ -3611,7 +3610,7 @@ def turn_undead( for _, monster in ordered: cost = effective_hd(monster) if cost > remaining: - break # excess is wasted, not reallocated (pinned) + break # the rest of the pool is wasted, never spent on another monster affected.append(monster) remaining -= cost if not affected and ordered: