Skip to content

osrlib.core.spells

Spell memorization, casting, spell resolution, and turning undead.

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, filled for the day by memorize_spells and spent one copy at a time by 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 in osrlib.core.effects, which ticks it and releases it when it ends. The functions here take the spell catalog 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, LearnSpell, CastSpell, and 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 models. A template has the page's presentation data (duration, range, prose) alongside structured mechanics: one SpellMode per castable usage, each naming its targeting, its saving throw, and, for the automated subset, a 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 prepares a caster's list, and an arcane caster prepares from a spell book, which add_spell_to_book adds to. Then validate_cast checks legality and cast_spell consumes the memorized copy and resolves the mode. cast_from_scroll resolves an inscribed spell with no memorized copy behind it, checked first by validate_scroll_cast, and disrupt_casting takes a copy away from a caster whose declared cast was broken before they could make it. Clerics also turn undead here: validate_turn_undead, then turn_undead.

Casters are Caster values, which a Character satisfies and a monster does not. Targets are Creature values, characters or 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. 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 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 stream.

Typical usage:

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

EFFECT_KINDS module-attribute

EFFECT_KINDS = frozenset(
    {"damage", "heal", "cure", "condition", "modifiers", "kill", "restore_life", "dispel", "attach_only"}
)

The effect kinds casting knows how to execute.

SpellEffect.kind is validated against this set, so a spell you author yourself has to resolve into one of these behaviors or be marked manual 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.

MAGIC_STREAM module-attribute

MAGIC_STREAM = StreamName.MAGIC

The name of the RNG stream every spell-resolution draw comes from.

Pass streams.get(MAGIC_STREAM) as the stream argument of cast_spell, cast_from_scroll, and turn_undead, where streams is an 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 instead, so pass that as effects_stream rather than reusing this one.

CastContext

Bases: BaseModel

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, cast_spell, or 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.

in_combat class-attribute instance-attribute

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 class-attribute instance-attribute

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 reaches at the level being used. Leave it unset and no range check happens.

bound class-attribute instance-attribute

bound: bool = False

True when the caster is tied or held so that they cannot gesture. Casting is rejected.

gagged class-attribute instance-attribute

gagged: bool = False

True when the caster cannot speak. Casting is rejected.

rounds_since_death class-attribute instance-attribute

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 class-attribute instance-attribute

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 class-attribute instance-attribute

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.

CastResult

Bases: BaseModel

What a cast did: which copy was spent, who it reached, and everything that happened.

You get one back from cast_spell and from 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.

spell_id instance-attribute

spell_id: str

The id of the spell that was cast.

The same one you would pass to SpellCatalog.get.

mode instance-attribute

mode: str

The SpellMode.key that resolved.

reversed class-attribute instance-attribute

reversed: bool = False

True when the reversed form was the one cast.

manual class-attribute instance-attribute

manual: bool = False

True when the mode was one osrlib does not resolve. Read prose and narrate it.

no_effect class-attribute instance-attribute

no_effect: bool = False

True when the cast resolved and changed nothing. The copy is still spent.

prose class-attribute instance-attribute

prose: str = ''

The SRD text of the mode that was cast, ready to show a player.

affected_ids class-attribute instance-attribute

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 class-attribute instance-attribute

events: tuple[Event, ...] = ()

Everything that happened, in order.

The SpellCastEvent first, then each saving throw, each wound, each effect attached. This is what your game publishes and what a replay reads.

CasterProfile

Bases: BaseModel

What kind of caster a class is, and which spell list it draws on.

Get one from 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.

kind instance-attribute

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, 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 instance-attribute

spell_list: str

The list id the class draws on, such as "cleric" or "magic_user".

It has to match SpellTemplate.spell_list for the class to memorize or learn a spell, and it is what you pass to SpellCatalog.by_list.

DurationSpec

Bases: BaseModel

How long a spell lasts, parsed out of the printed duration line.

You read one off SpellTemplate.duration_spec, or off a 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.

Examples:

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"

kind instance-attribute

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 class-attribute instance-attribute

unit: TimeUnit | None = None

The time unit a fixed duration counts in, as a TimeUnit.

Rounds, turns, hours, or days. None on every other kind.

