Skip to content

osrlib.core.combat

The combat kernel: initiative, attacks, damage, saving throws, morale, and targeting.

Call these functions yourself, or let a session call them for you. Every one is a pure resolution over state you pass in, so a script with no session, dungeon, or battle machine can roll a whole fight. The guide "Using the rules without a session" walks that path. Inside a session, osrlib.crawl.battle wraps these same functions in the SRD's round sequence, so what a session logs is what these functions return.

A round of B/X combat flows through the module in order: roll_initiative orders the actors, resolve_attack runs one attack end to end (the roll, the immunity gate, the damage), saving_throw resolves forced saves, and check_morale reports whether a side keeps fighting. Start with resolve_attack: most of the module is the pieces it composes, plus the specialized resolutions (breath weapons, gazes, splash weapons, energy drain) and the shared targeting model (select_targets).

Combatant-typed parameters (attacker, defender, target, and kin) follow one convention across the whole library: each takes a protocol from osrlib.core.creature, and a Character and a MonsterInstance both satisfy it. A function that reads THAC0, armour class, or saving throws takes Combatant; one that reads only hit points, conditions, and stat modifiers takes Creature; and one that needs what only a monster carries, such as the daily breath count, takes MonsterInstance itself. NPC adventurers are Character instances, so there's no third combatant type.

Resolutions also take an AttackContext that describes the situation you assert (distance, cover-like situational modifiers, back-stab position), the Ruleset in play, and an RngStream to draw from. There's no default stream and no hidden global RNG: build an RngStreams from a master seed and hand each function the stream it draws from. Battle resolution draws from COMBAT_STREAM. Four functions belong to another subsystem and name its stream in their own entries: roll_reaction draws from the encounter stream, natural_healing from the effects stream, and drain_monster_hd and resolve_energy_drain from the advancement stream. Resolutions return frozen result models that include an events tuple: a session appends result.events to its log, and a caller working without one reads the plain result fields.

The damage pipeline always runs in this order. First the immunity gate: if the defender's harmed_only_by or energy defenses exclude the source, no damage is rolled and the event reports that. Then the damage roll plus STR for melee, then the quality and context doublings (brace, charge, back-stab), minimum 1 on a hit. Then the reductions (the wraith's half-from-silver, the mummy's half-everything), floored but never below 1. Last, the damage is applied: hit points floor at 0, fire and acid route into a regenerating monster's non-regenerable ledger, and death emits at 0.

Validators (validate_attack, validate_breath) follow the same convention as the rest of the library. They're pure pre-phase functions that return Rejection lists, with no RNG draws and no mutation. A rejection is free (no roll, no time, no log entry), which is why holy water against the living is not a rejection: it resolves normally and the damage pipeline reports no effect. A free rejection would be a zero-cost undead detector.

Typical usage:

from osrlib.core.combat import COMBAT_STREAM, AttackContext, Participant, check_morale, resolve_attack, roll_initiative
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.data import load_equipment, load_monsters

rules = Ruleset()
streams = RngStreams(master_seed=3)
spawn = streams.get(MONSTER_SPAWN_STREAM)
catalog = load_monsters()
goblin = spawn_monster(catalog.get("goblin"), id="goblin-1", stream=spawn)
orc = spawn_monster(catalog.get("orc"), id="orc-1", stream=spawn)

combat = streams.get(COMBAT_STREAM)
initiative = roll_initiative(
    [Participant(key="goblin-1", side="goblins"), Participant(key="orc-1", side="orcs")],
    ruleset=rules,
    stream=combat,
)
assert initiative.order == ("goblin-1", "orc-1")

sword = load_equipment().get("sword")
attack = resolve_attack(goblin, orc, sword, context=AttackContext(), ruleset=rules, stream=combat)
assert attack.attack_roll.hit
assert attack.damage == 4
assert orc.current_hp == 3  # down from 7

morale = check_morale("orcs", orc.template.morale, stream=combat)
assert not morale.held  # 2d6 showed 9 against the orc's morale score of 6

Attack module-attribute

What a combatant attacks with. None is an unarmed attack (1d2).

Every attack-resolving function in this module takes one of these. Where each form comes from: a WeaponTemplate or GearTemplate from load_equipment (see the equipment id index), a CombatFacet from a gear template's combat field when you already have the item's fighting stats, a MonsterAttack from the attacking monster's template, and a MagicItemInstance from a character's inventory or from treasure generation.

A MagicItemInstance attack is an enchanted arm: its base weapon supplies the dice, qualities, and ranges, and its template supplies the attack and damage bonuses, with a versus clause swapping in its alternate bonus when the defender's template has the referenced tag or id. It counts as magical for the immunity checks, cursed forms included: a cursed sword is still a magic sword.

Use attack_facet to get the dice, qualities, and ranges behind any of these forms without branching on the type yourself.

Examples:

from osrlib.core.combat import attack_facet
from osrlib.data import load_equipment, load_monsters

sword = load_equipment().get("sword")
assert attack_facet(sword).damage == "1d8"

goblin_attack = load_monsters().get("goblin").attacks[0].attacks[0]
assert goblin_attack.damage == "1d6"
assert attack_facet(None) is None  # unarmed has no facet

COMBAT_STREAM module-attribute

COMBAT_STREAM = StreamName.COMBAT

The stream key for battle-resolution draws: attacks, damage, saving throws, morale.

Pass it to RngStreams.get to get the stream every function in this module draws from, except the four that belong to another subsystem (reaction rolls, natural healing, and the two energy-drain functions). A GameSession uses this key too, so a script that uses it replays a session's fights draw for draw.

A stream key is a label: the same master seed and the same key always produce the same sequence. You can pass a differently named stream instead, and a standalone script is free to, but a saved session can't then replay your draws.

Examples:

from osrlib.core.combat import COMBAT_STREAM
from osrlib.core.rng import RngStreams

combat = RngStreams(master_seed=3).get(COMBAT_STREAM)
assert combat.randbelow(20) + 1 == 20

MELEE_REACH_FEET module-attribute

MELEE_REACH_FEET = 5

Melee attacks reach up to 5 feet.

validate_attack rejects a melee attack whose context states a greater distance, and a weapon that's both melee and missile counts as a missile use beyond this reach. To play at a different reach, state the distance you want in the AttackContext instead of changing this constant, which the whole module reads.

AttackContext

Bases: BaseModel

The situation you assert an attack resolves under.

Build one and pass it to every attack-resolving function in this module. An empty AttackContext() is the plain case: a melee swing at an aware, standing defender. The model is frozen, so build a new one per attack instead of editing one.

Everything here is a judgment the tabletop rules leave to the referee. These functions apply the rules to the context you give them, and working out that context (was the charge 60 feet? is the target unaware?) is your job, or the crawl layer's. A session fills it in from its own battle state, so a caller working inside one never builds an AttackContext by hand.

Examples:

from osrlib.core.combat import AttackContext

plain = AttackContext()
assert plain.distance_feet is None
assert plain.situational_modifier == 0

back_stab = AttackContext(behind_target=True, target_unaware=True)
assert back_stab.behind_target and back_stab.target_unaware

distance_feet class-attribute instance-attribute

distance_feet: int | None = None

How far apart attacker and defender are.

None states nothing. A melee weapon, and a weapon that's both melee and missile, then resolve as melee at reach. A missile-only weapon resolves as a missile use with no range-band modifier, because it can't be anything else. A distance over MELEE_REACH_FEET makes a melee-and-missile weapon a missile use, and makes a melee-only attack a rejection.

situational_modifier class-attribute instance-attribute

situational_modifier: int = 0

The referee adjustment added to the attack roll.

Cover at −1 to −4, the dozing dragon's +2, and the like.

defender_ally_ac_bonus class-attribute instance-attribute

defender_ally_ac_bonus: int = 0

An AC bonus an ally grants the defender.

The Ring of Protection 5' Radius shielding the wearer's rank-mates is one. Adjacency is your spatial judgment, so the value arrives as context.

behind_target class-attribute instance-attribute

behind_target: bool = False

The attacker strikes from behind.

The defender's shield doesn't count, and with target_unaware this is the thief's back-stab position.

target_unaware class-attribute instance-attribute

target_unaware: bool = False

The defender is unaware of the attack.

With behind_target it enables the back-stab attack bonus and damage multiplier.

defender_retreating class-attribute instance-attribute

defender_retreating: bool = False

The defender is retreating.

The attacker gains +2 and the defender's shield doesn't count. The crawl layer, the osrlib.crawl package that runs a session, sets it for a monster group that has broken and run, and for a party that declared a retreat.

braced class-attribute instance-attribute

braced: bool = False

The attacker has set a brace-quality weapon against a charge, which doubles its damage.

charging class-attribute instance-attribute

charging: bool = False

The attacker is charging with a charge-quality weapon, which doubles its damage.

fired_last_round class-attribute instance-attribute

fired_last_round: bool = False

The weapon was fired in the previous round.

Under the weapon_reload ruleset flag a reload-quality weapon is then rejected.

attacker_large class-attribute instance-attribute

attacker_large: bool = False

The attacker is a large creature.

This turns on the defender's defensive_bonus class ability, which is the halfling's AC bonus against large opponents.

lit class-attribute instance-attribute

lit: bool = False

The thrown oil flask is alight. Unlit oil deals no damage and no fire.

fixed_damage_option class-attribute instance-attribute

fixed_damage_option: int = 0

Which entry of a monster attack's fixed_damage_options to use.

It matters only for monsters whose attack lists more than one fixed amount.

monster_missile class-attribute instance-attribute

monster_missile: bool = False

The monster's attack is a small missile, so protection from normal missiles blocks it.

A hobgoblin's arrow is one. Monster attacks are never marked automatically, because the hurled boulder is the counter-case.

AttackResult

Bases: BaseModel

A full attack resolution: the roll, the gate verdict, and any damage.

Returned by resolve_attack and resolve_splash_attack. The defender has already taken the damage by the time you have one of these. The result reports what happened instead of asking you to apply it. The model is frozen.

attack_roll instance-attribute

attack_roll: AttackRollResult

The roll that opened the resolution.

absorbed class-attribute instance-attribute

absorbed: bool = False

Whether the defender's defenses turned the damage aside entirely, so no damage was rolled.

A hit can be absorbed. A miss never is.

damage class-attribute instance-attribute

damage: int | None = None

The hit points the defender lost, or None when nothing was rolled.

Nothing is rolled on a miss, on an absorbed hit, or when a sleeping defender is killed outright by a blade. An unlit oil flask that hits reports 0.

events class-attribute instance-attribute

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

Every event the resolution produced, in order, ready to append to a session's log.

AttackRollResult

Bases: BaseModel

An attack roll's outcome. roll is None for the helpless auto-hit.

Returned by attack_roll, and on the attack_roll field of an AttackResult. Read hit to branch, and the rest to show the player the arithmetic. The model is frozen.

hit instance-attribute

hit: bool

Whether the attack landed.

auto class-attribute instance-attribute

auto: bool = False

Whether the hit needed no roll.

