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
Attack = WeaponTemplate | CombatFacet | GearTemplate | MonsterAttack | MagicItemInstance | None
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:
MELEE_REACH_FEET
module-attribute
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.
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.
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.
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.
modifier
class-attribute
instance-attribute
modifier: int = 0
The signed total of every modifier applied to the roll.
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.
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
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
Every participant's key, in acting order. Slow actors come last.
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.
modifier
class-attribute
instance-attribute
modifier: int = 0
The situational adjustment that was applied, after the clamp to ±2.
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
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
|
required |
modifier
|
int
|
The situational adjustment, clamped to ±2. |
0
|
stream
|
RngStream
|
The stream the 2d6 comes from, conventionally
|
required |
Returns:
| Type | Description |
|---|---|
MoraleResult | None
|
The result, or |
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
result: ReactionResult
The band the total fell in, from hostile through friendly.
total
instance-attribute
total: int
roll plus modifier.
It can fall outside 2 to 12, in which case the table's outermost band applies.
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 ray or poison, and the fallback category for anything with no category of its own.
WANDS
class-attribute
instance-attribute
Magic wands, and the category devices save under.
PARALYSIS
class-attribute
instance-attribute
Paralysis or petrification, which is what a petrifying gaze forces.
BREATH
class-attribute
instance-attribute
Breath attacks. The WIS magic-save modifier doesn't apply to this one.
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.
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.
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.
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
The caster or user only. The first candidate is taken.
SINGLE
class-attribute
instance-attribute
One creature. The first candidate is taken.
UP_TO_N
class-attribute
instance-attribute
The first N candidates in your order, with N either fixed or rolled (hold person's 1d4).
HD_BUDGET
class-attribute
instance-attribute
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
Every candidate. The footprint is yours to resolve.
alignments_differ
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
|
required |
target
|
Creature
|
The warded |
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 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 |
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'
|
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 |
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 |
required |
Returns:
| Type | Description |
|---|---|
WeaponTemplate | CombatFacet | None
|
The facet with the dice, qualities, and ranges. |
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 |
required |
defender
|
Combatant
|
The defending |
required |
attack
|
Attack
|
The weapon, facet, gear item, or monster attack ( |
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
|
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 |
Examples:
cannot_move
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 |
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 |
required |
source
|
DamageSource
|
The damage source presented. |
required |
ruleset
|
Ruleset
|
The ruleset in play. |
required |
attacker
|
Creature | None
|
The attacking |
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
|
required |
Returns:
| Type | Description |
|---|---|
MoraleResult
|
The outcome. Its event states the verdict in |
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 |
required |
attack
|
Attack
|
The weapon, facet, gear item, or monster attack ( |
required |
context
|
AttackContext
|
The situation you assert. Its |
required |
ruleset
|
Ruleset
|
The ruleset in play. |
required |
stream
|
RngStream
|
The stream to draw the damage dice from, conventionally
|
required |
defender
|
Creature | None
|
The defending |
None
|
Returns:
| Type | Description |
|---|---|
RollResult
|
The damage roll. |
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 |
required |
attack
|
Attack
|
The weapon, facet, gear item, or monster attack ( |
required |
context
|
AttackContext
|
The attack context. Set |
required |
Returns:
| Type | Description |
|---|---|
DamageSource
|
The frozen damage source, ready for |
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 |
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 |
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: |
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 |
required |
source
|
DamageSource | None
|
The destructive damage source, which selects the saving throw category.
|
None
|
ruleset
|
Ruleset | None
|
The ruleset in play. |
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 |
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
|
required |
Returns:
| Type | Description |
|---|---|
list[Event]
|
A surviving monster gets a |
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
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 |
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
|
required |
Returns:
| Type | Description |
|---|---|
RollResult | None
|
The damage roll, or |
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
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 |
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
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 |
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
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 |
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
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 |
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 |
required |
stream
|
RngStream
|
The stream the 1d3 comes from. Natural healing is effect-internal
randomness, so it draws from
|
required |
ledger
|
EffectsLedger | None
|
The |
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
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 |
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 |
required |
defender
|
Combatant
|
The defending |
required |
attack
|
Attack
|
The weapon, facet, gear item, or monster attack ( |
required |
context
|
AttackContext
|
The situation you assert. |
required |
ruleset
|
Ruleset
|
The ruleset in play. |
required |
stream
|
RngStream
|
The stream every draw in the resolution comes from, conventionally
|
required |
clock
|
GameClock | None
|
The game clock. When passed, the damage stamps the defender's
|
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 |
required |
targets
|
Sequence[Combatant]
|
required | |
ruleset
|
Ruleset
|
The ruleset in play. |
required |
stream
|
RngStream
|
The stream every draw comes from, conventionally
|
required |
clock
|
GameClock | None
|
The game clock. When passed, the damage stamps each target's
|
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 |
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 |
required |
target
|
Creature
|
required | |
stream
|
RngStream
|
The stream the lost-hit-point dice come from. Drain reverses advancement,
so it draws from
|
required |
Returns:
| Type | Description |
|---|---|
list[Event]
|
The drain events. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the attacker has no |
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 |
required |
engaged
|
Sequence[Combatant]
|
required | |
stream
|
RngStream
|
The stream the saves draw from, conventionally
|
required |
ledger
|
EffectsLedger
|
The |
required |
clock
|
GameClock
|
The game clock, which dates the attachment. |
required |
allocator
|
Any
|
The |
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 |
required |
defender
|
Combatant
|
The target, a |
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 |
required |
ruleset
|
Ruleset
|
The ruleset in play. |
required |
stream
|
RngStream
|
The stream every draw comes from, conventionally
|
required |
ledger
|
EffectsLedger
|
The |
required |
clock
|
GameClock
|
The game clock, which dates the douse's expiry. |
required |
allocator
|
Any
|
The |
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. |
required |
stream
|
RngStream
|
The stream the d6s come from, conventionally
|
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
|
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 |
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 |
None
|
stream
|
RngStream
|
The stream the d20 comes from, conventionally
|
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
|
required |
stream
|
RngStream
|
The stream a rolled count draws from, conventionally
|
required |
count
|
int | None
|
The fixed N for |
None
|
count_dice
|
str | None
|
The dice expression for a rolled N in |
None
|
hd_budget
|
int | None
|
The Hit Dice budget for |
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 |
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 |
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 |
required |
defender
|
Creature
|
The defending |
required |
attack
|
Attack
|
The weapon, facet, gear item, or monster attack ( |
required |
context
|
AttackContext
|
The situation you assert. This reads |
required |
ruleset
|
Ruleset
|
The ruleset in play. |
required |
Returns:
| Type | Description |
|---|---|
list[Rejection]
|
Structured |
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 |
required |
Returns:
| Type | Description |
|---|---|
list[Rejection]
|
Structured |
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"