amount class-attribute instance-attribute

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 class-attribute instance-attribute

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 class-attribute instance-attribute

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 class-attribute instance-attribute

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 class-attribute instance-attribute

concentration_cap_amount: int | None = None

How many concentration_cap_unit the limit on a concentration duration runs to.

MemorizationResult

Bases: BaseModel

What came of a call to 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 rather than testing either tuple yourself.

rejections class-attribute instance-attribute

rejections: tuple[Rejection, ...] = ()

Why the preparation was refused, as 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 class-attribute instance-attribute

events: tuple[Event, ...] = ()

The SpellsMemorizedEvent naming what was prepared.

Empty on a rejection.

accepted property

accepted: bool

Whether the caster's memorized list was actually replaced.

False means nothing changed and rejections says why.

MemorizedSpell

Bases: BaseModel

One spell a caster has ready to cast: which spell, and in which form.

You build these to hand to 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.

Examples:

from osrlib.core.spells import MemorizedSpell

prepared = [MemorizedSpell(spell_id="magic_missile"), MemorizedSpell(spell_id="light_mu", reversed=True)]
assert prepared[1].reversed  # this copy casts *darkness*, not *light*

spell_id class-attribute instance-attribute

spell_id: str = Field(min_length=1)

The spell's id, from load_spells.

For the ids the shipped catalog uses, see the spell id index.

reversed class-attribute instance-attribute

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.

RangeSpec

Bases: BaseModel

How far a spell reaches, parsed out of the printed range line.

You read one off SpellTemplate.range_spec, and you never build one during play. validate_cast reads it for you, but only when you tell it how far away the target is through CastContext.distance_feet. 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.

Examples:

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"

kind instance-attribute

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 class-attribute instance-attribute

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 class-attribute instance-attribute

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.

ReversedForm

Bases: BaseModel

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, which is None on a spell that does not reverse. To cast it, pass reversed=True to 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. 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.

Examples:

from osrlib.data import load_spells

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

name class-attribute instance-attribute

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 class-attribute instance-attribute

prose: str = ''

The SRD text for the reversed version.

modes class-attribute instance-attribute

modes: tuple[SpellMode, ...] = Field(min_length=1)

One SpellMode per castable usage of the reverse.

Each has its own key, and there is at least one.

duration class-attribute instance-attribute

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 class-attribute instance-attribute

duration_spec: DurationSpec | None = None

The parsed form of duration, as a 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.

SaveSpec

Bases: BaseModel

The saving throw one castable usage of a spell allows its targets, and what passing it buys.

You read one off SpellMode.save, 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.

Examples:

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

category instance-attribute

category: SaveCategory

Which column of the saving-throw table the target rolls on.

A SaveCategory.

modifier class-attribute instance-attribute

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 class-attribute instance-attribute

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.

SpellBookResult

Bases: BaseModel

What came of a call to 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 rather than testing either tuple yourself.

rejections class-attribute instance-attribute

rejections: tuple[Rejection, ...] = ()

Why the addition was refused, as Rejection models.

Each has a structured code and params. At most one: the first problem found ends the call. Empty on success.

events class-attribute instance-attribute

events: tuple[Event, ...] = ()

The SpellBookUpdatedEvent naming the new spell.

Empty on a rejection.

accepted property

accepted: bool

Whether the spell actually went into the book.

False means the book is unchanged and rejections says why.

SpellCatalog

Bases: BaseModel

Every spell osrlib knows, with lookup by id and by class list.

Get the shipped catalog from 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 out of it.

Use get when you have an id, and 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 on their class definition.

Examples:

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))

spells instance-attribute

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.

get

get(spell_id: str) -> SpellTemplate

Return one spell by its id.

This is how you turn a stored id back into castable data: a 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.

Parameters:

Name Type Description Default
spell_id str

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.

required

Returns:

Type Description
SpellTemplate

Raises:

Type Description
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:

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'"

by_list

by_list(spell_list: str, level: int | None = None) -> tuple[SpellTemplate, ...]

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, and what a divine caster may prepare with 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 rather than hard-coding it, so a class you add with a list of its own works without a change here.

Parameters:

Name Type Description Default
spell_list str