That happens in melee against a defender that's paralysed or asleep, and against any defender whose armour_class is None, which is how a monster template says no hit roll is required. Green slime and yellow mould are the two that do. The roll fields are all None when this is true, and no draw was taken.

roll class-attribute instance-attribute

roll: int | None = None

The natural 1d20.

modifier class-attribute instance-attribute

modifier: int = 0

The signed total of every modifier applied to the roll.

total class-attribute instance-attribute

total: int | None = None

roll plus modifier.

required class-attribute instance-attribute

required: int | None = None

The number total had to reach.

It comes from the attack matrix, or from THAC0 − AC under the thac0_arithmetic ruleset flag.

natural class-attribute instance-attribute

natural: int | None = None

The natural roll when the always-hits-on-20 or always-misses-on-1 rule overrode the arithmetic.

None when the arithmetic stood on its own. Use it to tell a lucky hit from an ordinary one.

events class-attribute instance-attribute

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

The events this roll produced, ready to append to a session's log.

DamageSource

Bases: BaseModel

What a damage packet presents to the defender's defenses.

Build one with damage_source_for from an attacker, an attack, and a context, or construct one by hand for damage that isn't an attack: a spell, a trap, a fall. Hand it to check_immunity to ask whether the defender absorbs it, and to deal_damage to apply it. The model is frozen.

Examples:

from osrlib.core.combat import DamageSource

dragon_breath = DamageSource(element="fire", kind="breath", destructive=True)
assert dragon_breath.keys == ()
assert not dragon_breath.magical

silver_dagger = DamageSource(keys=("silver",))
assert silver_dagger.kind == "weapon"  # the default delivery

keys class-attribute instance-attribute

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

The material and enchantment keys the source presents: silver, magic, holy.

A defender's harmed_only_by gate admits a source that presents one of the keys it names.

element class-attribute instance-attribute

element: str | None = None

The energy element (fire, cold, lightning, and the like), or None for a physical source.

Energy defenses, per-die reductions, and a regenerating monster's non-regenerable ledger all key off it.

magical class-attribute instance-attribute

magical: bool = False

Whether the source is magical.

A nonmagical source is absorbed by a defense that turns aside anything but magic.

kind class-attribute instance-attribute

kind: str = 'weapon'

The delivery: weapon, unarmed, splash, breath, falling, effect, or spell.

It selects the saving throw category when a destructive death makes the victim's magic items save.

destructive class-attribute instance-attribute

destructive: bool = False

Whether the source destroys the victim's equipment on a killing blow.

Breath weapons and lightning bolt do.

missile class-attribute instance-attribute

missile: bool = False

Whether the source is a small missile, which protection from normal missiles blocks.

Character weapon missiles and thrown splash items are marked automatically, and monster attacks never are, because the hurled boulder is the counter-case. AttackContext.monster_missile is how you say a hobgoblin's arrow is one.

InitiativeResult

Bases: BaseModel

An initiative resolution: per-key rolls (re-rolls included) and the acting order.

Returned by roll_initiative. Iterate order to run the round. The model is frozen.

mode instance-attribute

mode: str

side when one roll covered each side, individual when each participant rolled its own.

The individual_initiative ruleset flag selects which.

entries instance-attribute

entries: tuple[InitiativeRoll, ...]

One InitiativeRoll per key that rolled.

That's a side under side initiative and a participant under individual initiative. Its rolls tuple contains every die the key threw, so a tie that was re-rolled shows all of its attempts.

order instance-attribute

order: tuple[str, ...]

Every participant's key, in acting order. Slow actors come last.

events class-attribute instance-attribute

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

The events this resolution produced, ready to append to a session's log.

MoraleResult

Bases: BaseModel

A morale check's outcome. exempt marks the morale scores 2 and 12, which never roll.

Returned by check_morale and by MoraleTracker.check. Acting on a broken side (fleeing, surrendering) is yours. The check only reports the verdict. The model is frozen.

held instance-attribute

held: bool

Whether the side keeps fighting.

A side with morale 2 never does, so this is false for it even though no roll was made.

exempt class-attribute instance-attribute

exempt: bool = False

Whether the score put the side outside the roll.

Morale 2 never fights and morale 12 never checks. The roll fields are None when this is true, and no draw was taken.

roll class-attribute instance-attribute

roll: int | None = None

The natural 2d6.

modifier class-attribute instance-attribute

modifier: int = 0

The situational adjustment that was applied, after the clamp to ±2.

events class-attribute instance-attribute

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

The events this check produced.

They have referee visibility, because players learn a side's nerve from its behaviour.

MoraleTracker

Bases: BaseModel

The two-passed-checks memory: after two held checks, no further checks.

Build one per encounter and check morale through it instead of calling check_morale directly, so a side that has held twice stops being asked. The rule it keeps: "If a monster passes two morale checks in an encounter, it will fight until killed, with no further checks."

Unlike the result models in this module, a tracker is mutable, because its job is to keep a count across the encounter. Throw it away when the encounter ends. A new encounter starts the count again.

passed class-attribute instance-attribute

passed: dict[str, int] = {}

How many checks each subject has held, keyed by the same subject key you pass to check.

A subject at 2 is never checked again.

check

check(subject: str, score: int, *, modifier: int = 0, stream: RngStream) -> MoraleResult | None

Check morale unless the subject has already passed twice.

check_morale does the check itself, and this adds the count. An exempt subject, at morale 2 or 12, never counts towards the two, because it never rolled.

Parameters:

Name Type Description Default
subject str

The side or group key. The tracker counts per subject, so two groups of goblins with different keys are counted apart.

required
score int

The morale score, which is 2 to 12 on a stat block. Any integer is accepted, and 2 or below and 12 or above are exempt, as in check_morale.

required
modifier int

The situational adjustment, clamped to ±2.

0
stream RngStream

The stream the 2d6 comes from, conventionally COMBAT_STREAM. No draw is taken once the subject has held twice.

required

Returns:

Type Description
MoraleResult | None

The result, or None when no further checks are made and the subject fights until killed.

Examples:

from osrlib.core.combat import COMBAT_STREAM, MoraleTracker
from osrlib.core.rng import RngStreams

combat = RngStreams(master_seed=3).get(COMBAT_STREAM)
tracker = MoraleTracker()

first = tracker.check("goblins", 10, stream=combat)
second = tracker.check("goblins", 10, stream=combat)
assert first.held and second.held
assert tracker.passed == {"goblins": 2}

assert tracker.check("goblins", 10, stream=combat) is None  # they fight on

Participant

Bases: BaseModel

One initiative participant: a stable key, a side, and the modifier hooks.

Build one per combatant and pass the sequence to roll_initiative, in the order you want ties and equal ranks resolved. The model is frozen.

key instance-attribute

key: str

The combatant's stable identifier, which comes back in the acting order.

Use the entity id you already track, so you can map the order onto your own objects.

side instance-attribute

side: str

The side the combatant fights on.

Under side initiative, everyone sharing a side acts on that side's single roll.

slow class-attribute instance-attribute

slow: bool = False

Whether the combatant wields a slow weapon.

Slow actors always act after every non-slow actor, whatever they rolled.

modifier class-attribute instance-attribute

modifier: int = 0

The individual-initiative modifier.

It's used only when the individual_initiative ruleset flag is on. Compute it with participant_modifier, which gives characters their DEX modifier plus the halfling's class bonus, and monsters whatever modifier you supply.

ReactionRollResult

Bases: BaseModel

A reaction roll's outcome: the raw 2d6, the modifier, and the table band.

Returned by roll_reaction. What the monsters do about their reaction is yours. The model is frozen.

result instance-attribute

The band the total fell in, from hostile through friendly.

roll instance-attribute

roll: int

The natural 2d6.

modifier class-attribute instance-attribute

modifier: int = 0

The modifier you supplied.

total instance-attribute

total: int

roll plus modifier.

It can fall outside 2 to 12, in which case the table's outermost band applies.

events class-attribute instance-attribute

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

The events this roll produced.

They have referee visibility, because players read a monster's mood from its behaviour.

SaveCategory

Bases: StrEnum

The five saving throw categories.

Pass one to saving_throw. Which category an effect forces is the effect's own business: a spell names its category, a breath weapon uses breath, and a destructive death makes magic items save under the category of the source that killed their owner.

The wire values are lowercase and match the fields of SavingThrows. They serialize into saves, so changing them is a schema_version bump.

DEATH class-attribute instance-attribute

DEATH = 'death'

Death ray or poison, and the fallback category for anything with no category of its own.

WANDS class-attribute instance-attribute

WANDS = 'wands'

Magic wands, and the category devices save under.

PARALYSIS class-attribute instance-attribute

PARALYSIS = 'paralysis'

Paralysis or petrification, which is what a petrifying gaze forces.

BREATH class-attribute instance-attribute

BREATH = 'breath'

Breath attacks. The WIS magic-save modifier doesn't apply to this one.

SPELLS class-attribute instance-attribute

SPELLS = 'spells'

Spells, rods, and staves.

SaveResult

Bases: BaseModel

A saving throw's outcome. roll is None for auto-save defenses.

Returned by saving_throw. Read passed to branch. The saving throw itself changes nothing, so applying the consequence is yours. The model is frozen.

passed instance-attribute

passed: bool

Whether the save succeeded.

auto class-attribute instance-attribute

auto: bool = False

Whether the target passed without a roll.

That happens when an energy defense auto-saves against a magical form of its own element. The roll fields are all None when this is true, and no draw was taken.

roll class-attribute instance-attribute

roll: int | None = None

The natural 1d20.

modifier class-attribute instance-attribute

modifier: int = 0

The signed total of every modifier applied, including the one you passed.

required class-attribute instance-attribute

required: int | None = None

The number roll plus modifier had to reach.

events class-attribute instance-attribute

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

The events this save produced, ready to append to a session's log.

TargetingMode

Bases: StrEnum

The shared targeting model's modes.

Pass one to select_targets along with the candidates it should choose from. Spells, breath weapons, and thrown weapons all resolve through these modes. Nothing in the kernel works out who is in range: you supply the candidate list, and inside a session the battle machine supplies it from its range-track geometry.

The wire values are lowercase and travel in events. Changing them is a schema_version bump.

SELF class-attribute instance-attribute

SELF = 'self'

The caster or user only. The first candidate is taken.

SINGLE class-attribute instance-attribute

SINGLE = 'single'

One creature. The first candidate is taken.

UP_TO_N class-attribute instance-attribute

UP_TO_N = 'up_to_n'

