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
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.
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
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
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
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
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
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
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.
modes
class-attribute
instance-attribute
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
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
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 |
required |
Returns:
| Type | Description |
|---|---|
SpellTemplate
|
The |
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:
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 |
required |
level
|
int | None
|
A spell level, 1 to 6, to filter by. |
None
|
Returns:
| Type | Description |
|---|---|
SpellTemplate
|
The matching |
...
|
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)
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
The conditions a cure effect lifts. Cure light wounds' second usage lifts paralysis.
cures_effect_kinds
class-attribute
instance-attribute
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
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
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
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
The spell's printed name, such as "Cure Light Wounds". Show this, not the id.
spell_list
class-attribute
instance-attribute
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
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
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
The range line as printed. Show this to a player.
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
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
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
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
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 |
required |
reversed
|
bool
|
True to look on the spell's reversed form instead of its normal one. |
False
|
Returns:
| Type | Description |
|---|---|
SpellMode
|
The |
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
mode: TargetingMode
Which targeting mode the usage takes.
self takes no targets, single takes exactly one, up_to_n a bounded group, hd_budget as
many creatures as a rolled pool of Hit Dice pays for, area everything you supply as covered by
the shape, and gaze the gaze-attack form.
count
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.
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
The entity ids of the individual monsters the attempt reached, as many as hd_pool paid for.
destroyed_ids
class-attribute
instance-attribute
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
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
|
required | |
definition
|
ClassDefinition
|
The caster's class, as a
|
required |
catalog
|
SpellCatalog
|
The spell catalog, from |
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
|
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_feetin theCastContext.
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
|
required | |
spell
|
SpellTemplate
|
The inscribed |
required |
mode
|
str
|
Which usage of the spell, by its |
required |
reversed
|
bool
|
True to cast the spell's reversed form. |
False
|
targets
|
Sequence[Creature | str]
|
The candidate targets in your own order: |
()
|
context
|
CastContext | None
|
The |
None
|
ledger
|
EffectsLedger
|
The |
required |
clock
|
GameClock
|
The |
required |
allocator
|
Any
|
The |
required |
registry
|
dict[str, Any]
|
Every live combatant by entity id, as
|
required |
ruleset
|
Ruleset
|
The |
required |
stream
|
RngStream
|
The |
required |
effects_stream
|
RngStream
|
The stream that attaching effects draw from, conventionally
|
required |
Returns:
| Type | Description |
|---|---|
CastResult
|
The |
CastResult
|
order. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the read is illegal. Nothing is drawn or changed before the refusal. Ask
|
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
|
required | |
spell
|
SpellTemplate
|
The |
required |
mode
|
str
|
Which usage of the spell, by its |
required |
profile
|
CasterProfile
|
The caster's |
required |
reversed
|
bool
|
True to cast the spell's reversed form. |
False
|
targets
|
Sequence[Creature | str]
|
The candidate targets in your own order: |
()
|
context
|
CastContext | None
|
The |
None
|
ledger
|
EffectsLedger
|
The |
required |
clock
|
GameClock
|
The |
required |
allocator
|
Any
|
The |
required |
registry
|
dict[str, Any]
|
Every live combatant by entity id, as
|
required |
ruleset
|
Ruleset
|
The |
required |
stream
|
RngStream
|
The |
required |
effects_stream
|
RngStream
|
The stream that attaching effects draw from, conventionally
|
required |
Returns:
| Type | Description |
|---|---|
CastResult
|
The |
CastResult
|
order. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the cast is illegal. Ask
|
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 |
required |
Returns:
| Type | Description |
|---|---|
CasterProfile | None
|
The |
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
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
|
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 |
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
|
required | |
definition
|
ClassDefinition
|
The caster's class, as a
|
required |
catalog
|
SpellCatalog
|
The spell catalog, from |
required |
Returns:
| Type | Description |
|---|---|
list[Event]
|
One |
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
|
required | |
definition
|
ClassDefinition
|
The caster's class, as a
|
required |
catalog
|
SpellCatalog
|
The spell catalog, from |
required |
selections
|
Sequence[MemorizedSpell]
|
The |
required |
Returns:
| Type | Description |
|---|---|
MemorizationResult
|
A |
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 |
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:
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
|
required | |
definition
|
ClassDefinition
|
The caster's class, as a
|
required |
catalog
|
SpellCatalog
|
The spell catalog, from |
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 |
required |
target_ref
|
str
|
The entity id of the caster being attacked. |
required |
registry
|
dict[str, Any]
|
Every live combatant by entity id, as
|
required |
clock
|
GameClock
|
The |
required |
Returns:
| Type | Description |
|---|---|
list[Event]
|
An |
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
|
required | |
definition
|
ClassDefinition
|
Their class, as a |
required |
candidates
|
Sequence[MonsterInstance]
|
The monsters present, as
|
required |
ledger
|
EffectsLedger
|
The |
required |
clock
|
GameClock
|
The |
required |
allocator
|
Any
|
The |
required |
registry
|
dict[str, Any]
|
Every live combatant by entity id, as
|
required |
stream
|
RngStream
|
The |
required |
Returns:
| Type | Description |
|---|---|
TurnUndeadResult
|
The |
TurnUndeadResult
|
kind, who was reached, and every event. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the character cannot turn undead at all. Ask
|
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
|
required | |
spell
|
SpellTemplate
|
The |
required |
mode
|
str
|
Which usage of the spell, by its
|
required |
profile
|
CasterProfile | None
|
The caster's |
required |
reversed
|
bool
|
True to cast the spell's reversed form. |
False
|
targets
|
Sequence[Creature | str]
|
The candidate targets: |
()
|
context
|
CastContext | None
|
The |
None
|
ledger
|
EffectsLedger | None
|
The |
None
|
Returns:
| Type | Description |
|---|---|
list[Rejection]
|
Every reason the cast is illegal, as |
list[Rejection]
|
with structured |
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
|
required | |
spell
|
SpellTemplate
|
The inscribed |
required |
mode
|
str
|
Which usage of the spell, by its |
required |
reversed
|
bool
|
True to ask about the spell's reversed form. |
False
|
targets
|
Sequence[Creature | str]
|
The candidate targets, |
()
|
context
|
CastContext | None
|
The |
None
|
ledger
|
EffectsLedger | None
|
The |
None
|
Returns:
| Type | Description |
|---|---|
list[Rejection]
|
Every reason the read is illegal, as |
list[Rejection]
|
with structured |
list[Rejection]
|
|
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
|
required | |
definition
|
ClassDefinition
|
Their class, as a |
required |
Returns:
| Type | Description |
|---|---|
list[Rejection]
|
Why the attempt cannot be made, as |
list[Rejection]
|
with structured |
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"]