The list id, such as "cleric" or "magic_user". An id no spell uses returns nothing rather than raising.

required
level int | None

A spell level, 1 to 6, to filter by. None returns the whole list.

None

Returns:

Type Description
SpellTemplate

The matching SpellTemplate models in the catalog's

...

own order, which for the shipped catalog is id order. Empty when nothing matches.

Examples:

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

SpellEffect

Bases: BaseModel

What one castable usage of a spell actually does to its targets.

You read one off SpellMode.effect. 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 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 when the catalog loads.

Examples:

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)

kind instance-attribute

kind: str

Which resolution behavior runs, one of EFFECT_KINDS.

condition class-attribute instance-attribute

condition: Condition | None = None

The Condition a condition effect attaches to each target.

Blindness, charm, and the rest. None on every other kind.

cures_conditions class-attribute instance-attribute

cures_conditions: tuple[Condition, ...] = ()

The conditions a cure effect lifts. Cure light wounds' second usage lifts paralysis.

cures_effect_kinds class-attribute instance-attribute

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 class-attribute instance-attribute

modifiers: tuple[ModifierSpec, ...] = ()

The 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 class-attribute instance-attribute

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.

SpellMode

Bases: BaseModel

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. Get the modes of a spell from SpellTemplate.modes, or one by key from 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.

Examples:

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:")

key class-attribute instance-attribute

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 and validate_cast. A spell with a single usage still has one, such as fire ball's "damage".

targeting class-attribute instance-attribute

targeting: TargetingSpec | None = None

Who the mode can hit and how many, as a TargetingSpec.

None only on manual modes.

save class-attribute instance-attribute

save: SaveSpec | None = None

The saving throw the targets get, as a SaveSpec.

None when the mode allows none.

effect class-attribute instance-attribute

effect: SpellEffect | None = None

What the mode does, as a SpellEffect.

None only on manual modes.

manual class-attribute instance-attribute

manual: bool = False

True when osrlib does the bookkeeping and leaves the outcome to your game.

prose class-attribute instance-attribute

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.

SpellTemplate

Bases: BaseModel

One spell, compiled from its SRD page: the reference data behind every cast.

Get one from SpellCatalog.get by id, or a whole class list from SpellCatalog.by_list. The catalog itself comes from load_spells. Pass the template straight to cast_spell, cast_from_scroll, or 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 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.

Examples:

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"

id class-attribute instance-attribute

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.

name class-attribute instance-attribute

name: str = Field(min_length=1)

The spell's printed name, such as "Cure Light Wounds". Show this, not the id.

spell_list class-attribute instance-attribute

spell_list: str = Field(pattern='^[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 of any caster who memorizes or learns the spell.

level class-attribute instance-attribute

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 class-attribute instance-attribute

duration: str = Field(min_length=1)

The duration line as printed. Show this to a player.

duration_spec instance-attribute

duration_spec: DurationSpec

The parsed form of duration, as a DurationSpec.

Casting reads it to set the length of what it attaches.

range class-attribute instance-attribute

range: str = Field(min_length=1)

The range line as printed. Show this to a player.

range_spec instance-attribute

range_spec: RangeSpec

The parsed form of range, as a RangeSpec.

reversed_form class-attribute instance-attribute

reversed_form: ReversedForm | None = None

The spell's reverse, as a ReversedForm.

None when the spell does not reverse.

modes class-attribute instance-attribute

modes: tuple[SpellMode, ...] = Field(min_length=1)

One 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 class-attribute instance-attribute

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 class-attribute instance-attribute

conjured_monsters: tuple[MonsterTemplate, ...] = ()

Full monster stat blocks printed on the spell's own page rather than in the monster catalog.

MonsterTemplate models: sticks to snakes brings its own snake. Spawn them with spawn_monster when you resolve the spell.

conjured_monster_ids class-attribute instance-attribute

conjured_monster_ids: tuple[str, ...] = ()

Ids of monsters the spell summons that already exist in the monster catalog.

Look them up with load_monsters. Conjure elemental names its four elementals this way.

overrides_applied class-attribute instance-attribute

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.

mode

mode(key: str, *, reversed: bool = False) -> SpellMode

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 and show each mode's prose. The key you pass here is the same string casting takes as its mode argument.

Parameters:

Name Type Description Default
key str

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.

required
reversed bool

True to look on the spell's reversed form instead of its normal one.

False

Returns:

Type Description
SpellMode

The SpellMode.

Raises:

Type Description
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:

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)