The first N candidates in your order, with N either fixed or rolled (hold person's 1d4).

HD_BUDGET class-attribute instance-attribute

HD_BUDGET = 'hd_budget'

Candidates taken weakest first until the Hit Dice budget runs out, as sleep spends its dice.

A candidate too large for what's left is skipped and the selection continues.

AREA class-attribute instance-attribute

AREA = 'area'

Every candidate. The footprint is yours to resolve.

GAZE class-attribute instance-attribute

GAZE = 'gaze'

Every candidate, for a gaze that reaches everyone engaged with the gazer.

alignments_differ

alignments_differ(source: Creature, target: Creature) -> bool

Return whether two combatants' operative alignments differ, for warding gates.

The wards that turn aside creatures "of another alignment", protection from evil and the like, turn on this answer. attack_roll and saving_throw call it for you when a ward is in play, so you need it yourself only when you write a ward of your own.

A combatant whose alignment is unresolved, which is None on a multi-option monster spawned without a choice, counts as being of another alignment. The ward errs protective.

Parameters:

Name Type Description Default
source Creature

The creature the ward is checked against, usually the attacker. A Creature, which a Character and a MonsterInstance both satisfy.

required
target Creature

The warded Creature, a Character or a MonsterInstance.

required

Returns:

Type Description
bool

True when the alignments differ or either is unresolved.

Examples:

from osrlib.core.combat import alignments_differ
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.data import load_monsters

spawn = RngStreams(master_seed=3).get(MONSTER_SPAWN_STREAM)
catalog = load_monsters()
goblin = spawn_monster(catalog.get("goblin"), id="goblin-1", stream=spawn)
acolyte = spawn_monster(catalog.get("acolyte"), id="acolyte-1", stream=spawn)

assert goblin.alignment == "chaotic"
assert acolyte.alignment is None  # the acolyte lists several, and none was chosen
assert alignments_differ(goblin, acolyte)
assert not alignments_differ(goblin, goblin)

apply_healing

apply_healing(target: Creature, amount: int, *, source: str = 'magical') -> list[Event]

Apply instantaneous healing, capped at max HP.

This mutates the target and draws nothing: roll the amount first if the healing is rolled. Use it for cure spells, potions, and a regenerating monster's tick. For a day of rest, call natural_healing instead, which rolls the 1d3 and applies the diseases that slow it.

What blocks healing. The dead can't be healed. Mummy rot blocks magical healing, so a diseased target emits the blocked event and heals nothing from a magical source. Instantaneous healing counts as magical healing, which is why magical is the default: a cure spell that forgets to name its source still respects the rot rule. The weakness that follows being raised from the dead blocks healing from every source, because the rules say the subject "has 1 hit point" until the recovery period ends and that it "may not be shortened by any magical healing". The hit point comes back when the weakness effect ends. A cursed scroll's slow healing halves a magical amount rather than blocking it.

Parameters:

Name Type Description Default
target Creature

The Creature to heal, which a Character and a MonsterInstance both satisfy. Mutated in place.

required
amount int

The healing amount, which must not be negative. Healing past the maximum is capped, not an error.

required
source str

The healing kind: magical, which is the default, natural, or regeneration. It selects which blocks apply and appears in the event.

'magical'

Returns:

Type Description
list[Event]

The healing and hit point events. A blocked target gets one event reporting 0 healed. A dead target gets nothing.

Raises:

Type Description
ValueError

If amount is negative.

Examples:

from osrlib.core.combat import DamageSource, apply_healing, deal_damage
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.data import load_monsters

spawn = RngStreams(master_seed=3).get(MONSTER_SPAWN_STREAM)
goblin = spawn_monster(load_monsters().get("goblin"), id="goblin-1", stream=spawn)
deal_damage(goblin, 3, source=DamageSource())
assert (goblin.current_hp, goblin.max_hp) == (2, 5)

events = apply_healing(goblin, 10)
assert events[0].amount == 3  # capped at the maximum, not 10
assert goblin.current_hp == 5

attack_facet

attack_facet(attack: Attack) -> WeaponTemplate | CombatFacet | None

Return the combat stats behind any attack.

Use it to read an attack's damage dice, qualities, or missile ranges without branching on which form of Attack you have: a gear item returns its embedded combat facet, and an enchanted arm returns its base weapon. The resolution functions call this themselves, so you need it when you're displaying an attack or choosing between attacks rather than resolving one.

Parameters:

Name Type Description Default
attack Attack

The weapon, facet, gear item, magic instance, or None.

required

Returns:

Type Description
WeaponTemplate | CombatFacet | None

The facet with the dice, qualities, and ranges. None for an unarmed attack, a monster's natural attack, a gear item with no fighting stats, and a magic item whose template names no base weapon, which is most of them: armour, rings, potions, and the rest. None of those has a facet to return.

Examples:

from osrlib.core.combat import attack_facet
from osrlib.data import load_equipment

catalog = load_equipment()
assert attack_facet(catalog.get("sword")).damage == "1d8"
assert attack_facet(catalog.get("oil_flask")).damage == "1d8"  # the gear item's facet
assert attack_facet(catalog.get("torch")).damage == "1d4"
assert attack_facet(None) is None  # unarmed: the 1d2 rule lives in damage_roll

attack_roll

attack_roll(
    attacker: Combatant,
    defender: Combatant,
    attack: Attack,
    *,
    context: AttackContext,
    ruleset: Ruleset,
    stream: RngStream
) -> AttackRollResult

Roll an attack: 1d20 plus modifiers against the defender's armour class.

This is the first step of resolve_attack, which is what you normally call: it rolls, checks the defender's immunities, rolls damage, and applies it. Call attack_roll on its own when you want the hit decision without the damage, as for an attack whose effect isn't hit points.

The roll gathers every modifier the situation supplies: the attacker's STR in melee, or its missile bonus and range band at distance, the back-stab bonus behind an unaware target, +2 against a retreating defender, an enchanted arm's bonus, spell bonuses and penalties on either side, and the context's situational_modifier. The defender's armour class takes its own adjustments the same way, so a shield doesn't count from behind and an ally's ward does.

Two defenders are hit automatically, with no roll taken and no draw consumed: one that's paralysed or asleep and struck in melee, and one whose armour_class is None, which is how a monster template says no hit roll is required. Green slime and yellow mould are the two monsters that say it.

A natural 20 always hits and a natural 1 always misses. The target number comes from the attack matrix, or from unclamped THAC0 − AC under the thac0_arithmetic ruleset flag.

Parameters:

Name Type Description Default
attacker Combatant

The attacking Combatant, which a Character and a MonsterInstance both satisfy.

required
defender Combatant

The defending Combatant, a Character or a MonsterInstance.

required
attack Attack

The weapon, facet, gear item, or monster attack (None for unarmed).

required
context AttackContext

The situation you assert.

required
ruleset Ruleset

The ruleset in play.

required
stream RngStream

The stream to draw the d20 from, conventionally COMBAT_STREAM from your RngStreams. One draw on a rolled attack, none on an automatic hit.

required

Returns:

Type Description
AttackRollResult

The roll outcome, with its events.

Raises:

Type Description
ValueError

If the defender exposes no armour class, which means the object isn't a combatant.

Examples:

from osrlib.core.combat import COMBAT_STREAM, AttackContext, attack_roll
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.data import load_equipment, load_monsters

streams = RngStreams(master_seed=5)
spawn = streams.get(MONSTER_SPAWN_STREAM)
catalog = load_monsters()
goblin = spawn_monster(catalog.get("goblin"), id="goblin-1", stream=spawn)
orc = spawn_monster(catalog.get("orc"), id="orc-1", stream=spawn)

rolled = attack_roll(
    goblin,
    orc,
    load_equipment().get("sword"),
    context=AttackContext(situational_modifier=-2),  # the orc has cover
    ruleset=Ruleset(),
    stream=streams.get(COMBAT_STREAM),
)
assert (rolled.roll, rolled.modifier, rolled.total) == (16, -2, 14)
assert rolled.required == 13
assert rolled.hit
assert rolled.natural is None  # no natural 1 or 20 overrode the arithmetic

burning_oil_pool_definition

burning_oil_pool_definition() -> EffectDefinition

Build the burning oil pool: a location-attached fire that burns for one turn.

A flask of oil thrown unlit does no damage, and this is what you do with it instead: it pools, and someone sets it alight. Once lit the pool burns for one turn and deals 1d8 to creatures passing through. Who passes through is your assertion, and you apply the damage yourself with deal_damage, because nothing in the kernel tracks where creatures walk.

The definition takes no arguments because the pool is the same every time: 1d8 of fire in a 3-foot radius for one turn. Attach it to a location with EffectsLedger.attach.

Returns:

Type Description
EffectDefinition

The one-turn pool EffectDefinition, whose params contain the dice, the element, and the radius.

Examples:

from osrlib.core.combat import burning_oil_pool_definition

pool = burning_oil_pool_definition()
assert pool.kind == "burning_oil_pool"
assert pool.duration_unit == "turn"
assert pool.params == {"dice": "1d8", "element": "fire", "radius_feet": 3}

cannot_move

cannot_move(combatant: Creature) -> bool

Return whether a combatant cannot move.

Ask this before letting a combatant move, flee, or close to melee. It's incapacitated plus entanglement: a creature caught in a web "can't move", and a creature that's dead, paralysed, petrified, or asleep can't either. Movement itself isn't this module's business, so nothing here calls it for you.

Parameters:

Name Type Description Default
combatant Creature

The Creature to ask about, which a Character and a MonsterInstance both satisfy.

required

Returns:

Type Description
bool

True when movement is impossible.

Examples:

from osrlib.core.combat import cannot_move
from osrlib.core.effects import ActiveCondition, Condition
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.data import load_monsters

spawn = RngStreams(master_seed=3).get(MONSTER_SPAWN_STREAM)
goblin = spawn_monster(load_monsters().get("goblin"), id="goblin-1", stream=spawn)
assert not cannot_move(goblin)

goblin.conditions = (ActiveCondition(condition=Condition.ENTANGLED),)  # a web caught it
assert cannot_move(goblin)

check_immunity

check_immunity(defender: Creature, source: DamageSource, *, ruleset: Ruleset, attacker: Creature | None = None) -> bool

Return True when the defender's defenses absorb the source: no damage is rolled.

resolve_attack and resolve_breath run this gate on every hit, so call it yourself to ask whether a weapon can hurt a monster before the party spends rounds finding out. Build the source with damage_source_for.

The rules this gate resolves. A monster's harmed_only_by list admits only sources presenting one of its keys, so a steel sword bounces off a werewolf and a silver dagger doesn't. The holy key is admitted through any such gate on an undead target and has no effect on anything else, which is why holy water against the living resolves as a hit that does nothing rather than as a rejection. A monster that uses fire ignores burning oil. An energy defense turns aside its own element, and turns aside the nonmagical form of it even when it doesn't turn aside the magical one. Under the hd5_counts_as_magical ruleset flag, an attacker of 5 or more Hit Dice, or one with a silver-or-magic gate of its own, gets through a gate that asks only for silver or magic. A defender under protection from normal missiles absorbs any small nonmagical missile, so an arrow or a thrown flask is blocked and a hurled boulder or an enchanted arrow isn't.

Parameters:

Name Type Description Default
defender Creature

The defending Creature, which a Character and a MonsterInstance both satisfy.

required
source DamageSource

The damage source presented.

required
ruleset Ruleset

The ruleset in play.

required
attacker Creature | None

The attacking Creature, a Character or a MonsterInstance. Only hd5_counts_as_magical reads it, and the flag cannot apply without it.

None

Returns:

Type Description
bool

True when the hit is absorbed and no damage should be rolled.

Examples:

from osrlib.core.combat import AttackContext, check_immunity, damage_source_for
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.data import load_equipment, load_monsters

spawn = RngStreams(master_seed=3).get(MONSTER_SPAWN_STREAM)
catalog = load_monsters()
goblin = spawn_monster(catalog.get("goblin"), id="goblin-1", stream=spawn)
werewolf = spawn_monster(catalog.get("werewolf"), id="werewolf-1", stream=spawn)

equipment = load_equipment()
rules = Ruleset()
steel = damage_source_for(goblin, equipment.get("sword"), AttackContext())
silver = damage_source_for(goblin, equipment.get("silver_dagger"), AttackContext())

assert check_immunity(werewolf, steel, ruleset=rules)  # the werewolf shrugs off steel
assert not check_immunity(werewolf, silver, ruleset=rules)

check_morale

check_morale(subject: str, score: int, *, modifier: int = 0, stream: RngStream) -> MoraleResult

Check morale: 2d6 against the morale score. Over the score means flee or surrender.

Call this when a side's nerve is in question, and act on the result yourself: nothing here makes anyone flee or surrender. Ask morale_triggers which of a side's states call for a check, and use MoraleTracker instead of this function when you want the rule that a side which holds twice stops checking. The morale score, ML on a monster's stat block, is its morale value from its template.

The check is over a side or group, not a creature, so a creature's own spell morale bonus reaches it through modifier: read it with morale_modifier and fold it into the situational adjustment. A side with ML 2 never fights and one with ML 12 never checks. Both are exempt: no roll is taken, no draw is consumed, and adjustments don't reach them.

Parameters:

Name Type Description Default
subject str

The side or group key, which appears in the event so a listener can name the side whose nerve broke.

required
score int

The morale score, which is 2 to 12 on a stat block. Any integer is accepted: 2 or below is exempt and never fights, 12 or above is exempt and always does.

required
modifier int

The situational adjustment, clamped to ±2 however large a value you pass.

0
stream RngStream

The stream the 2d6 comes from, conventionally COMBAT_STREAM. Two draws on a rolled check, none on an exempt one.

required

Returns:

Type Description
MoraleResult

The outcome. Its event states the verdict in held on every code, exempt checks included, so a listener never has to work it out from the score. The events have referee visibility, because players read a side's nerve from its behaviour rather than from a number.

Examples:

from osrlib.core.combat import COMBAT_STREAM, check_morale
from osrlib.core.rng import RngStreams

combat = RngStreams(master_seed=3).get(COMBAT_STREAM)

goblins = check_morale("goblins", 7, stream=combat)
assert goblins.roll == 8  # over their morale score of 7
assert not goblins.held

skeletons = check_morale("skeletons", 12, stream=combat)
assert skeletons.exempt and skeletons.held  # morale 12 never checks
assert skeletons.roll is None

damage_roll

damage_roll(
    attacker: Combatant,
    attack: Attack,
    *,
    context: AttackContext,
    ruleset: Ruleset,
    stream: RngStream,
    defender: Creature | None = None
) -> RollResult

Roll an attack's damage: dice, STR for melee, doublings, minimum 1.

resolve_attack calls this after a hit clears the immunity gate, and passes the result to deal_damage. Call it yourself to preview a weapon's damage, or when you're applying the damage some other way. It rolls only: nothing is subtracted from the defender here, and the defender's reductions are applied later by deal_damage.

What goes into the total, in order: the attack's dice, the attacker's melee modifier on a melee attack, an enchanted arm's damage bonus with a versus clause swapping in its alternate against a matching defender, spell damage bonuses, then the doublings. Those are a braced weapon meeting a charge, a charge of the attacker's own, the thief's back-stab multiplier, and the item multipliers, which are giant strength on weapon attacks and growth on melee attacks. The total is never below 1.

With the variable_weapon_damage ruleset flag off, every weapon and gear combat facet deals 1d6 instead of its listed dice. Unarmed attacks stay 1d2, which is a rule of their own rather than weapon damage, and monster damage is untouched. The Girdle of Giant Strength branches on the same flag: twice normal weapon damage under the default, and its printed 2d8 with the flag off.

Parameters:

Name Type Description Default
attacker Combatant

The attacking Combatant, which a Character and a MonsterInstance both satisfy.

required
attack Attack

The weapon, facet, gear item, or monster attack (None for unarmed).

required
context AttackContext

The situation you assert. Its braced, charging, behind_target, and target_unaware fields drive the doublings.

required
ruleset Ruleset

The ruleset in play.

required
stream RngStream

The stream to draw the damage dice from, conventionally COMBAT_STREAM.

required
defender Creature | None

The defending Creature, a Character or a MonsterInstance. Only an enchanted arm's versus clause reads it, so an ordinary weapon needs no defender.

None

Returns:

Type Description
RollResult

The damage roll. rolls contains the individual dice and total the final amount, which is at least 1. A monster attack that deals no hit point damage, like a wight's touch, returns a total of 0, because its effect isn't damage.

Raises:

Type Description
ValueError

If the attack has no combat facet to roll damage from. That's a gear item with no fighting stats, like a lantern, and a magic item whose template names no base weapon, like a suit of Armour +1.

Examples:

from osrlib.core.combat import COMBAT_STREAM, AttackContext, damage_roll
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.data import load_equipment, load_monsters

streams = RngStreams(master_seed=3)
spawn = streams.get(MONSTER_SPAWN_STREAM)
goblin = spawn_monster(load_monsters().get("goblin"), id="goblin-1", stream=spawn)

rolled = damage_roll(
    goblin,
    load_equipment().get("sword"),
    context=AttackContext(),
    ruleset=Ruleset(),
    stream=streams.get(COMBAT_STREAM),
)
assert rolled.rolls == (5,)  # one d8
assert rolled.total == 5  # a goblin adds no strength modifier

damage_source_for

damage_source_for(attacker: Creature, attack: Attack, context: AttackContext) -> DamageSource

Build the damage source an attack presents to the defender's defenses.

resolve_attack builds one for you on every hit. Call it yourself to ask check_immunity whether an attack would land before spending a round on it, or to hand deal_damage a source when you're applying damage outside an attack. For damage that isn't an attack, like a trap or a spell, construct a DamageSource directly.

What each attack presents: a silver weapon presents silver, holy water presents holy, and a torch or burning oil deals fire, because they're burning brands, which is what routes them into a regenerating monster's non-regenerable ledger. Monster natural attacks are mundane. The hd5_counts_as_magical ruleset flag is resolved from the attacker by check_immunity, not here. A wielder under striking presents magic on weapon attacks but never on unarmed ones, because the enchantment belongs to the weapon and sits on the wielder only because item instances have no ids of their own. Whether the source counts as a small missile is recorded here for the immunity gate.

Parameters:

Name Type Description Default
attacker Creature

The attacking Creature, which a Character and a MonsterInstance both satisfy.

required
attack Attack

The weapon, facet, gear item, or monster attack (None for unarmed).

required
context AttackContext

The attack context. Set lit when the oil flask is alight, and distance_feet when a melee-and-missile weapon is thrown.

required

Returns:

Type Description
DamageSource

The frozen damage source, ready for check_immunity and deal_damage.

Examples:

from osrlib.core.combat import AttackContext, damage_source_for
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.data import load_equipment, load_monsters

spawn = RngStreams(master_seed=3).get(MONSTER_SPAWN_STREAM)
goblin = spawn_monster(load_monsters().get("goblin"), id="goblin-1", stream=spawn)
catalog = load_equipment()

silver = damage_source_for(goblin, catalog.get("silver_dagger"), AttackContext())
assert silver.keys == ("silver",)

oil = damage_source_for(goblin, catalog.get("oil_flask"), AttackContext(lit=True))
assert (oil.element, oil.kind, oil.missile) == ("fire", "splash", True)

fist = damage_source_for(goblin, None, AttackContext())
assert fist.kind == "unarmed"

deal_damage

deal_damage(
    target: Combatant,
    amount: int,
    *,
    source: DamageSource,
    attacker_id: str | None = None,
    rolls: tuple[int, ...] = (),
    clock: GameClock | None = None,
    ruleset: Ruleset | None = None,
    stream: RngStream | None = None
) -> list[Event]

Apply damage: reductions, the hit point floor, ledgers, and death.

This mutates the target. resolve_attack calls it for you on a hit, so call it yourself for damage that isn't an attack: a trap, a fall (pair it with falling_damage), a spell, or an effect ticking. Roll the amount first, with damage_roll or roll, and describe it with a DamageSource. This function draws no dice of its own unless a destructive kill makes magic items save.

What it does, in order. Divisor reductions from the target's defenses apply first, like the wraith's half-from-silver and the mummy's half-from-everything, each flooring at 1. Then element-scoped per-die reductions, which take 1 point per damage die rolled and never take a die below 1, so a source that rolled no dice has nothing to reduce. Hit points then fall, floored at 0. Fire and acid against a regenerating monster whose regeneration they block also accrue in its non-regenerable ledger, capped at its maximum. Only a MonsterInstance has a regeneration ability, so a target that reaches that step is one, and the field written there is the instance's nonregen_damage. A monster instance also records the round it was last damaged whenever you pass a clock, which is what a revival countdown is measured from. Such a monster dies permanently only when its regeneration names a revive entry, meaning it's the kind that gets back up, and the ledger alone reaches the maximum. At 0 hit points the target dies, and a destructive source then destroys what it carried.

Parameters:

Name Type Description Default
target Combatant

The Combatant taking the damage, which a Character and a MonsterInstance both satisfy. Mutated in place, and its saving throws are rolled when a destructive source kills it.

required
amount int

The rolled amount, before reductions.

required
source DamageSource

The damage source, which selects the reductions, the ledger, and whether a kill destroys equipment.

required
attacker_id str | None

The attacker's entity id, which appears in the event.

None
rolls tuple[int, ...]

The raw damage dice, which appear in the event and set how much a per-die reduction can take.

()
clock GameClock | None

The game clock. When passed, the target's last_damaged_round is stamped, which is what delays a regenerating monster's healing.

None
ruleset Ruleset | None

The ruleset in play, for the magic-item death save on a destructive kill.

None
stream RngStream | None

The stream the magic-item death save draws from, which is the stream of whichever subsystem is resolving. Needed only when a destructive source can kill a target carrying magic items.

None

Returns:

Type Description
list[Event]

The events in order, ready to append to a session's log: DamageDealtEvent, then HitPointsReportedEvent, then on a killing blow the death events, and last an EquipmentDestroyedEvent when a destructive source killed a target that was carrying something.

Examples:

from osrlib.core.combat import DamageSource, deal_damage
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.data import load_monsters

spawn = RngStreams(master_seed=3).get(MONSTER_SPAWN_STREAM)
goblin = spawn_monster(load_monsters().get("goblin"), id="goblin-1", stream=spawn)
assert goblin.current_hp == 5

events = deal_damage(goblin, 2, source=DamageSource(kind="falling"), attacker_id=None)
assert [event.code for event in events] == ["combat.damage.dealt", "combat.state.hit_points"]
assert goblin.current_hp == 3

events = deal_damage(goblin, 99, source=DamageSource(kind="falling"))
assert "combat.death.died" in [event.code for event in events]
assert goblin.current_hp == 0  # hit points floor at 0, they never go negative

destroy_equipment

destroy_equipment(
    target: Combatant,
    *,
    source: DamageSource | None = None,
    ruleset: Ruleset | None = None,
    stream: RngStream | None = None
) -> list[Event]

Destroy a victim's carried equipment: the destructive-death outcome.

deal_damage calls this when a destructive source lands the killing blow, so you rarely call it yourself. Disintegrate does, because the material form it destroys includes what the victim carried.

Under the magic_item_death_save ruleset flag, which is on by default, each magic item in the doomed inventory rolls 1d20 against the owner's saving throw value for the destructive source's category: a breath weapon saves versus breath, a destructive spell versus spells, a device versus wands, anything else versus death. The item adds its best combat bonus, meaning the highest of its attack, damage, and armour class bonuses, so a cursed item saves at its penalty. Survivors stay in the item list and the event's saved_items names their instance ids, and a session puts them in a drop pile at the victim's cell, because surviving the blast but not the looting would be no survival. The rolls themselves are silent, and the event reports the outcome.

Parameters:

Name Type Description Default
target Combatant

The victim, a Combatant, which a Character and a MonsterInstance both satisfy. Its inventory is emptied except for saved magic items, and what it wielded, wore, and had on its fingers is cleared.

required
source DamageSource | None

The destructive damage source, which selects the saving throw category. None means the death category.

None
ruleset Ruleset | None

The ruleset in play. None skips the save and everything burns.

None
stream RngStream | None

The stream the item saves draw from, one draw per magic item.

None

Returns:

Type Description
list[Event]

The destruction event, naming what burned and what saved. Nothing for an empty inventory.

Raises:

Type Description
ValueError

If the save is on, the victim carries magic items, and no stream was supplied. The save can't roll without one.

Examples:

from osrlib.core.alignment import Alignment
from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character
from osrlib.core.combat import COMBAT_STREAM, DamageSource, destroy_equipment
from osrlib.core.items import MagicItemInstance
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset

rules = Ruleset()
streams = RngStreams(master_seed=3)
hild = create_character(
    name="Hild",
    class_id="fighter",
    alignment=Alignment.LAWFUL,
    ruleset=rules,
    stream=streams.get(CHARACTER_CREATION_STREAM),
    purchases=[("torch", 1)],
).character
hild.inventory.items.append(MagicItemInstance(instance_id="item-1", template_id="sword_plus_1"))

breath = DamageSource(element="fire", kind="breath", destructive=True)
events = destroy_equipment(hild, source=breath, ruleset=rules, stream=streams.get(COMBAT_STREAM))
assert events[0].item_names == ("Torches (6)",)
assert events[0].saved_items == ("item-1",)  # the Sword +1 made its save versus breath
assert [item.instance_id for item in hild.inventory.items] == ["item-1"]

drain_monster_hd

drain_monster_hd(monster: MonsterInstance, *, levels: int = 1, stream: RngStream) -> list[Event]

Drain a monster's Hit Dice, which is what "experience level (or Hit Die)" means.

Call this when something drains a monster rather than a character. For a character, drain_levels is the matching function, and resolve_energy_drain picks between the two for you from the draining monster's tag. This mutates the monster.

The drain works the way character drain does. The instance re-derives its THAC0 and saving throws from the reduced Hit Dice, and loses a rolled d8 from both its maximum and its current hit points per die drained. Neither total falls below 1, so a monster is never drained to death by hit point loss.

A monster already at 1 Hit Die is killed instead. No die is rolled for that step, no Hit Dice come off, and the event counts the last one as lost.

Parameters:

Name Type Description Default
monster MonsterInstance

The drained MonsterInstance. Mutated in place.

required
levels int

How many Hit Dice the drain removes.

1
stream RngStream

The stream the lost-hit-point d8s come from. Drain reverses advancement, so it draws from ADVANCEMENT_STREAM rather than the combat stream. One draw per Hit Die actually removed, and none for the step that kills.

required

Returns:

Type Description
list[Event]

A surviving monster gets a LevelDrainedEvent coded combat.drain.drained and a HitPointsReportedEvent. A killed one gets a LevelDrainedEvent coded combat.drain.slain and then the death events, which end with their own HitPointsReportedEvent reporting 0.

Examples:

from osrlib.core.character import ADVANCEMENT_STREAM
from osrlib.core.combat import drain_monster_hd
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=3)
spawn = streams.get(MONSTER_SPAWN_STREAM)
ogre = spawn_monster(load_monsters().get("ogre"), id="ogre-1", stream=spawn)
assert (ogre.hit_dice_count, ogre.max_hp, ogre.thac0) == (4, 25, 15)

