osrlib.core.effects
Attach timed effects to creatures, items, and locations, and run them against the game clock.
Three kinds of caller reach this module. A GameSession calls
EffectsLedger.advance every time it moves the game clock, which is
what makes durations run out and periodic effects fire. cast_spell calls
EffectsLedger.attach when a cast lands, so a spell's printed
duration becomes a live effect. You call both yourself when you run the rules without a session: you keep the
ledger, the clock, and the registry, and you advance them in your own loop.
The module has two layers. The condition layer is vocabulary. A condition is a named state a creature is in,
like asleep or petrified, and Condition is the closed set of them. Each
creature has its own tuple of ActiveCondition records and its own tuple
of ActiveModifier records, so a serialized creature says what is wrong
with it without the ledger beside it, and combat reads both tuples directly through
has_condition and the modifier_ helpers below.
The engine layer is EffectsLedger, which runs durations, periodic ticks,
expiry, and stacking. It is the only writer of a creature's conditions and modifiers, apart from
grant_condition,
remove_condition, and kill, which handle
the states no timed effect owns. Go through those helpers rather than assigning to creature.conditions
yourself, or a creature ends up with a condition that nothing will ever take away.
Every round boundary resolves in a fixed order: effects suspend first, then expirations, then ticks, and within each phase effects resolve in attachment order, tie-broken by effect id. A creature petrified by one effect suspends its other effects, which neither tick nor age while the stone lasts, so an adventurer who was poisoned before being turned to stone is still poisoned after stone to flesh.
Effect-internal randomness (rolled durations, onset delays, a troll's revival countdown) draws from the stream
named by EFFECTS_STREAM, so adding a draw to combat never shifts an
effect's roll.
The target parameters below take Creature, the protocol that names the
hit points, conditions, and stat modifiers these calls read. A Character
and a MonsterInstance both satisfy it, and a registry maps entity
ids to those same creatures.
Typical usage:
from osrlib.core.clock import GameClock, TimeUnit
from osrlib.core.effects import EFFECTS_STREAM, Condition, EffectDefinition, EffectsLedger, has_condition
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, IdAllocator, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.data import load_monsters
streams = RngStreams(master_seed=3)
goblin = spawn_monster(load_monsters().get("goblin"), id="monster-0001", stream=streams.get(MONSTER_SPAWN_STREAM))
registry = {"monster-0001": goblin}
ledger = EffectsLedger()
clock = GameClock()
sleep = EffectDefinition(
kind="sleep",
duration_unit=TimeUnit.TURN,
duration_amount=4,
condition=Condition.ASLEEP,
dispellable=True,
)
effect, events = ledger.attach(sleep, "monster-0001", clock=clock, allocator=IdAllocator(), registry=registry)
assert [event.code for event in events] == ["effects.effect.attached", "effects.condition.gained"]
assert has_condition(goblin, Condition.ASLEEP)
# Four turns later the duration runs out and the ledger takes the condition back.
expiry = ledger.advance(clock, 4, TimeUnit.TURN, registry, stream=streams.get(EFFECTS_STREAM))
assert [event.code for event in expiry] == ["effects.effect.expired", "effects.condition.removed"]
assert not has_condition(goblin, Condition.ASLEEP)
assert ledger.effects == []
EFFECTS_STREAM
module-attribute
EFFECTS_STREAM = StreamName.EFFECTS
The random-number stream name for effect-internal draws: rolled durations, onsets, and revival countdowns.
Build an RngStreams from your session's master seed and pass
streams.get(EFFECTS_STREAM) wherever EffectsLedger.attach and
EffectsLedger.advance ask for a stream. Each subsystem draws from
its own named stream, so an extra attack roll never shifts the round on which a charmed creature saves itself
free.
MODIFIER_KINDS
module-attribute
MODIFIER_KINDS = frozenset(
{
"attack_bonus",
"damage_bonus",
"morale_bonus",
"save_bonus",
"ac_bonus",
"ac_set",
"ac_set_vs_missile",
"attack_penalty_of_attackers",
"damage_reduction_per_die",
"damage_multiplier",
"melee_damage_multiplier",
"missile_immunity_nonmagical",
"strength_set",
"weapon_damage_dice_bonus",
"counts_as_magical",
"magical_healing_half",
}
)
The closed set of statistic names a modifier can adjust.
These are the only values a ModifierSpec accepts for its kind.
Combat looks each of these up by name while it resolves a roll, so a kind nothing reads changes nothing.
Constructing a ModifierSpec with a name outside this set raises a validation error rather than failing
silently, which is why the set is closed. Adding a kind means teaching combat to read it, so when you're
authoring your own content, express what you want with a kind already here.
What each one does:
attack_bonusadjusts the bearer's own attack rolls, anddamage_bonusits damage rolls.attack_penalty_of_attackersadjusts the rolls of anyone attacking the bearer, which is how a ward works.save_bonusadjusts the bearer's saving throws, narrowed byelement,save_categories, orversus_other_alignment.morale_bonusadjusts the bearer's side's morale checks throughmorale_modifier.ac_bonusimproves the bearer's armour class by its value,ac_setreplaces the armour class outright when the set value is better, andac_set_vs_missiledoes the same against missile attacks only.damage_reduction_per_dietakes points off incoming damage, one per die rolled, for the namedelement.damage_multipliermultiplies the bearer's weapon damage andmelee_damage_multiplierits melee damage, after the flat bonuses are added.weapon_damage_dice_bonusadds its owndiceto the bearer's weapon damage.strength_setreplaces the strength score the bearer's melee modifiers derive from, which is how Gauntlets of Ogre Power grant a fixed 18 and a Ring of Weakness a fixed 3.counts_as_magicalmakes the bearer's attacks count as magical, andmissile_immunity_nonmagicalabsorbs non-magical missiles aimed at the bearer.magical_healing_halfhalves the hit points magical healing restores to the bearer.
ActiveCondition
Bases: BaseModel
One condition a creature currently has, paired with the effect that owns it.
You read these off a creature's conditions tuple rather than building them:
grant_condition and
EffectsLedger.attach put them there. To ask whether a creature
has a condition without caring which effect granted it, call
has_condition instead of scanning the tuple.
The pairing is what lets two effects grant the same condition and each take back only its own: a creature charmed twice has two records, and releasing one leaves the other standing. Records compare by value, so the same condition from the same effect is never stored twice.
Examples:
from osrlib.core.effects import ActiveCondition, Condition
active = ActiveCondition(condition=Condition.ASLEEP, effect_id="effect-0001")
assert active.condition is Condition.ASLEEP
assert active.model_dump(mode="json") == {"condition": "asleep", "effect_id": "effect-0001"}
effect_id
class-attribute
instance-attribute
effect_id: str | None = None
The id of the ActiveEffect that granted the condition and will take
it back. None marks a condition no timed effect owns, which in the core rules means dead.
ActiveEffect
Bases: BaseModel
One effect currently running on a creature, item, or location.
EffectsLedger.attach returns one of these and keeps it in the
ledger's effects list, and EffectsLedger.active_on finds
them again. You read one to ask how long an effect has left or what it's tracking, and you pass its
effect_id to EffectsLedger.release to end it early. Build
one yourself only when you're restoring a saved game. In play, attaching is what creates them.
effect_id
instance-attribute
effect_id: str
The effect's id, allocated at attach time by the
IdAllocator you passed, in the form effect-0001. The conditions and
modifiers the effect granted record this id, which is how they are matched back when it ends.
definition
instance-attribute
definition: EffectDefinition
The EffectDefinition this effect was attached from, kept here
so the ledger can tick and expire it without looking anything up.
target_ref
instance-attribute
target_ref: str
What the effect is on: an entity id for a creature, or a location string for something that sits in a place, as a burning oil pool or a stationary silence does. A location reference is not a key in the registry, so an effect on a location grants no conditions or modifiers.
attached_round
class-attribute
instance-attribute
The absolute round the effect was attached on, counted from the start of the game clock. Ticks are counted from here, and it is the first key effects are ordered by when several resolve in one round.
expires_round
class-attribute
instance-attribute
expires_round: int | None = None
The absolute round the effect expires on, or None when it has no duration or is permanent. Suspension
pushes it forward one round for each round the bearer spends petrified, so a suspended effect keeps the time
it had left.
caster_level
class-attribute
instance-attribute
caster_level: int | None = None
The level of the caster whose spell attached this effect, recorded when the attach passed one. Dispel magic rolls against it to decide whether the effect survives.
state
class-attribute
instance-attribute
The effect's own running bookkeeping, written by its tick and expiry behaviors: the round a troll revives on, the number of consecutive rest days a slowed-healing effect has counted. Read it if you want to show a countdown, and leave the writing to the ledger.
ActiveModifier
Bases: ModifierSpec
One live modifier on a creature, paired with the effect that granted it.
You read these off a creature's stat_modifiers tuple. Attaching an effect turns each of its
ModifierSpec entries into one of these, and expiry or release takes
them back, the same way conditions work. Nothing else writes the tuple, so a creature's modifiers always
trace to a live effect.
Read them through modifier_total,
modifier_values, modifier_dice,
and has_modifier rather than scanning the tuple: those helpers apply
the scope filters and the rule that spell bonuses don't add up.
effect_id
instance-attribute
effect_id: str
The id of the ActiveEffect that granted the modifier and will take
it back.
Condition
Bases: StrEnum
The closed set of named states a creature can be in.
Read a creature's conditions with has_condition. Put one on a creature
by attaching an effect that brings it, through
EffectsLedger.attach, so the ledger takes it away again when the
duration runs out. Reach for grant_condition only for a state no
timed effect owns.
Some members drive rules in osrlib.core.combat and
osrlib.core.spells, and the rest are states the rest of the game acts on. The member
docstrings below say which is which, so you know whether granting one changes a roll or only tells your
interface what to show.
One rule covers every member. A creature whose template lists a condition in its defenses'
condition_immunities never takes that condition, whether you call
grant_condition or attach an effect that brings it, so the member
that looks inert to the rest of the core rules still decides which monsters a spell can touch.
The values are the lowercase strings, and they serialize into creatures and saved games. A renamed value is a
schema_version bump, not an edit.
PARALYSED
class-attribute
instance-attribute
Frozen in place. The creature cannot attack, cast, or move, it counts toward a side's morale check for half the side being incapacitated, and a melee attack against it hits automatically. Cure light wounds cures it.
ASLEEP
class-attribute
instance-attribute
Unconscious. Everything paralysed does, and one more rule of its own: a melee hit with a bladed weapon
kills the sleeper outright, with no damage roll.
BLIND
class-attribute
instance-attribute
Unable to see. validate_attack rejects the creature's attacks with
combat.attack.attacker_blind.
CHARMED
class-attribute
instance-attribute
Under a charm. The monsters that cannot be charmed, the undead and the golems among them, list it in
their condition_immunities, so a charm aimed at one of those takes hold of nothing. Past that, the charmed
creature's obedience is yours to play out, and the recurring save that can end the charm rides the effect's
charm_resave tick.
PETRIFIED
class-attribute
instance-attribute
Turned to stone. Everything paralysed does, and it suspends the creature's other effects, which neither
tick nor age until the stone is undone. Stone is not dead: stone to flesh cures it and the creature picks
up where it left off.
DISEASED
class-attribute
instance-attribute
Sick with a disease. Magical healing is refused outright, and natural rest heals on the slower cadence the
effect names, or not at all when you pass no ledger to
natural_healing. Cure disease cures it.
EXHAUSTED
class-attribute
instance-attribute
Spent from a forced march or a night without rest. The penalties ride the effect's modifiers rather than the condition, so past the immunity rule nothing in the core rules turns on it and your interface can show it.
LYCANTHROPY_INCUBATION
class-attribute
instance-attribute
Infected by a lycanthrope's bite and not yet transformed. Vocabulary only: nothing in the core rules grants it, nothing past the immunity rule turns on it, and the transformation is yours to run.
AVERTED_EYES
class-attribute
instance-attribute
Fighting with eyes turned away from a gaze attack. resolve_gaze skips
the creature, and the attack penalty for fighting blind is yours to pass in the attack context.
POISONED
class-attribute
instance-attribute
Poisoned. The monsters that cannot be poisoned, the undead and the cave locust among them, list it in
their condition_immunities, so a poison aimed at one of those takes hold of nothing, and neutralize
poison cures it and can bring back a character who died of poison within the last ten rounds. The killing
is the effect's rather than the condition's: a poison that kills carries an expiry of death, which fires
when the onset runs out.
DEAD
class-attribute
instance-attribute
Killed. Granted by kill rather than by any effect, so its effect_id is
None. It blocks acting and healing, the battle machine passes the creature over when it picks targets, and
only a spell that removes the condition brings the creature back.
SILENCED
class-attribute
instance-attribute
Unable to speak. validate_cast rejects the creature's casting with
magic.cast.caster_incapacitated.
ENTANGLED
class-attribute
instance-attribute
Held fast, as by a web. cannot_move reports True, and the creature
can still attack and cast.
AFRAID
class-attribute
instance-attribute
Panicked by a fear effect. Remove fear cures it, and when the fear was magical the subject first saves
versus spells at +1 for each level of the curing caster, keeping the fear on a failure. The battle machine in
osrlib.crawl.battle treats the creature as routed.
FEEBLEMINDED
class-attribute
instance-attribute
Robbed of the wit to cast. validate_cast rejects the creature's
casting with magic.cast.caster_incapacitated.
INVISIBLE
class-attribute
instance-attribute
Unseen. Past the immunity rule nothing in the core rules turns on it. The battle machine leaves the creature out of the ranks an enemy picks targets from.
TURNED
class-attribute
instance-attribute
Driven off by a cleric's turning, which is where it comes from:
turn_undead attaches the effect that grants it. Past the immunity rule
nothing in the core rules turns on it, and the battle and encounter machines treat the creature as
fleeing.
CONFUSED
class-attribute
instance-attribute
Acting at random. Past the immunity rule nothing in the core rules turns on it. The battle machine chooses the creature's action instead of letting you choose.
EffectDefinition
Bases: BaseModel
The blueprint for an effect: how long it lasts, what it does while it lasts, and what happens when it ends.
Write one of these for each spell, ability, or hazard you want to put on a creature, then hand it to
EffectsLedger.attach with the entity id or location it applies
to. Attaching turns the blueprint into a live ActiveEffect. The
blueprint itself is frozen, so one definition serves every creature you attach it to. The compiled spell and
magic item data already includes definitions for the published content, so you write your own only when you're
authoring something new.
Give the effect a duration through duration_unit with either duration_amount or duration_dice. Leave
the unit out and the effect runs until you release it. Set permanent for something only magic undoes.
Examples:
A four-turn sleep that grants a condition, and an indefinite +1 to attacks that grants a modifier:
from osrlib.core.clock import TimeUnit
from osrlib.core.effects import Condition, EffectDefinition, ModifierSpec
sleep = EffectDefinition(
kind="sleep",
duration_unit=TimeUnit.TURN,
duration_amount=4,
condition=Condition.ASLEEP,
dispellable=True,
)
bless = EffectDefinition(kind="bless", modifiers=(ModifierSpec(kind="attack_bonus", value=1),))
assert sleep.stacking == "stack" # the default: a second sleep is a second effect
assert bless.duration_unit is None # no unit means it runs until released
kind
class-attribute
instance-attribute
The effect's name, like "sleep" or "regeneration". Stacking compares kinds, and
EffectsLedger.active_on filters on it, so pick one name per
thing and use it everywhere. Any non-empty string is accepted.
duration_unit
class-attribute
instance-attribute
duration_unit: TimeUnit | None = None
The unit the duration is counted in: rounds, turns, or days. None means the effect has no duration and
runs until you release it.
duration_amount
class-attribute
instance-attribute
duration_amount: int | None = None
A fixed duration, counted in duration_unit. Use this or duration_dice, not both.
duration_dice
class-attribute
instance-attribute
duration_dice: str | None = None
A dice expression rolled once at attach time to set the duration, like "2d6". Rolling one needs the
effects stream, so attaching a definition that uses dice without passing stream raises ValueError.
Parsed at construction by parse.
permanent
class-attribute
instance-attribute
permanent: bool = False
True means the effect never expires on its own, which is how petrification lasts until someone casts
stone to flesh. It says nothing about whether dispel magic can end it: that is dispellable.
tick
class-attribute
instance-attribute
tick: str | None = None
The name of a periodic behavior the ledger runs while the effect lasts. There are two.
"regeneration" heals the bearer and can bring a troll back from death. "charm_resave" rolls a saving
throw that ends the effect when it passes. Any other name raises ValueError at the first tick.
tick_interval_rounds
class-attribute
instance-attribute
How many rounds pass between ticks. The default of 1 ticks every round. A charm sets this from the subject's intelligence, so the dull re-save monthly and the bright daily.
stacking
class-attribute
instance-attribute
stacking: Literal['stack', 'refresh', 'ignore'] = 'stack'
What happens when the same kind is attached to a target that already has one. "stack" adds a second
effect. "refresh" restarts the existing effect's duration and attaches nothing new. "ignore" does
nothing at all, and the attach returns no effect.
expiry
class-attribute
instance-attribute
expiry: str | None = None
The name of an outcome the ledger resolves when the duration runs out, on top of the ordinary ending
rather than in place of it. The effect is dropped, the
EffectExpiredEvent goes out, the condition and modifiers come
off, and the outcome runs last. There are three. "death" kills the bearer, which is how a delayed poison
works. "splash_damage" deals the second application of burning oil or holy water.
"weakness_strength_set" replaces the finished onset with the curse itself. Any other name raises
ValueError at expiry.
condition
class-attribute
instance-attribute
condition: Condition | None = None
A Condition granted when the effect attaches and taken back when it
expires or is released. A target immune to the condition is never affected, and the attach returns no
effect.
modifiers
class-attribute
instance-attribute
modifiers: tuple[ModifierSpec, ...] = ()
The ModifierSpec adjustments granted while the effect lasts and
taken back when it ends.
dispellable
class-attribute
instance-attribute
dispellable: bool = False
True marks the effect as something dispel magic can end. Everything
cast_spell attaches is dispellable, permanent effects included, while what
a monster inflicts is not.
params
class-attribute
instance-attribute
Whatever else the effect's tick or expiry behavior needs to read: regeneration's per_round,
delay_rounds, and revive, a splash douse's dice and element, a slowed-healing effect's
healing_rest_days. Each behavior documents the keys it reads, and keys it doesn't recognize are left
untouched.
EffectsLedger
Bases: BaseModel
The engine that contains every live effect and runs it against the game clock.
One ledger covers a whole game: a GameSession creates one and keeps it
for the life of the session, and a caller running the rules without a session creates one and keeps it
alongside the GameClock. It is a pydantic model, so saving a game is saving
the ledger along with the clock and the creatures.
Four calls are the whole interface. attach puts an effect on a
target, active_on asks what is on one,
release ends an effect early, and
advance moves the clock and resolves everything the passing
time triggers. Nothing happens without advance: an effect with a duration sits there until the clock
reaches its expiry round, so advance the clock through the ledger rather than writing to the clock directly.
Examples:
from osrlib.core.clock import GameClock, TimeUnit
from osrlib.core.effects import EFFECTS_STREAM, Condition, EffectDefinition, EffectsLedger, has_condition
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, IdAllocator, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.data import load_monsters
streams = RngStreams(master_seed=3)
spawn = streams.get(MONSTER_SPAWN_STREAM)
goblin = spawn_monster(load_monsters().get("goblin"), id="monster-0001", stream=spawn)
registry = {"monster-0001": goblin}
ledger = EffectsLedger()
clock = GameClock()
web = EffectDefinition(
kind="web",
duration_unit=TimeUnit.TURN,
duration_amount=2,
condition=Condition.ENTANGLED,
)
effect, _ = ledger.attach(web, "monster-0001", clock=clock, allocator=IdAllocator(), registry=registry)
assert effect is not None and effect.expires_round == 120 # two turns of sixty rounds
# One turn on, the web still holds.
ledger.advance(clock, 1, TimeUnit.TURN, registry, stream=streams.get(EFFECTS_STREAM))
assert has_condition(goblin, Condition.ENTANGLED)
# One more, and it lets go.
ledger.advance(clock, 1, TimeUnit.TURN, registry, stream=streams.get(EFFECTS_STREAM))
assert not has_condition(goblin, Condition.ENTANGLED)
effects
class-attribute
instance-attribute
effects: list[ActiveEffect] = []
Every live ActiveEffect, in the order they were attached. Read it to
see everything running at once. To find the effects on one target, call
active_on. Attaching, releasing, and expiry maintain the
list, so leave the writing to them.
active_on
active_on(target_ref: str, kind: str | None = None) -> list[ActiveEffect]
Return the effects currently running on one target.
Use this to answer questions about a creature's situation that the condition and modifier helpers cannot:
whether a mirror image is still up, how many rounds a light source has left, whether an anti-magic
shell is blocking a cast. Pass kind when you know which effect you're after, and you get either an
empty list or the ones that match.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
target_ref
|
str
|
The entity id or location string the effects are attached to. |
required |
kind
|
str | None
|
An |
None
|
Returns:
| Type | Description |
|---|---|
list[ActiveEffect]
|
The matching effects, in the order they were attached. The list is new, but the effects in it are the |
list[ActiveEffect]
|
ledger's own, so a change to one changes what the ledger runs. |
attach
attach(
definition: EffectDefinition,
target_ref: str,
*,
clock: GameClock,
allocator: Any,
registry: Mapping[str, Any] | None = None,
stream: RngStream | None = None,
caster_level: int | None = None
) -> tuple[ActiveEffect | None, list[Event]]
Put an effect on a target and return it with the events the attach produced.
This is the way an effect starts. Build an EffectDefinition,
call this with the target's entity id, and the ledger works out when the effect expires, grants the
condition and modifiers it brings, and starts counting its ticks. Afterwards, keep the clock moving
through advance or nothing further happens.
When a spell is what attaches the effect, cast_spell makes this call
for you and hands back the same events.
The call can hand back None instead of an effect, so check before you use it. Two cases produce it:
the definition's stacking is "ignore" and the target already has that kind, or the target's template
lists the definition's condition among its condition_immunities. A stacking of "refresh" is
different again: you get the existing effect back with its duration restarted, and no events.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
definition
|
EffectDefinition
|
The blueprint to attach. |
required |
target_ref
|
str
|
The entity id of the creature, or the location string of the place, to attach to. An id
that is not a key in |
required |
clock
|
GameClock
|
The game clock. The current round anchors the duration and the tick count. The clock is read, not advanced. |
required |
allocator
|
Any
|
The |
required |
registry
|
Mapping[str, Any] | None
|
The live creatures by entity id, so the attach can grant conditions and modifiers. Pass None, or leave the target out of it, and the effect runs with nothing to write to. |
None
|
stream
|
RngStream | None
|
The effects stream from |
None
|
caster_level
|
int | None
|
The casting caster's level, recorded on the effect for dispel magic to roll against. |
None
|
Returns:
| Type | Description |
|---|---|
ActiveEffect | None
|
A pair of the attached effect and its events. The events are the |
list[Event]
|
|
tuple[ActiveEffect | None, list[Event]]
|
condition the target takes, the |
tuple[ActiveEffect | None, list[Event]]
|
|
tuple[ActiveEffect | None, list[Event]]
|
nothing was attached, and on a refresh it is the existing effect with an empty event list. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the definition has |
release
End an effect before its duration runs out.
Call this when something in the game cuts an effect short: a dispel magic, a charmed creature making its save, a light source put out, an invisible creature attacking and losing the invisibility. The condition and the modifiers the effect granted come off with it.
Find the id first with active_on, and copy the list
before you release from it, since releasing changes the ledger's own list as you go. To end an effect
because time ran out, do nothing: advance expires it for
you and emits EffectExpiredEvent instead.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
effect_id
|
str
|
The id of the effect to end, from its
|
required |
registry
|
Mapping[str, Any] | None
|
The live creatures by entity id, so the condition and modifiers can be taken back. Leave it out and the effect is dropped from the ledger with the creature still under them. |
None
|
Returns:
| Type | Description |
|---|---|
list[Event]
|
The |
list[Event]
|
condition to a creature in the registry, the |
list[Event]
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If the ledger has no effect with that id, which means it already expired or was already released. |
advance
advance(
clock: GameClock,
n: int,
unit: TimeUnit,
registry: Mapping[str, Any],
*,
stream: RngStream,
allocator: Any | None = None
) -> list[Event]
Move the game clock forward and resolve everything the passing time triggers.
This is what makes an effect with a duration actually end, and a regenerating troll actually heal.
Advance the clock through this call rather than writing to
GameClock.rounds yourself: the clock records elapsed time and nothing
about what is attached to whom, so time you add behind the ledger's back resolves no effects at all. A
GameSession makes this call for you inside
advance_rounds and
advance_turns.
Every round in the span is resolved, one at a time, in the same order. Effects whose bearer is petrified by another effect suspend first, and a suspended effect neither ticks nor ages: its expiry moves forward one round for each round it spends suspended. Then expirations resolve, then ticks. Within each of those phases, effects go in attachment order, tie-broken by effect id, so the same span always produces the same events in the same order.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
clock
|
GameClock
|
The game clock. It is advanced in place, so it shows the new time when the call returns. |
required |
n
|
int
|
How many units to advance. Advancing a long span resolves every round in it, so a day is thousands of rounds of work. |
required |
unit
|
TimeUnit
|
The unit |
required |
registry
|
Mapping[str, Any]
|
The live creatures by entity id, so conditions, modifiers, and hit points can be written. A session keeps one across play, and a plain dict works. |
required |
stream
|
RngStream
|
The effects stream from |
required |
allocator
|
Any | None
|
The |
None
|
Returns:
| Type | Description |
|---|---|
list[Event]
|
Every event the span produced, in the order it was produced, ready to append to your log. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If a tick or expiry behavior names something osrlib doesn't define, or if a follow-on
attach needed an |
Examples:
A troll that took ten points of damage regenerates three of them a round:
from osrlib.core.clock import GameClock, TimeUnit
from osrlib.core.effects import EFFECTS_STREAM, EffectsLedger, regeneration_definition
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, IdAllocator, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.data import load_monsters
streams = RngStreams(master_seed=9)
template = load_monsters().get("troll")
troll = spawn_monster(template, id="monster-0001", stream=streams.get(MONSTER_SPAWN_STREAM))
registry = {"monster-0001": troll}
troll.current_hp -= 10
ledger = EffectsLedger()
clock = GameClock()
definition = regeneration_definition(template.abilities[0].params)
ledger.attach(definition, "monster-0001", clock=clock, allocator=IdAllocator(), registry=registry)
events = ledger.advance(clock, 2, TimeUnit.ROUND, registry, stream=streams.get(EFFECTS_STREAM))
assert [event.code for event in events] == [
"effects.effect.ticked",
"combat.healing.applied",
"combat.state.hit_points",
"effects.effect.ticked",
"combat.healing.applied",
"combat.state.hit_points",
]
assert troll.current_hp == troll.max_hp - 4 # six of the ten points back
ModifierSpec
Bases: BaseModel
One adjustment to a combat statistic that an effect grants for as long as it lasts.
You write these when you author a spell, a magic item, or an effect of your own, and put them in an
EffectDefinition's modifiers. Attaching that definition turns
each spec into an ActiveModifier on the creature, and combat reads
them back through modifier_total and its siblings. Nothing takes a
bare spec: attaching an effect is the only way one reaches a creature. A spec is frozen, so the same one can
sit in several definitions.
The scope fields narrow when the modifier counts. Leave them at their defaults and the modifier applies to every roll of its kind.
Examples:
from osrlib.core.effects import ModifierSpec
bless = ModifierSpec(kind="attack_bonus", value=1)
resist_fire = ModifierSpec(kind="save_bonus", value=2, element="fire")
assert bless.dice is None and not bless.from_item
assert resist_fire.element == "fire"
kind
instance-attribute
kind: str
Which statistic the modifier adjusts. Must be one of MODIFIER_KINDS
or construction raises a validation error.
value
class-attribute
instance-attribute
value: int = 0
The signed adjustment: bless's +1 attack bonus, protection from evil's -1 on attackers, the armour
class a ac_set kind sets. Leave it at 0 for a kind that uses dice or acts as a flag.
dice
class-attribute
instance-attribute
dice: str | None = None
A dice expression rolled instead of adding value, for the kinds that grant dice: striking's "1d6" of
extra weapon damage. Parsed at construction by parse, so a malformed expression
raises a validation error rather than failing at the table.
element
class-attribute
instance-attribute
element: str | None = None
Narrows the modifier to one damage or save element, like "fire" for resist fire. A modifier scoped
to an element counts only when the caller names that element in the roll.
versus_other_alignment
class-attribute
instance-attribute
versus_other_alignment: bool = False
True narrows the modifier to rolls against creatures of a different alignment, which is how protection from evil works. It counts only when the caller attests that the alignments differ.
save_categories
class-attribute
instance-attribute
Narrows a save bonus to the named saving throw categories, as a Displacer Cloak covers petrification, rods, spells, staves, and wands but nothing else. Empty means every category.
melee_only
class-attribute
instance-attribute
melee_only: bool = False
True narrows the modifier to melee attacks, which is how the Displacer Cloak's -2 on attackers leaves missile attacks alone. It counts only when the caller attests the attack is melee.
from_item
class-attribute
instance-attribute
from_item: bool = False
True marks the modifier as coming from a magic item rather than a spell, which exempts it from the rule that only the largest spell bonus counts. Item modifiers add up on top of the capped spell total. Set it on potion effects and ward scrolls as well as worn items: the rule covers magic items in general.
grant_condition
Put a condition on a creature and return the event that says so.
Call this for a state no timed effect owns, the way kill does for dead. When
the state has a duration, put the condition on an
EffectDefinition and attach that with
EffectsLedger.attach instead, and the ledger takes the
condition back on its own when the duration runs out. A condition granted here stays until you call
remove_condition with the same effect_id.
The call replaces the creature's conditions tuple, so pass a live creature rather than a copy, and append
the returned events to whatever log your caller is building.
Two cases grant nothing and return no events. A creature whose template lists the condition in its
defenses' condition_immunities is never affected, which is how a skeleton shrugs off sleep. A second
grant of the same condition from the same effect changes nothing, because the creature already has that
record.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
target
|
Creature
|
The |
required |
condition
|
Condition
|
The condition to grant. |
required |
effect_id
|
str | None
|
The id of the effect that owns the condition and will take it back, or |
required |
Returns:
| Type | Description |
|---|---|
list[Event]
|
A list of one |
list[Event]
|
the creature is immune or already has the same record. |
Examples:
from osrlib.core.effects import Condition, grant_condition, has_condition
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.data import load_monsters
streams = RngStreams(master_seed=5)
spawn = streams.get(MONSTER_SPAWN_STREAM)
goblin = spawn_monster(load_monsters().get("goblin"), id="monster-0001", stream=spawn)
events = grant_condition(goblin, Condition.AFRAID, "effect-0001")
assert [event.code for event in events] == ["effects.condition.gained"]
assert has_condition(goblin, Condition.AFRAID)
# The same grant a second time changes nothing and says nothing.
assert grant_condition(goblin, Condition.AFRAID, "effect-0001") == []
has_condition
Return whether a creature currently has a condition.
This is the read side of the condition layer, and the call combat itself makes. Use it wherever your code
asks "is this creature asleep", instead of scanning the creature's conditions tuple, so a creature with
the same condition from two effects still reads as having it once.
It doesn't care which effect granted the condition. When you need that, read the creature's conditions
tuple of ActiveCondition records directly.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
target
|
Creature
|
The |
required |
condition
|
Condition
|
The condition to look for. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True when the creature has that condition from any source. |
Examples:
from osrlib.core.effects import Condition, grant_condition, has_condition
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.data import load_monsters
streams = RngStreams(master_seed=5)
spawn = streams.get(MONSTER_SPAWN_STREAM)
goblin = spawn_monster(load_monsters().get("goblin"), id="monster-0001", stream=spawn)
assert not has_condition(goblin, Condition.AFRAID)
grant_condition(goblin, Condition.AFRAID, "effect-0001")
assert has_condition(goblin, Condition.AFRAID)
has_modifier
Return whether a creature has any modifier of one kind.
Use this for the kinds that act as flags rather than numbers, where the presence of the modifier is the whole
rule: counts_as_magical, missile_immunity_nonmagical, and magical_healing_half. For a kind that uses
a number, call modifier_total, whose 0 means "no adjustment" rather
than "not present".
It ignores the scope fields, so a modifier narrowed to one element still reports True here. Where the scope
matters, go through modifier_values.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
target
|
Creature
|
The |
required |
kind
|
str
|
The statistic to look for, one of |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True when the creature has at least one modifier of that kind. |
Examples:
from osrlib.core.clock import GameClock
from osrlib.core.effects import EffectDefinition, EffectsLedger, ModifierSpec, has_modifier
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, IdAllocator, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.data import load_monsters
streams = RngStreams(master_seed=5)
spawn = streams.get(MONSTER_SPAWN_STREAM)
goblin = spawn_monster(load_monsters().get("goblin"), id="monster-0001", stream=spawn)
registry = {"monster-0001": goblin}
enchanted = EffectDefinition(
kind="striking",
modifiers=(ModifierSpec(kind="counts_as_magical", value=1),),
)
ledger = EffectsLedger()
ledger.attach(enchanted, "monster-0001", clock=GameClock(), allocator=IdAllocator(), registry=registry)
assert has_modifier(goblin, "counts_as_magical")
assert not has_modifier(goblin, "missile_immunity_nonmagical")
kill
Kill a creature outright: hit points to zero, the dead condition, and the death events.
B/X kills a creature the moment it is reduced to zero hit points or fewer, and
deal_damage calls this for you when damage takes a creature that far.
Call it yourself for a death that skips the damage pipeline: a failed save against finger of death, a
delayed poison whose onset ran out, a creature you're removing from play by fiat.
Death is granted here rather than through an effect, so the dead condition has no effect_id. Calling
twice is safe: a creature that's already dead returns no events and isn't killed again.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
target
|
Creature
|
The |
required |
permanent
|
bool
|
True when a regenerating creature can no longer come back, which for a troll means its non-regenerable damage has reached its maximum hit points. It changes the death event's code, not the outcome. |
False
|
Returns:
| Type | Description |
|---|---|
list[Event]
|
The |
list[Event]
|
|
list[Event]
|
|
list[Event]
|
the creature was already dead. |
Examples:
from osrlib.core.effects import Condition, has_condition, kill
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.data import load_monsters
streams = RngStreams(master_seed=5)
spawn = streams.get(MONSTER_SPAWN_STREAM)
goblin = spawn_monster(load_monsters().get("goblin"), id="monster-0001", stream=spawn)
events = kill(goblin)
assert [event.code for event in events] == [
"effects.condition.gained",
"combat.death.died",
"combat.state.hit_points",
]
assert goblin.current_hp == 0
assert has_condition(goblin, Condition.DEAD)
assert kill(goblin) == [] # already dead
modifier_dice
Return the dice expression of a creature's dice-valued modifier of one kind.
A few modifiers grant dice instead of a flat number, striking's extra "1d6" of weapon damage among them.
Call this to find them, then roll the expression yourself with roll. For flat
adjustments, call modifier_total instead.
Only the first matching modifier is returned, which is the no-stacking rule applied to dice: a creature under two strikings rolls one extra die, not two.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
target
|
Creature
|
The |
required |
kind
|
str
|
The statistic to look for, one of |
required |
Returns:
| Type | Description |
|---|---|
str | None
|
The dice expression, in the notation |
str | None
|
creature has no dice-valued modifier of that kind. |
Examples:
from osrlib.core.clock import GameClock, TimeUnit
from osrlib.core.effects import EffectDefinition, EffectsLedger, ModifierSpec, modifier_dice
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, IdAllocator, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.data import load_monsters
streams = RngStreams(master_seed=5)
spawn = streams.get(MONSTER_SPAWN_STREAM)
goblin = spawn_monster(load_monsters().get("goblin"), id="monster-0001", stream=spawn)
registry = {"monster-0001": goblin}
striking = EffectDefinition(
kind="striking",
duration_unit=TimeUnit.TURN,
duration_amount=6,
modifiers=(ModifierSpec(kind="weapon_damage_dice_bonus", dice="1d6"),),
)
ledger = EffectsLedger()
ledger.attach(striking, "monster-0001", clock=GameClock(), allocator=IdAllocator(), registry=registry)
assert modifier_dice(goblin, "weapon_damage_dice_bonus") == "1d6"
assert modifier_dice(goblin, "damage_bonus") is None
modifier_total
modifier_total(
target: Creature,
kind: str,
*,
element: str | None = None,
versus_differs: bool = False,
save_category: str | None = None,
melee: bool = False
) -> int
Return the one number to add to a roll for a creature's modifiers of one kind.
This is the call combat makes, and the one you want when you're resolving a roll of your own. It reads the
same modifiers modifier_values returns and folds them into a single
signed adjustment, applying the rule that spells affecting the same statistic don't combine: only the
largest bonus and the largest penalty count. Two blesses give +1, not +2, while a bless and a blight
cancel out.
Modifiers marked from_item sit outside that rule and are added on top, all of them, because the
no-stacking rule covers spells rather than magic items. The scope arguments work exactly as they do for
modifier_values.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
target
|
Creature
|
The |
required |
kind
|
str
|
The statistic to total, one of |
required |
element
|
str | None
|
The damage or save element in play, like |
None
|
versus_differs
|
bool
|
True when the other creature in the roll has a different alignment from the target. |
False
|
save_category
|
str | None
|
The saving throw category in play. Leave it None outside a saving throw. |
None
|
melee
|
bool
|
True when the attack in play is melee. |
False
|
Returns:
| Type | Description |
|---|---|
int
|
The signed adjustment to add to the roll, and 0 when nothing applies. |
Examples:
Two blessings and one blight, all on the same goblin, come to a single point of bonus and a single point of penalty:
from osrlib.core.clock import GameClock, TimeUnit
from osrlib.core.effects import EffectDefinition, EffectsLedger, ModifierSpec, modifier_total, modifier_values
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, IdAllocator, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.data import load_monsters
streams = RngStreams(master_seed=5)
spawn = streams.get(MONSTER_SPAWN_STREAM)
goblin = spawn_monster(load_monsters().get("goblin"), id="monster-0001", stream=spawn)
registry = {"monster-0001": goblin}
ledger, clock, allocator = EffectsLedger(), GameClock(), IdAllocator()
bless = EffectDefinition(
kind="bless",
duration_unit=TimeUnit.TURN,
duration_amount=6,
modifiers=(ModifierSpec(kind="attack_bonus", value=1),),
)
blight = EffectDefinition(
kind="blight",
duration_unit=TimeUnit.TURN,
duration_amount=6,
modifiers=(ModifierSpec(kind="attack_bonus", value=-1),),
)
for definition in (bless, bless, blight):
ledger.attach(definition, "monster-0001", clock=clock, allocator=allocator, registry=registry)
assert modifier_values(goblin, "attack_bonus") == [1, 1, -1]
assert modifier_total(goblin, "attack_bonus") == 0
modifier_values
modifier_values(
target: Creature,
kind: str,
*,
element: str | None = None,
versus_differs: bool = False,
save_category: str | None = None,
melee: bool = False
) -> list[int]
Return every modifier value of one kind that applies to the situation you describe.
Use this when you need the individual values rather than a single number: the armour class rules read the
ac_set values one at a time and keep the best. For the ordinary case, where you want one number to add to a
roll, call modifier_total, which also applies the rule that spell
bonuses don't add up.
The keyword arguments describe the roll in play, and a modifier narrowed to something you don't name is
left out. An element-scoped modifier counts only when you pass its element, an alignment-scoped one only
when you pass versus_differs=True, a category-scoped save bonus only when you pass one of its categories,
and a melee-only modifier only when you pass melee=True.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
target
|
Creature
|
The |
required |
kind
|
str
|
The statistic to look for, one of |
required |
element
|
str | None
|
The damage or save element in play, like |
None
|
versus_differs
|
bool
|
True when the other creature in the roll has a different alignment from the target. |
False
|
save_category
|
str | None
|
The saving throw category in play. Leave it None outside a saving throw. |
None
|
melee
|
bool
|
True when the attack in play is melee. |
False
|
Returns:
| Type | Description |
|---|---|
list[int]
|
The signed values of the modifiers that apply, in the order their effects were attached. Empty when none |
list[int]
|
apply. |
Examples:
from osrlib.core.clock import GameClock, TimeUnit
from osrlib.core.effects import EffectDefinition, EffectsLedger, ModifierSpec, modifier_values
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, IdAllocator, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.data import load_monsters
streams = RngStreams(master_seed=5)
spawn = streams.get(MONSTER_SPAWN_STREAM)
goblin = spawn_monster(load_monsters().get("goblin"), id="monster-0001", stream=spawn)
registry = {"monster-0001": goblin}
resist_fire = EffectDefinition(
kind="resist_fire",
duration_unit=TimeUnit.TURN,
duration_amount=12,
modifiers=(ModifierSpec(kind="save_bonus", value=2, element="fire"),),
)
ledger = EffectsLedger()
ledger.attach(resist_fire, "monster-0001", clock=GameClock(), allocator=IdAllocator(), registry=registry)
assert modifier_values(goblin, "save_bonus", element="fire") == [2]
assert modifier_values(goblin, "save_bonus", element="cold") == []
regeneration_definition
regeneration_definition(params: Mapping[str, Any]) -> EffectDefinition
Build the effect definition for a monster that regenerates.
A regenerating monster like a troll has a regeneration ability whose params say how fast it heals
and whether it comes back from death. Pass those params here and attach the result with
EffectsLedger.attach when the monster enters play, and every
advance heals it on its own. Find the params on the template's
ability with the tag regeneration.
The definition it builds has no duration, so the regeneration runs until you release it, and its stacking
is "ignore", so attaching it twice to the same monster is harmless.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
params
|
Mapping[str, Any]
|
The ability's params. The tick reads four keys. |
required |
Returns:
| Type | Description |
|---|---|
EffectDefinition
|
An |
EffectDefinition
|
|
Examples:
from osrlib.core.effects import regeneration_definition
from osrlib.data import load_monsters
ability = next(a for a in load_monsters().get("troll").abilities if a.tag == "regeneration")
definition = regeneration_definition(ability.params)
assert definition.kind == "regeneration" and definition.tick == "regeneration"
assert definition.duration_unit is None # it runs until released
assert definition.params["per_round"] == 3
remove_condition
Take back the condition one effect granted, and return the event that says so.
This is the other half of grant_condition, and it matches on the
pair: the condition and the effect_id you granted it under. Pass the same effect_id you granted with, or
nothing is removed. A creature charmed by two effects keeps the second charm after you remove the first,
which is the point of recording the owner.
You call this for conditions you granted yourself. A condition that came from an attached effect is taken
back for you when the effect expires or you release it through
EffectsLedger.release.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
target
|
Creature
|
The |
required |
condition
|
Condition
|
The condition to take back. |
required |
effect_id
|
str | None
|
The id the condition was granted under, or |
required |
Returns:
| Type | Description |
|---|---|
list[Event]
|
A list of one |
list[Event]
|
the creature has no matching record. |
Examples:
from osrlib.core.effects import Condition, grant_condition, has_condition, remove_condition
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.data import load_monsters
streams = RngStreams(master_seed=5)
spawn = streams.get(MONSTER_SPAWN_STREAM)
goblin = spawn_monster(load_monsters().get("goblin"), id="monster-0001", stream=spawn)
grant_condition(goblin, Condition.AFRAID, "effect-0001")
# A different owner removes nothing.
assert remove_condition(goblin, Condition.AFRAID, "effect-0002") == []
assert has_condition(goblin, Condition.AFRAID)
events = remove_condition(goblin, Condition.AFRAID, "effect-0001")
assert [event.code for event in events] == ["effects.condition.removed"]
assert not has_condition(goblin, Condition.AFRAID)