TargetingSpec

Bases: BaseModel

Who one castable usage of a spell can hit, and how many of them.

You read one off SpellMode.targeting to know how many targets to collect before you call cast_spell, which is the question a spell-targeting interface has to answer first. mode is the shared TargetingMode that 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 with no_effect set, and the memorized copy is still spent.

Examples:

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})

mode instance-attribute

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 class-attribute instance-attribute

count: int | None = None

The fixed size of an up_to_n group, when the page prints a number rather than dice.

count_dice class-attribute instance-attribute

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 class-attribute instance-attribute

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 class-attribute instance-attribute

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 class-attribute instance-attribute

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 class-attribute instance-attribute

shape: str | None = None

The name of the area an area mode covers, such as "sphere". None on every other mode.

dimensions class-attribute instance-attribute

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.

TurnUndeadResult

Bases: BaseModel

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. 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.

roll instance-attribute

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 class-attribute instance-attribute

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 class-attribute instance-attribute

outcomes: tuple[TurningTypeOutcome, ...] = ()

One 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 class-attribute instance-attribute

affected_ids: tuple[str, ...] = ()

The entity ids of the individual monsters the attempt reached, as many as hd_pool paid for.

destroyed_ids class-attribute instance-attribute

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 class-attribute instance-attribute

events: tuple[Event, ...] = ()

The UndeadTurnedEvent and then the consequences.

A death for each monster destroyed, an attached turned condition for each one that fled. Publish these.

add_spell_to_book

add_spell_to_book(caster: Caster, definition: ClassDefinition, catalog: SpellCatalog, spell_id: str) -> SpellBookResult

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 first if you want to show only the spells that will fit, and SpellCatalog.by_list to build the menu of what the class may learn at all. Once a spell is in the book, memorize_spells can prepare it.

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 wraps this call and also passes no time.

Parameters:

Name Type Description Default
caster Caster

The Caster learning the spell, a Character with an arcane class. Its spell_book grows by one id. Nothing is written when the call is rejected.

required
definition ClassDefinition

The caster's class, as a ClassDefinition from load_classes.

required
catalog SpellCatalog

The spell catalog, from load_spells.

required
spell_id str

The spell to write in. For the ids the shipped catalog uses, see the spell id index.

required

Returns:

Type Description
SpellBookResult

A SpellBookResult: the book-updated event on

SpellBookResult

success, or the rejection with the book left untouched. A class with no book, an id no spell

SpellBookResult

uses, a spell off the class's list, one the book already contains, and a level with no room

SpellBookResult

are each their own rejection code.

Examples:

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"]

cast_from_scroll

cast_from_scroll(
    reader: Caster,
    spell: SpellTemplate,
    mode: str,
    *,
    reversed: bool = False,
    targets: Sequence[Creature | str] = (),
    context: CastContext | None = None,
    ledger: EffectsLedger,
    clock: GameClock,
    allocator: Any,
    registry: dict[str, Any],
    ruleset: Ruleset,
    stream: RngStream,
    effects_stream: RngStream
) -> CastResult

Cast a spell off a scroll, with the scroll standing in for the memorized copy.

Use this rather than 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.

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 read runs at one caster level throughout: the level minimum_caster_level gives for the spell, whatever level the reader is. Legality and resolution both use it, so a fire ball off a scroll always burns for 5d6, a per-level duration is figured from that same level, and the two legality checks that scale with caster level follow the scroll rather than the reader:

  • How many targets a mode demands. Magic missile wants one target per missile, and a scroll's level grants one, so even a 6th-level reader supplies one target and is refused three.
  • How far the spell reaches, for a spell whose printed range grows per level. That reach is figured from the scroll's level when you assert a distance_feet in the CastContext.

A condition, a modifier, or a wound the spell puts on its caster lands on the reader, the same as it would from a spell they had memorized.

Parameters:

Name Type Description Default
reader Caster

The Caster reading the scroll, which a Character satisfies. Nothing is taken from their memorized spells.