events = drain_monster_hd(ogre, stream=streams.get(ADVANCEMENT_STREAM))
assert events[0].code == "combat.drain.drained"
assert (ogre.hit_dice_count, ogre.max_hp, ogre.thac0) == (3, 21, 16)  # worse at everything

effective_hd

effective_hd(combatant: Creature) -> int

Return a combatant's effective Hit Dice for the HD-budget targeting mode.

select_targets spends its budget in these units, so call it yourself to work out in advance how many creatures a sleep would take. It's not a general power rating: a monster's plus-signs and asterisks aren't in it.

A monster under 1 Hit Die counts as 1, and the fixed hit point bonus after a monster's Hit Dice is dropped, so a 2+1 HD monster counts as 2. A character counts its level.

Parameters:

Name Type Description Default
combatant Creature

The Creature to measure, which a Character and a MonsterInstance both satisfy.

required

Returns:

Type Description
int

The effective Hit Dice, never below 1.

Examples:

from osrlib.core.combat import effective_hd
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.data import load_monsters

spawn = RngStreams(master_seed=3).get(MONSTER_SPAWN_STREAM)
catalog = load_monsters()

assert effective_hd(spawn_monster(catalog.get("goblin"), id="goblin-1", stream=spawn)) == 1
assert effective_hd(spawn_monster(catalog.get("ogre"), id="ogre-1", stream=spawn)) == 4  # 4+1 HD
assert effective_hd(spawn_monster(catalog.get("normal_rat"), id="rat-1", stream=spawn)) == 1  # under 1 HD

falling_damage

falling_damage(feet: int, stream: RngStream) -> RollResult | None

Roll falling damage: 1d6 per full 10 feet fallen, floored.

This rolls only. Apply the total with deal_damage, passing a DamageSource whose kind is falling, so a defense that turns aside weapons doesn't turn aside the floor.

Parameters:

Name Type Description Default
feet int

The distance fallen. Partial ten-foot increments don't count, so 19 feet is one die.

required
stream RngStream

The stream the d6s come from, conventionally COMBAT_STREAM.

required

Returns:

Type Description
RollResult | None

The damage roll, or None for a fall under 10 feet, which takes no dice and no draw.

Examples:

from osrlib.core.combat import COMBAT_STREAM, falling_damage
from osrlib.core.rng import RngStreams

combat = RngStreams(master_seed=3).get(COMBAT_STREAM)

rolled = falling_damage(25, combat)
assert rolled.rolls == (5, 3)  # two dice: 25 feet is two full ten-foot drops
assert rolled.total == 8

assert falling_damage(8, combat) is None

incapacitated

incapacitated(combatant: Creature) -> bool

Return whether a combatant counts as incapacitated for morale triggers.

morale_triggers counts a side's incapacitated members with this, and resolve_gaze skips them. Call it yourself to ask whether a combatant is out of the fight, whichever way it went out. To ask whether it can still move, use cannot_move, which adds entanglement.

The tabletop rules say "slain, paralysed, etc". osrlib reads that as dead, paralysed, petrified, or asleep.

Parameters:

Name Type Description Default
combatant Creature

The Creature to ask about, which a Character and a MonsterInstance both satisfy.

required

Returns:

Type Description
bool

True when incapacitated.

Examples:

from osrlib.core.combat import DamageSource, deal_damage, incapacitated
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.data import load_monsters

spawn = RngStreams(master_seed=3).get(MONSTER_SPAWN_STREAM)
goblin = spawn_monster(load_monsters().get("goblin"), id="goblin-1", stream=spawn)
assert not incapacitated(goblin)

deal_damage(goblin, 99, source=DamageSource())
assert incapacitated(goblin)

melee_modifier_for

melee_modifier_for(combatant: Combatant) -> int

Return a combatant's melee attack-and-damage modifier, strength_set aware.

attack_roll and damage_roll add this themselves on every melee attack, so call it to show a character sheet's melee bonus or preview a swing, not to feed it back into a resolution.

A strength_set modifier, which is what the Gauntlets of Ogre Power (18) and the Ring of Weakness (3) impose, replaces the STR score the ability table derives the melee modifier from. Monsters have no STR score and keep their intrinsic 0.

Parameters:

Name Type Description Default
combatant Combatant

The attacking Combatant, which a Character and a MonsterInstance both satisfy.

required

Returns:

Type Description
int

The signed melee modifier, applied to both the attack roll and the damage.

Examples:

from osrlib.core.alignment import Alignment
from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character
from osrlib.core.combat import melee_modifier_for
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.data import load_monsters

streams = RngStreams(master_seed=17)
hild = create_character(
    name="Hild",
    class_id="fighter",
    alignment=Alignment.LAWFUL,
    ruleset=Ruleset(),
    stream=streams.get(CHARACTER_CREATION_STREAM),
).character
assert melee_modifier_for(hild) == 2  # this Hild rolled STR 16

goblin = spawn_monster(load_monsters().get("goblin"), id="goblin-1", stream=streams.get(MONSTER_SPAWN_STREAM))
assert melee_modifier_for(goblin) == 0

morale_modifier

morale_modifier(combatant: Creature) -> int

Return a combatant's spell morale modifier, from bless, blight, and their kin.

check_morale takes a side key and a score, never a creature, so a spell's morale modifier can't reach it on its own. Read it here and fold it into the modifier you pass. A spell morale modifier counts inside the same ±2 clamp and the same morale 2 and 12 exemptions as any situational adjustment, because there's one adjustment rule rather than a second channel for spells.

Parameters:

Name Type Description Default
combatant Creature

The Creature whose morale is being checked, which a Character and a MonsterInstance both satisfy.

required

Returns:

Type Description
int

The signed modifier, already totalled across every active effect and 0 when none applies.

Examples:

from osrlib.core.combat import COMBAT_STREAM, check_morale, morale_modifier
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=3)
spawn = streams.get(MONSTER_SPAWN_STREAM)
goblin = spawn_monster(load_monsters().get("goblin"), id="goblin-1", stream=spawn)
assert morale_modifier(goblin) == 0  # nothing has blessed or blighted it

outnumbered = -1
adjustment = outnumbered + morale_modifier(goblin)
checked = check_morale("goblins", 7, modifier=adjustment, stream=streams.get(COMBAT_STREAM))
assert checked.modifier == -1
assert checked.roll == 8 and checked.held  # 8 - 1 is within the morale score of 7

morale_triggers

morale_triggers(members: Sequence[Creature]) -> list[str]

Return the morale triggers a side's current state raises.