required
spell SpellTemplate

The inscribed SpellTemplate, from SpellCatalog.get.

required
mode str

Which usage of the spell, by its SpellMode.key.

required
reversed bool

True to cast the spell's reversed form.

False
targets Sequence[Creature | str]

The candidate targets in your own order: Creature values, which a Character and a MonsterInstance both satisfy, or location strings for spells your game attaches to a place.

()
context CastContext | None

The CastContext with what you assert about the situation. None asserts nothing.

None
ledger EffectsLedger

The EffectsLedger that ongoing effects attach to. Pass the one your game keeps.

required
clock GameClock

The GameClock, read to stamp attached effects.

required
allocator Any

The IdAllocator that names each attached effect.

required
registry dict[str, Any]

Every live combatant by entity id, as Character and MonsterInstance objects.

required
ruleset Ruleset

The Ruleset in play.

required
stream RngStream

The RngStream the cast's own draws come from, conventionally MAGIC_STREAM.

required
effects_stream RngStream

The stream that attaching effects draw from, conventionally EFFECTS_STREAM.

required

Returns:

Type Description
CastResult

The CastResult: what was reached, and every event, in

CastResult

order.

Raises:

Type Description
ValueError

If the read is illegal. Nothing is drawn or changed before the refusal. Ask validate_scroll_cast first to get the reasons instead of the exception. It is the check this call makes, asked the same way, so an empty answer from it means this call will not raise.

Examples:

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

cast_spell

cast_spell(
    caster: Caster,
    spell: SpellTemplate,
    mode: str,
    *,
    profile: CasterProfile,
    reversed: bool = False,
    targets: Sequence[Creature | str] = (),
    context: CastContext | None = None,
    ledger: EffectsLedger,
    clock: GameClock,
    allocator: Any,
    registry: dict[str, Any],
    ruleset: Ruleset,
    stream: RngStream,
    effects_stream: RngStream
) -> CastResult

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 gives them, and you need the profile that caster_profile returns. After it, publish the 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. To cast an inscribed spell with no memorized copy behind it, use 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 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.

Parameters:

Name Type Description Default
caster Caster

The Caster with a matching memorized copy, which a Character satisfies. Its memorized_spells loses that copy.

required
spell SpellTemplate

The SpellTemplate to cast, from SpellCatalog.get.

required
mode str

Which usage of the spell, by its SpellMode.key.

required
profile CasterProfile

The caster's CasterProfile, from caster_profile.

required
reversed bool

True to cast the spell's reversed form.

False
targets Sequence[Creature | str]

The candidate targets in your own order: Creature values, which a Character and a MonsterInstance both satisfy, 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 CastContext | None

The CastContext with what you assert about the situation. None asserts nothing.

None
ledger EffectsLedger

The EffectsLedger that ongoing effects attach to. Pass the one your game keeps, not a fresh one, or the spell's duration is lost.

required
clock GameClock

The GameClock, read to stamp when attached effects began and when they end.

required
allocator Any

The IdAllocator that names each attached effect. Pass the one your game keeps, so ids stay unique across the session.

required
registry dict[str, Any]

Every live combatant by entity id, as Character and MonsterInstance objects. Resolution reaches through it to change creatures the targets alone do not name, such as when an effect lifts.

required
ruleset Ruleset

The Ruleset in play, read for the optional rules that touch attack rolls and damage.

required
stream RngStream

The RngStream every draw the cast makes comes from, conventionally MAGIC_STREAM.

required
effects_stream RngStream

The stream that attaching effects draw from, conventionally EFFECTS_STREAM.

required

Returns:

Type Description
CastResult

The CastResult: what was reached, and every event, in

CastResult

order.

Raises:

Type Description
ValueError

If the cast is illegal. Ask validate_cast first. Reaching this means the cast was not legal to offer.

Examples:

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, MemorizedSpell, cast_spell, caster_profile, memorize_spells
from osrlib.data import load_classes, load_monsters, load_spells

rules = Ruleset()
streams = RngStreams(master_seed=11)
catalog = load_spells()
definition = load_classes().get("magic_user")