Call this after each round to learn whether a side should check morale, then call check_morale or MoraleTracker.check for the check itself. A session's battle machine does this for you. The triggers are first_death, raised once the side has lost anyone, and half_incapacitated, raised when half the side or more is dead, paralysed, petrified, or asleep.

The triggers describe the side's current state, not what's changed since you last asked, so first_death keeps coming back while the body is on the floor. Track which ones you've already acted on.

Parameters:

Name Type Description Default
members Sequence[Creature]

The side's creatures, each a Creature, which a Character and a MonsterInstance both satisfy. An empty side raises nothing.

required

Returns:

Type Description
list[str]

The raised trigger keys.

Examples:

from osrlib.core.combat import DamageSource, deal_damage, morale_triggers
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.data import load_monsters

spawn = RngStreams(master_seed=3).get(MONSTER_SPAWN_STREAM)
catalog = load_monsters()
first = spawn_monster(catalog.get("goblin"), id="goblin-1", stream=spawn)
second = spawn_monster(catalog.get("goblin"), id="goblin-2", stream=spawn)
assert morale_triggers([first, second]) == []

deal_damage(first, 99, source=DamageSource())
assert morale_triggers([first, second]) == ["first_death", "half_incapacitated"]

natural_healing

natural_healing(target: Creature, stream: RngStream, *, ledger: EffectsLedger | None = None) -> list[Event]

Apply one full day of complete rest: 1d3 hit points.

Call this once per day of uninterrupted rest. Whether the rest was uninterrupted is yours to attest, and a session's rest procedure attests it for you. For healing that comes from a spell or a potion, call apply_healing instead, which takes the amount you rolled.

A slowed-healing effect stretches the cadence rather than reducing the amount. Mummy rot makes natural healing run ten times slower, and an effect with a healing_rest_days param heals once per that many consecutive full rest days, which is 2 for cause disease and for a cursed scroll's "twice the usual amount of time". The count is kept on the effect, whether or not it's a disease, and when several apply the slowest one wins. A diseased target with no ledger to count on doesn't heal.

Parameters:

Name Type Description Default
target Creature

The resting Creature, which a Character and a MonsterInstance both satisfy. Mutated in place.

required
stream RngStream

The stream the 1d3 comes from. Natural healing is effect-internal randomness, so it draws from EFFECTS_STREAM, not the combat stream. One draw on a healing day, none on a skipped one.

required
ledger EffectsLedger | None

The EffectsLedger that contains the target's effects, when any. Without one, no slowdown can be counted.

None

Returns:

Type Description
list[Event]

The healing and hit point events. Empty on a rest day that a slowdown swallows, and empty for a dead target.

Examples:

from osrlib.core.combat import DamageSource, deal_damage, natural_healing
from osrlib.core.effects import EFFECTS_STREAM
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=3)
spawn = streams.get(MONSTER_SPAWN_STREAM)
goblin = spawn_monster(load_monsters().get("goblin"), id="goblin-1", stream=spawn)
deal_damage(goblin, 4, source=DamageSource())
assert goblin.current_hp == 1

events = natural_healing(goblin, streams.get(EFFECTS_STREAM))
assert events[0].amount == 1  # the 1d3 came up 1
assert goblin.current_hp == 2

participant_modifier

participant_modifier(combatant: Combatant, *, monster_modifier: int = 0) -> int

Return a combatant's individual-initiative modifier.

Use it to fill the modifier field of a Participant before calling roll_initiative. The modifier matters only when the individual_initiative ruleset flag is on, since side initiative rolls once per side and applies no modifier.

A character gets its DEX modifier plus the halfling's initiative_bonus class tag. A monster gets whatever you pass, because the tabletop rules leave monster initiative modifiers to the referee.

Parameters:

Name Type Description Default
combatant Combatant

The Combatant rolling, which a Character and a MonsterInstance both satisfy.

required
monster_modifier int

The modifier to use for a monster. Ignored for characters.

0

Returns:

Type Description
int

The signed modifier.

Examples:

from osrlib.core.alignment import Alignment
from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character
from osrlib.core.combat import participant_modifier
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.data import load_monsters

streams = RngStreams(master_seed=9)
hild = create_character(
    name="Hild",
    class_id="fighter",
    alignment=Alignment.LAWFUL,
    ruleset=Ruleset(),
    stream=streams.get(CHARACTER_CREATION_STREAM),
).character
assert participant_modifier(hild) == 1  # this Hild rolled DEX 17

goblin = spawn_monster(load_monsters().get("goblin"), id="goblin-1", stream=streams.get(MONSTER_SPAWN_STREAM))
assert participant_modifier(goblin) == 0
assert participant_modifier(goblin, monster_modifier=1) == 1

resolve_attack

resolve_attack(
    attacker: Combatant,
    defender: Combatant,
    attack: Attack,
    *,
    context: AttackContext,
    ruleset: Ruleset,
    stream: RngStream,
    clock: GameClock | None = None
) -> AttackResult

Resolve one attack end to end: roll, gate, damage.

This is the module's entry point, and the one function most callers need: it rolls the attack, checks the defender's immunities, rolls the damage, and applies it. The defender is mutated, so the hit points are already gone by the time you read the result. To ask first whether the attack is legal, call validate_attack, which draws nothing and changes nothing. For a thrown flask of oil or holy water, call resolve_splash_attack instead, which adds the second application. For a breath weapon, a gaze, or an energy drain, use the specialized resolutions.

The pipeline order is fixed. The attack roll comes first (attack_roll). On a hit, the immunity gate (check_immunity): if the defender's defenses exclude the source, no damage is rolled and the absorbed event reports that. Otherwise the damage roll (damage_roll) and its application (deal_damage).

Two hits take a shorter path. A sleeping defender struck in melee with a bladed weapon dies outright, with no damage rolled, once the immunity gate has run. An oil flask thrown unlit does nothing on a hit and reports 0 damage, and you can compile a pool from it instead with burning_oil_pool_definition.

Parameters:

Name Type Description Default
attacker Combatant

The attacking Combatant, which a Character and a MonsterInstance both satisfy.

required
defender Combatant

The defending Combatant, a Character or a MonsterInstance. Mutated in place when damage lands.

required
attack Attack

The weapon, facet, gear item, or monster attack (None for unarmed).

required
context AttackContext

The situation you assert. AttackContext() is the plain melee case.

required
ruleset Ruleset

The ruleset in play.

required
stream RngStream

The stream every draw in the resolution comes from, conventionally COMBAT_STREAM. An automatic hit costs no draw. A miss, an absorbed hit, a sleeping defender killed by a blade, and an unlit oil flask each cost the attack roll alone. An ordinary hit costs the attack roll plus the damage dice, and the extra dice on top of those when the attacker is under striking.

required
clock GameClock | None

The game clock. When passed, the damage stamps the defender's last_damaged_round, which is what delays a regenerating monster's healing.

None

Returns:

Type Description
AttackResult

The full resolution with its events.

Examples:

A 1st-level fighter swings a sword at a goblin. Every draw comes from a named, seeded stream, so the same seed always replays the same fight:

from osrlib.core.alignment import Alignment
from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character
from osrlib.core.combat import COMBAT_STREAM, AttackContext, resolve_attack
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.data import load_equipment, load_monsters

rules = Ruleset()
streams = RngStreams(master_seed=7)
hild = create_character(
    name="Hild",
    class_id="fighter",
    alignment=Alignment.LAWFUL,
    ruleset=rules,
    stream=streams.get(CHARACTER_CREATION_STREAM),
).character
spawn = streams.get(MONSTER_SPAWN_STREAM)
goblin = spawn_monster(load_monsters().get("goblin"), id="goblin-1", stream=spawn)
sword = load_equipment().get("sword")

combat = streams.get(COMBAT_STREAM)
result = resolve_attack(hild, goblin, sword, context=AttackContext(), ruleset=rules, stream=combat)
assert result.events[0].code in ("combat.attack.hit", "combat.attack.missed")
assert result.attack_roll.hit  # seed 7 hits: 12 rolled + 1 = 13 against a required 13
assert result.damage == 9  # same seed, same damage roll
assert goblin.current_hp == 0
assert "combat.death.died" in [event.code for event in result.events]

resolve_breath

resolve_breath(
    monster: MonsterInstance,
    targets: Sequence[Combatant],
    *,
    ruleset: Ruleset,
    stream: RngStream,
    clock: GameClock | None = None
) -> list[Event]

Resolve a breath weapon against an explicitly supplied target list.

Call this instead of resolve_attack when a monster breathes: there's no attack roll, every target saves instead, and the whole area resolves in one call. You supply who's caught in it, because nothing in the kernel tracks position. select_targets in area mode is how a caller with a footprint turns it into a target list. Check with validate_breath first, because breathing past the daily limit raises rather than rejecting. The monster's daily counter goes up here, and every target is mutated.

What the breath does depends on the monster. A dragon's deals its own current hit points, halved on a successful save, which is why a wounded dragon breathes weakly, and it gets three uses a day. A hellhound's deals dice by Hit Dice with no daily limit. A sea dragon's spittle kills outright on a failed save. Halving floors, so 1 point halves to 0 and nothing lands.

A breath weapon is a destructive source, so a target it kills loses its equipment, with magic items saving under the magic_item_death_save ruleset flag. The tabletop rules' examples of item destruction are a lightning bolt and a dragon's breath, which osrlib reads as covering energy deaths generally, so a hellhound's fire destroys equipment the same way a dragon's does.

Parameters:

Name Type Description Default
monster MonsterInstance

The breathing MonsterInstance. Its breath_uses_today goes up when the breath has a daily limit.

required
targets Sequence[Combatant]

The creatures caught in the breath, each a Combatant, which a Character and a MonsterInstance both satisfy. Mutated in place.

required
ruleset Ruleset

The ruleset in play.

required
stream RngStream

The stream every draw comes from, conventionally COMBAT_STREAM: one save per target, the damage dice when the breath rolls them, and the magic-item saves of anyone it kills.

required
clock GameClock | None

The game clock. When passed, the damage stamps each target's last_damaged_round.

None

Returns:

Type Description
list[Event]

The events per target, in the order the targets were given. A target whose defenses absorb the breath gets a DamageAbsorbedEvent and never saves. Any other target gets its SavingThrowRolledEvent, then the damage and death events when damage lands.

Raises:

Type Description
ValueError

If the monster has no breath weapon, or its daily uses are spent. Validate first. Breathing anyway is a programming mistake rather than a move the rules reject.

Examples:

from osrlib.core.combat import COMBAT_STREAM, resolve_breath
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.data import load_monsters

streams = RngStreams(master_seed=3)
spawn = streams.get(MONSTER_SPAWN_STREAM)
catalog = load_monsters()
dragon = spawn_monster(catalog.get("white_dragon"), id="dragon-1", stream=spawn)
goblins = [spawn_monster(catalog.get("goblin"), id=f"goblin-{n}", stream=spawn) for n in (1, 2)]
assert dragon.current_hp == 39  # the cone deals this much, halved on a save

events = resolve_breath(dragon, goblins, ruleset=Ruleset(), stream=streams.get(COMBAT_STREAM))
assert [goblin.current_hp for goblin in goblins] == [0, 0]  # half of 39 still kills a goblin
assert [event.code for event in events].count("combat.death.died") == 2
assert dragon.breath_uses_today == 1

resolve_energy_drain

resolve_energy_drain(attacker: MonsterInstance, target: Creature, *, stream: RngStream) -> list[Event]

Drain a victim's levels or Hit Dice from a drain-tagged monster's touch.

Call this after a wight, wraith, spectre, or vampire lands a hit, since resolve_attack deals the hit point damage and leaves the drain to you. It reads the attacker's energy_drain tag for how many levels to take and which XP policy to use, then applies character drain or drain_monster_hd according to what the target is. What it is shows in its class definition: a Character has one and loses experience levels through drain_levels, and a MonsterInstance has none and loses Hit Dice. The tag's own text describes what the victim becomes, and that text appears in the drain event.

Parameters:

Name Type Description Default
attacker MonsterInstance

The draining MonsterInstance, which must have an energy_drain tag.

required
target Creature

The drained Creature, a Character or a MonsterInstance. Which one it is decides whether levels or Hit Dice come off. Mutated in place.

required
stream RngStream

The stream the lost-hit-point dice come from. Drain reverses advancement, so it draws from ADVANCEMENT_STREAM rather than the combat stream.

required

Returns:

Type Description
list[Event]

The drain events.

Raises:

Type Description
ValueError

If the attacker has no energy_drain tag, which means it does not drain.

Examples:

from osrlib.core.character import ADVANCEMENT_STREAM
from osrlib.core.combat import resolve_energy_drain
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=3)
spawn = streams.get(MONSTER_SPAWN_STREAM)
catalog = load_monsters()
wight = spawn_monster(catalog.get("wight"), id="wight-1", stream=spawn)
ogre = spawn_monster(catalog.get("ogre"), id="ogre-1", stream=spawn)

events = resolve_energy_drain(wight, ogre, stream=streams.get(ADVANCEMENT_STREAM))
assert events[0].code == "combat.drain.drained"
assert ogre.hit_dice_count == 3  # the wight's touch takes one Hit Die

resolve_gaze

resolve_gaze(
    gazer: Creature,
    engaged: Sequence[Combatant],
    *,
    stream: RngStream,
    ledger: EffectsLedger,
    clock: GameClock,
    allocator: Any,
    registry: dict[str, Any]
) -> list[Event]

Resolve one round of a petrifying gaze against the engaged combatants.

Call this once per round for a basilisk, a medusa, or anything else whose look turns creatures to stone, on top of whatever it does with its attacks. You supply who's engaged with it, because nothing in the kernel tracks position.

Each engaged combatant that's neither averting its eyes nor already out of the fight saves against paralysis, and a failed save attaches permanent petrification. Stone isn't dead: the effect is recoverable. Fighting with averted eyes costs an attack modifier, which is yours to apply through the AttackContext of the attacks that round, and counterplay with a mirror stays a matter for the referee.

Parameters:

Name Type Description Default
gazer Creature

The gazing Creature, which in the SRD's monsters is always a MonsterInstance. Nothing is read from it.

required
engaged Sequence[Combatant]

The creatures in melee with it, each a Combatant, which a Character and a MonsterInstance both satisfy.

required
stream RngStream

The stream the saves draw from, conventionally COMBAT_STREAM. One draw per combatant that has to save.

required
ledger EffectsLedger

The EffectsLedger petrification attaches through.

required
clock GameClock

The game clock, which dates the attachment.

required
allocator Any

The IdAllocator that mints effect ids.

required
registry dict[str, Any]

Live objects by entity id, so the ledger can find the targets.

required

Returns:

Type Description
list[Event]

The save and petrification events, per engaged combatant in order.

Examples:

from osrlib.core.clock import GameClock
from osrlib.core.combat import COMBAT_STREAM, resolve_gaze
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.data import load_monsters

streams = RngStreams(master_seed=5)
spawn = streams.get(MONSTER_SPAWN_STREAM)
catalog = load_monsters()
basilisk = spawn_monster(catalog.get("basilisk"), id="basilisk-1", stream=spawn)
goblins = [spawn_monster(catalog.get("goblin"), id=f"goblin-{n}", stream=spawn) for n in (1, 2)]

events = resolve_gaze(
    basilisk,
    goblins,
    stream=streams.get(COMBAT_STREAM),
    ledger=EffectsLedger(),
    clock=GameClock(),
    allocator=IdAllocator(),
    registry={goblin.id: goblin for goblin in goblins},
)
assert [event.code for event in events][:2] == ["combat.save.passed", "combat.save.failed"]
assert [has_condition(goblin, Condition.PETRIFIED) for goblin in goblins] == [False, True]

resolve_splash_attack

resolve_splash_attack(
    attacker: Combatant,
    defender: Combatant,
    attack: GearTemplate,
    *,
    context: AttackContext,
    ruleset: Ruleset,
    stream: RngStream,
    ledger: EffectsLedger,
    clock: GameClock,
    allocator: Any,
    registry: dict[str, Any]
) -> AttackResult

Resolve a thrown splash weapon: the attack, the first application, the douse.

Use this rather than resolve_attack for holy water and burning oil, because a splash weapon damages its target twice: once on the hit, and once more when the douse expires at the next round boundary. This function runs resolve_attack and then attaches the douse, so it needs the effects machinery a bare attack doesn't: a ledger, a clock, an id allocator, and a registry.

Holy water against a living target, and burning oil against a monster that uses fire, resolve as a hit that does nothing rather than as a rejection. A rejection is free, and a free one would give away what B/X keeps hidden until it matters.

Parameters:

Name Type Description Default
attacker Combatant

The throwing Combatant, which a Character and a MonsterInstance both satisfy.

required
defender Combatant

The target, a Combatant, which a Character and a MonsterInstance both satisfy. Mutated in place when damage lands.

required
attack GearTemplate

The splash gear item, which is holy water or a flask of oil.

required
context AttackContext

The situation you assert. Oil does nothing unless lit is true.

required
ruleset Ruleset

The ruleset in play.

required
stream RngStream

The stream every draw comes from, conventionally COMBAT_STREAM.

required
ledger EffectsLedger

The EffectsLedger the douse attaches through, and the one whose expiries you must run for the second application to land.

required
clock GameClock

The game clock, which dates the douse's expiry.

required
allocator Any

The IdAllocator that mints the douse effect's id.

required
registry dict[str, Any]

Live objects by entity id, so the ledger can find the target again.

required

Returns:

Type Description
AttackResult

The full resolution with its events, the attachment event last when a douse was attached.

Examples:

from osrlib.core.clock import GameClock
from osrlib.core.combat import COMBAT_STREAM, AttackContext, resolve_splash_attack
from osrlib.core.effects import 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.data import load_equipment, load_monsters

streams = RngStreams(master_seed=5)
spawn = streams.get(MONSTER_SPAWN_STREAM)
catalog = load_monsters()
goblin = spawn_monster(catalog.get("goblin"), id="goblin-1", stream=spawn)
ogre = spawn_monster(catalog.get("ogre"), id="ogre-1", stream=spawn)

result = resolve_splash_attack(
    goblin,
    ogre,
    load_equipment().get("oil_flask"),
    context=AttackContext(lit=True, distance_feet=10),
    ruleset=Ruleset(),
    stream=streams.get(COMBAT_STREAM),
    ledger=EffectsLedger(),
    clock=GameClock(),
    allocator=IdAllocator(),
    registry={"goblin-1": goblin, "ogre-1": ogre},
)
assert result.attack_roll.hit
assert result.damage == 2
assert ogre.current_hp == 15  # down from 17
assert result.events[-1].code == "effects.effect.attached"  # the douse, due next round

roll_initiative

roll_initiative(participants: Sequence[Participant], *, ruleset: Ruleset, stream: RngStream) -> InitiativeResult

Roll initiative: by side, or per participant under individual_initiative.

Call this once at the top of each combat round, then act through the order it returns. Build one Participant per combatant, in the order you want equal ranks broken, and compute each modifier with participant_modifier. The function reads nothing off your combatants and changes nothing: it works entirely from the participants you describe.

Under side initiative, which is the default, each side rolls one 1d6 and everyone on that side acts together. Under the individual_initiative ruleset flag, each participant rolls its own die and adds its modifier.

Ties always re-roll. The tabletop rules offer "re-roll or simultaneous", and osrlib re-rolls because simultaneous resolution is a different combat model. Tied sides, or tied individuals among themselves, re-roll in stable input order until the totals are distinct, and each re-roll takes another draw, so a round with a tie costs more draws than one without. Slow-weapon actors act after every non-slow actor, ordered among themselves by their side's initiative, or by their own under individual initiative, and then by input order.

Parameters:

Name Type Description Default
participants Sequence[Participant]

One entry per combatant, in the order that breaks equal ranks.

required
ruleset Ruleset

The ruleset in play. individual_initiative selects the mode.

required
stream RngStream

The stream the d6s come from, conventionally COMBAT_STREAM. One draw per side, or per participant under individual initiative, plus one for each re-roll a tie forces.

required

Returns:

Type Description
InitiativeResult

The rolls, re-rolls included, and the full acting order.

Examples:

from osrlib.core.combat import COMBAT_STREAM, Participant, roll_initiative
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset

combat = RngStreams(master_seed=3).get(COMBAT_STREAM)
result = roll_initiative(
    [
        Participant(key="hild", side="party"),
        Participant(key="brand", side="party", slow=True),  # a two-handed sword
        Participant(key="goblin-1", side="goblins"),
    ],
    ruleset=Ruleset(),
    stream=combat,
)
assert result.mode == "side"
assert result.order == ("hild", "goblin-1", "brand")  # the slow actor goes last
assert [(entry.key, entry.total) for entry in result.entries] == [("party", 5), ("goblins", 3)]

roll_reaction

roll_reaction(*, modifier: int = 0, stream: RngStream) -> ReactionRollResult

Roll a monster reaction: 2d6 plus the modifier against the reaction table.

Roll this when the party meets monsters that aren't already fighting, to learn whether they attack, wait, or talk. The result changes nothing on its own: acting on it is yours, and a hostile band is where start_battle comes in for a session.

The CHA modifier is yours to supply, from the speaking character's npc_reaction_modifier, because the tabletop rules apply it only when one particular character tries to speak with the monsters. A total outside 2 to 12 falls into the table's outermost band rather than erroring.

Parameters:

Name Type Description Default
modifier int

The speaking character's CHA reaction modifier, when one applies.

0
stream RngStream

The stream the 2d6 comes from. Reaction rolls belong to the encounter procedure rather than to battle, so a session draws them from ENCOUNTER_STREAM. Use the same key in a standalone script to replay a session's encounters. Two draws.

required

Returns:

Type Description
ReactionRollResult

The outcome. Its event has referee visibility, because players learn a monster's mood from its behaviour, just as they do its morale.

Examples:

from osrlib.core.combat import roll_reaction
from osrlib.core.rng import RngStreams

encounter = RngStreams(master_seed=3).get("encounter")