# A 1st-level magic-user with *magic missile* in her book, memorized for the day.
created = create_character(
    name="Zelia",
    class_id="magic_user",
    alignment=Alignment.NEUTRAL,
    ruleset=rules,
    stream=streams.get(CHARACTER_CREATION_STREAM),
    starting_spell_ids=["magic_missile"],
)
zelia = created.character
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.
template = load_monsters().get("goblin")
goblin = spawn_monster(template, id="monster-0001", stream=streams.get(MONSTER_SPAWN_STREAM))
outcome = cast_spell(
    zelia,
    catalog.get("magic_missile"),
    "missiles",
    profile=caster_profile(definition),
    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.spell_id == "magic_missile" and not outcome.no_effect
assert outcome.affected_ids == ("monster-0001",)
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

caster_profile

caster_profile(definition: ClassDefinition) -> CasterProfile | None

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 and 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.

Parameters:

Name Type Description Default
definition ClassDefinition

The class, as a ClassDefinition from load_classes. A character names its class in class_id.

required

Returns:

Type Description
CasterProfile | None

The CasterProfile, or None when the class has

CasterProfile | None

neither casting tag.

Examples:

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

disrupt_casting

disrupt_casting(caster: Caster, spell_id: str, *, reversed: bool = False) -> list[Event]

Take away a spell a caster declared but never got to cast.

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.

Parameters:

Name Type Description Default
caster Caster

The Caster who was interrupted, which a Character satisfies. Its memorized_spells loses one copy.

required
spell_id str

The id of the spell they had declared. For the ids the shipped catalog uses, see the spell id index.

required
reversed bool

True when the declared cast was of the reversed form.

False

Returns:

Type Description
list[Event]

A single SpellDisruptedEvent, in a list, for you

list[Event]

to publish alongside whatever caused the disruption.

Raises:

Type Description
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:

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

forget_excess_memorized

forget_excess_memorized(caster: Caster, definition: ClassDefinition, catalog: SpellCatalog) -> list[Event]

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.

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.

Parameters:

Name Type Description Default
caster Caster

The Caster who lost levels, which a Character satisfies. Its memorized_spells shrinks. A caster with nothing memorized is left alone.

required
definition ClassDefinition

The caster's class, as a ClassDefinition from load_classes. Its row at the caster's new level supplies the slot counts.

required
catalog SpellCatalog

The spell catalog, from load_spells, used to look up the level of each memorized spell.

required

Returns:

Type Description
list[Event]

One SpellForgottenEvent per copy dropped, newest

list[Event]

first. Empty when everything still fits.

Examples:

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

memorize_spells

memorize_spells(
    caster: Caster, definition: ClassDefinition, catalog: SpellCatalog, selections: Sequence[MemorizedSpell]
) -> MemorizationResult

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 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 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 in the crawl layer. Nothing here checks them, so if you drive the rules yourself you decide when preparation is allowed.

Parameters:

Name Type Description Default
caster Caster

The Caster preparing spells, which a Character satisfies. Its memorized_spells is what this replaces. Nothing is written when the call is rejected.

required
definition ClassDefinition

The caster's class, as a ClassDefinition from load_classes. Its progression row at the caster's level supplies the slot counts.

required
catalog SpellCatalog

The spell catalog, from load_spells.

required
selections Sequence[MemorizedSpell]

The 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 drops the last ones first.

required

Returns:

Type Description
MemorizationResult

A MemorizationResult: the memorized event on

MemorizationResult

success, or every rejection found with the caster left untouched.

Examples:

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

minimum_caster_level

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's resolution runs at, so 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 and duration, and when you want to validate a scroll read ahead of time, because cast_from_scroll checks legality at this level too.

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.

The tabletop rules do not say what level a scroll's spell was inscribed at. osrlib reads a scroll at the lowest level that could cast it, so a scroll the party finds never outdoes the caster who found it. A game that wants scrolls to have their own caster level resolves them with cast_spell against a caster of that level instead.

Parameters:

Name Type Description Default
spell SpellTemplate

The SpellTemplate to look up.

required

Returns:

Type Description
int

The caster level, 1 or higher.

Raises:

Type Description
ValueError

If no class drawing on the spell's list ever gains a slot of the spell's level, which means the spell's level is higher than any of those classes ever prepares.

Examples:

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

open_book_capacity

open_book_capacity(caster: Caster, definition: ClassDefinition, catalog: SpellCatalog) -> tuple[int, ...]

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 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.

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.

Parameters:

Name Type Description Default
caster Caster

The Caster, which a Character satisfies. Its spell_book and level are read and nothing is written.

required
definition ClassDefinition

The caster's class, as a ClassDefinition from load_classes.

required
catalog SpellCatalog

The spell catalog, from load_spells, used to look up the level of each spell in the book.

required

Returns:

Type Description
int

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

tuple[int, ...]

non-caster.

Examples:

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

pop_mirror_image

pop_mirror_image(ledger: EffectsLedger, target_ref: str, *, registry: dict[str, Any], clock: GameClock) -> list[Event]

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.

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.

Parameters:

Name Type Description Default
ledger EffectsLedger

The EffectsLedger the images live on, the one the cast attached them to.

required
target_ref str

The entity id of the caster being attacked.

required
registry dict[str, Any]

Every live combatant by entity id, as Character and MonsterInstance objects. Read when the last image goes and the effect lifts.

required
clock GameClock

The GameClock, read to stamp the event with the current round.

required

Returns:

Type Description
list[Event]

An EffectTickedEvent for the image destroyed, plus

list[Event]

the release events when that was the last one. Empty when the target has no images.

Examples:

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) == []

turn_undead

turn_undead(
    cleric: Caster,
    definition: ClassDefinition,
    candidates: Sequence[MonsterInstance],
    *,
    ledger: EffectsLedger,
    clock: GameClock,
    allocator: Any,
    registry: dict[str, Any],
    stream: RngStream
) -> TurnUndeadResult

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 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 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.

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.

Parameters:

Name Type Description Default
cleric Caster

The Caster turning, which a Character satisfies. Its level picks the row of the turning table.

required
definition ClassDefinition

Their class, as a ClassDefinition from load_classes. It must have the turn_undead tag.

required
candidates Sequence[MonsterInstance]

The monsters present, as 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.

required
ledger EffectsLedger

The EffectsLedger the turned condition attaches to. Pass the one your game keeps.

required
clock GameClock

The GameClock, read to stamp the attached effects.

required
allocator Any

The IdAllocator that names each attached effect.

required
registry dict[str, Any]

Every live combatant by entity id, as Character and MonsterInstance objects.

required
stream RngStream

The RngStream both 2d6 rolls come from, conventionally MAGIC_STREAM. Both rolls are on the player-visible event, because in B/X the player rolls them.

required

Returns:

Type Description
TurnUndeadResult

The TurnUndeadResult: the dice, the verdict per

TurnUndeadResult

kind, who was reached, and every event.

Raises:

Type Description
ValueError

If the character cannot turn undead at all. Ask validate_turn_undead first.

Examples:

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)

validate_cast

validate_cast(
    caster: Caster,
    spell: SpellTemplate,
    mode: str,
    *,
    profile: CasterProfile | None,
    reversed: bool = False,
    targets: Sequence[Creature | str] = (),
    context: CastContext | None = None,
    ledger: EffectsLedger | None = None
) -> list[Rejection]

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, 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.

Parameters:

Name Type Description Default
caster Caster

The Caster, which a Character satisfies. Read, never written.

required
spell SpellTemplate

The SpellTemplate to cast, from SpellCatalog.get.

required
mode str

Which usage of the spell, by its SpellMode.key. A key the chosen form does not have is a rejection, not an exception.

required
profile CasterProfile | None

The caster's CasterProfile, from 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.

required
reversed bool

True to cast the spell's reversed form.

False
targets Sequence[Creature | str]

The candidate targets: Creature values, which a Character and a MonsterInstance both satisfy, or location strings for spells your game attaches to a place rather than a creature. Only the count is examined here.

()
context CastContext | None

The CastContext with what you assert about the situation. None asserts nothing.

None
ledger EffectsLedger | None

The 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.

None

Returns:

Type Description
list[Rejection]

Every reason the cast is illegal, as Rejection models

list[Rejection]

with structured code and params. Empty when the cast may go ahead. Some checks stop the

list[Rejection]