met = roll_reaction(stream=encounter)
assert met.roll == 6
assert met.result == "uncertain"

spoken_to = roll_reaction(modifier=2, stream=encounter)
assert (spoken_to.roll, spoken_to.modifier, spoken_to.total) == (3, 2, 5)
assert spoken_to.result == "hostile"  # a 5 is still a bad start

saving_throw

saving_throw(
    target: Combatant,
    category: SaveCategory,
    *,
    modifier: int = 0,
    magical: bool = False,
    element: str | None = None,
    source: Creature | None = None,
    stream: RngStream
) -> SaveResult

Roll a saving throw: 1d20 at or above the target's value for the category.

Call this whenever something forces a save, then act on passed yourself: the save reports a verdict and changes nothing. Pick the category from SaveCategory. Breath weapons and petrifying gazes already save through resolve_breath and resolve_gaze, so you need this for spells, traps, poisons, and saves of your own.

The roll gathers the target's bonuses so you don't have to. A character adds its WIS magic-save modifier when magical is true and the category isn't breath, since the rules say the WIS bonus "does not normally include saves against breath attacks". Anything a referee wants beyond that arrives as your modifier. Save bonuses from spells apply under the cumulative rule, whether unconditional, element-scoped like resist cold and resist fire against a matching element, or alignment-scoped like protection from evil against source. Equipped items add their bonuses on top, outside the spell caps. An energy defense that auto-saves, which is what a dragon has against magical forms of its own element, passes without a roll and without a draw.

Parameters:

Name Type Description Default
target Combatant

The saving Combatant, which a Character and a MonsterInstance both satisfy.

required
category SaveCategory

The saving throw category.

required
modifier int

Your adjustment, added to everything the target supplies.

0
magical bool

Whether the effect is magical. It turns on the WIS modifier and is what an auto-saving energy defense keys off.

False
element str | None

The effect's element, read by auto-save defenses and by element-scoped save bonuses.

None
source Creature | None

The Creature whose attack or ability forced the save, a Character or a MonsterInstance. Only alignment-scoped save bonuses read it.

None
stream RngStream

The stream the d20 comes from, conventionally COMBAT_STREAM. One draw, or none on an automatic save.

required

Returns:

Type Description
SaveResult

The outcome, with its events.

Examples:

from osrlib.core.combat import COMBAT_STREAM, SaveCategory, saving_throw
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=3)
spawn = streams.get(MONSTER_SPAWN_STREAM)
goblin = spawn_monster(load_monsters().get("goblin"), id="goblin-1", stream=spawn)

result = saving_throw(goblin, SaveCategory.DEATH, stream=streams.get(COMBAT_STREAM))
assert (result.roll, result.required) == (20, 14)
assert result.passed
assert [event.code for event in result.events] == ["combat.save.passed"]

select_targets

select_targets(
    mode: TargetingMode,
    candidates: Sequence[Creature],
    *,
    stream: RngStream,
    count: int | None = None,
    count_dice: str | None = None,
    hd_budget: int | None = None
) -> tuple[list[Creature], list[Event]]

Resolve the shared targeting model against an explicit candidate list.

Spells, breath weapons, and thrown weapons all choose their victims through this one function, so a caster, a dragon, and a flask of oil pick targets by the same rules. You supply the candidates, because nothing in the kernel tracks position: who's in range, in the blast, or engaged with the gazer is your judgment, and a session's battle machine supplies it from its range track. Hand the selected targets to whichever resolution follows, like saving_throw or deal_damage.

How each mode chooses, from TargetingMode. self and single take the first candidate. up_to_n takes the first N in your order, with N either the fixed count or the rolled count_dice, which is where hold person's 1d4 comes from. area and gaze take every candidate. hd_budget spends its budget weakest first by effective_hd, breaking ties by your input order. The budget buys whole creatures, and a candidate larger than what's left is skipped while the selection continues down the list, which is the arithmetic sleep uses.

Parameters:

Name Type Description Default
mode TargetingMode

The targeting mode.

required
candidates Sequence[Creature]

The candidates, in the order you want ties and precedence broken. Each a Creature, which a Character and a MonsterInstance both satisfy.

required
stream RngStream

The stream a rolled count draws from, conventionally COMBAT_STREAM. Only count_dice draws.

required
count int | None

The fixed N for up_to_n.

None
count_dice str | None

The dice expression for a rolled N in up_to_n. count wins when both are given.

None
hd_budget int | None

The Hit Dice budget for hd_budget, which is required in that mode.

None

Returns:

Type Description
tuple[list[Creature], list[Event]]

The selected targets, in selection order, and the targeting event, which has referee visibility.

Raises:

Type Description
ValueError

If hd_budget mode is used without a budget, or the mode is not one of the targeting modes.

Examples:

from osrlib.core.combat import COMBAT_STREAM, TargetingMode, select_targets
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=3)
spawn = streams.get(MONSTER_SPAWN_STREAM)
catalog = load_monsters()
ogre = spawn_monster(catalog.get("ogre"), id="ogre-1", stream=spawn)
goblins = [spawn_monster(catalog.get("goblin"), id=f"goblin-{n}", stream=spawn) for n in (1, 2, 3)]
combat = streams.get(COMBAT_STREAM)

# Four Hit Dice of sleep: three 1 HD goblins first, then nothing left for the 4 HD ogre.
selected, events = select_targets(TargetingMode.HD_BUDGET, [ogre, *goblins], stream=combat, hd_budget=4)
assert [target.id for target in selected] == ["goblin-1", "goblin-2", "goblin-3"]
assert events[0].code == "combat.targeting.selected"

selected, _ = select_targets(TargetingMode.UP_TO_N, goblins, stream=combat, count=2)
assert [target.id for target in selected] == ["goblin-1", "goblin-2"]

splash_douse_definition

splash_douse_definition(attack: Attack, source: DamageSource) -> EffectDefinition

Build the splash weapon's dousing effect: one more application next round.

resolve_splash_attack builds and attaches this for you on a damaging hit, so call it yourself only when you're attaching the douse through an EffectsLedger of your own. Build the source with damage_source_for from the same attack and context, so the second application presents the same keys and element as the first.

The rules read "inflicted for two rounds" as two applications: the hit's damage now, and the same dice once more when the effect expires at the next round boundary.

Parameters:

Name Type Description Default
attack Attack

The splash item. Its combat facet's damage dice are rolled again for the second application.

required
source DamageSource

The damage source the hit presented, whose keys and element are presented again.

required

Returns:

Type Description
EffectDefinition

A one-round EffectDefinition whose expiry deals the second application. Attach it with EffectsLedger.attach.

Raises:

Type Description
ValueError

If the attack has no combat facet, so there are no dice for the second application.

Examples:

from osrlib.core.combat import AttackContext, damage_source_for, splash_douse_definition
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.data import load_equipment, load_monsters

spawn = RngStreams(master_seed=3).get(MONSTER_SPAWN_STREAM)
goblin = spawn_monster(load_monsters().get("goblin"), id="goblin-1", stream=spawn)

oil = load_equipment().get("oil_flask")
context = AttackContext(lit=True)
definition = splash_douse_definition(oil, damage_source_for(goblin, oil, context))
assert definition.kind == "splash_douse"
assert definition.duration_amount == 1
assert definition.params == {"dice": "1d8", "keys": (), "element": "fire"}

validate_attack

validate_attack(
    attacker: Creature, defender: Creature, attack: Attack, context: AttackContext, *, ruleset: Ruleset
) -> list[Rejection]

Validate an attack: the pure pre-phase, with no RNG draws and no mutation.

Call this before resolve_attack when the attack might be illegal and you'd rather tell the player why than resolve it. resolve_attack doesn't call it for you, because a rejection costs nothing and what to do with one is yours to decide. An empty list means the attack may be rolled.

It rejects an attacker who is dead, petrified, paralysed, asleep, weakened, or blind. It rejects a missile shot past its long range, a reload-quality weapon fired two rounds running under the weapon_reload ruleset flag, and a melee attack at a stated distance beyond MELEE_REACH_FEET. A blocked attacker produces one rejection and stops, so the list names the first reason rather than every reason.

Parameters:

Name Type Description Default
attacker Creature

The attacking Creature, which a Character and a MonsterInstance both satisfy. Only its conditions are read here.

required
defender Creature

The defending Creature, a Character or a MonsterInstance. Nothing is read from it.

required
attack Attack

The weapon, facet, gear item, or monster attack (None for unarmed).

required
context AttackContext

The situation you assert. This reads distance_feet and fired_last_round.

required
ruleset Ruleset

The ruleset in play. weapon_reload is enforced here.

required

Returns:

Type Description
list[Rejection]

Structured Rejection values, each with a code and the parameters a message needs. Empty when the attack may be rolled.

Examples:

from osrlib.core.combat import AttackContext, validate_attack
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.data import load_equipment, load_monsters

spawn = RngStreams(master_seed=3).get(MONSTER_SPAWN_STREAM)
catalog = load_monsters()
goblin = spawn_monster(catalog.get("goblin"), id="goblin-1", stream=spawn)
orc = spawn_monster(catalog.get("orc"), id="orc-1", stream=spawn)
sword = load_equipment().get("sword")
rules = Ruleset()

assert validate_attack(goblin, orc, sword, AttackContext(distance_feet=5), ruleset=rules) == []

far = validate_attack(goblin, orc, sword, AttackContext(distance_feet=30), ruleset=rules)
assert far[0].code == "combat.attack.out_of_reach"
assert far[0].params == {"attacker": "goblin-1", "distance_feet": 30}

validate_breath

validate_breath(monster: MonsterInstance) -> list[Rejection]

Validate a breath weapon use against the per-monster daily limit.

Call this before resolve_breath, which raises rather than rejecting when the monster can't breathe. Like every validator here it's pure: no draws, no mutation, and no cost. An empty list means the monster may breathe.

It rejects a monster with no breath weapon, and one that has already used its daily allowance, which is three for the dragons. A breath weapon with no daily limit, like the hellhound's, never exhausts. How often such a monster breathes is a matter for the action policy that chooses its moves, not for this validator.

Parameters:

Name Type Description Default
monster MonsterInstance

The breathing MonsterInstance, whose breath_uses_today is what the limit is checked against.

required

Returns:

Type Description
list[Rejection]

Structured Rejection values. Empty when the monster may breathe.

Examples:

from osrlib.core.combat import validate_breath
from osrlib.core.monsters import MONSTER_SPAWN_STREAM, spawn_monster
from osrlib.core.rng import RngStreams
from osrlib.data import load_monsters

spawn = RngStreams(master_seed=3).get(MONSTER_SPAWN_STREAM)
catalog = load_monsters()
dragon = spawn_monster(catalog.get("white_dragon"), id="dragon-1", stream=spawn)
goblin = spawn_monster(catalog.get("goblin"), id="goblin-1", stream=spawn)

assert validate_breath(dragon) == []
assert validate_breath(goblin)[0].code == "combat.breath.no_breath_weapon"

dragon.breath_uses_today = 3
assert validate_breath(dragon)[0].code == "combat.breath.exhausted"