call at the first problem, so treat the list as the reasons found rather than every reason

list[Rejection]

there is.

Examples:

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

validate_scroll_cast

validate_scroll_cast(
    reader: Caster,
    spell: SpellTemplate,
    mode: str,
    *,
    reversed: bool = False,
    targets: Sequence[Creature | str] = (),
    context: CastContext | None = None,
    ledger: EffectsLedger | None = None
) -> list[Rejection]

Ask whether a scroll read is legal, without reading it.

This is validate_cast for a spell coming off a page, and it is the check cast_from_scroll makes before it resolves anything. Call it to decide whether to offer a read, and call it before any read you are about to make. cast_from_scroll raises on an illegal read, and osrlib has no model of the scroll, so your own inventory is what decides whether the refused attempt still used it up.

The difference from validate_cast is the caster the question is asked about. A scroll resolves at the lowest class level able to cast the inscribed spell, from minimum_caster_level, whatever level the reader is, so this builds that caster and asks about them. The two checks that scale with caster level therefore follow the scroll: how many targets a mode demands, which is why a 6th-level reader of a magic missile scroll supplies one target and is refused three, and how far a per-level range reaches. The memorized-copy check is skipped, since the scroll is the copy.

Two things it does not answer, because they depend on the game around the spell rather than on the spell: 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 crawl layer, the osrlib.crawl package that runs a session, checks both of those.

Parameters:

Name Type Description Default
reader Caster

The Caster reading the scroll, which a Character satisfies. Read, never written.

required
spell SpellTemplate

The inscribed SpellTemplate, from SpellCatalog.get.

required
mode str

Which usage of the spell, by its SpellMode.key. A key the chosen form does not have is a rejection, not an exception.

required
reversed bool

True to ask about the spell's reversed form.

False
targets Sequence[Creature | str]

The candidate targets, Creature values or location strings, as cast_from_scroll takes them. Only the count is examined. Leave it out for a spell with no targets.

()
context CastContext | None

The CastContext with what you assert about the situation. None asserts nothing.

None
ledger EffectsLedger | None

The EffectsLedger, consulted for effects on the reader that block casting. Pass None and no such effect is found, so pass the ledger you play with.

None

Returns:

Type Description
list[Rejection]

Every reason the read is illegal, as Rejection models

list[Rejection]

with structured code and params. Empty when the read may go ahead, which means

list[Rejection]

cast_from_scroll with the same arguments will not raise.

Examples:

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 validate_scroll_cast
from osrlib.data import load_monsters, load_spells

streams = RngStreams(master_seed=5)
zelia = create_character(
    name="Zelia",
    class_id="magic_user",
    alignment=Alignment.NEUTRAL,
    ruleset=Ruleset(),
    stream=streams.get(CHARACTER_CREATION_STREAM),
    starting_spell_ids=["read_magic"],
).character
zelia.level = 6  # three missiles from memory, one off a 1st-level scroll
template = load_monsters().get("goblin")
spawns = streams.get(MONSTER_SPAWN_STREAM)
goblins = [spawn_monster(template, id=f"monster-000{number}", stream=spawns) for number in (1, 2, 3)]
missile = load_spells().get("magic_missile")

refused = validate_scroll_cast(zelia, missile, "missiles", targets=goblins)
assert [rejection.code for rejection in refused] == ["magic.cast.target_count"]
assert refused[0].params["expected"] == 1  # the scroll's level, not the reader's
assert validate_scroll_cast(zelia, missile, "missiles", targets=goblins[:1]) == []

validate_turn_undead

validate_turn_undead(cleric: Caster, definition: ClassDefinition) -> list[Rejection]

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, which runs the same checks and raises if any fail. Nothing here rolls dice or changes anything.

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.

Parameters:

Name Type Description Default
cleric Caster

The Caster attempting the turning, a Character with a cleric's class definition. Read, never written.

required
definition ClassDefinition

Their class, as a ClassDefinition from load_classes.

required

Returns:

Type Description
list[Rejection]

Why the attempt cannot be made, as Rejection models

list[Rejection]

with structured code and params. Empty when the attempt may go ahead. At most one: the

list[Rejection]

first problem found ends the call.

Examples:

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"]