osrlib.crawl.battle
Run a fight round by round on the range track, with a pluggable policy driving the monsters.
This module takes over once osrlib.crawl.encounter has decided there is a
fight. start_battle is the entry point, and the encounter
procedure calls it for you: from an attacking stance, from a hostile group's deadline arriving,
from a chase that closed to arm's length, and from the party's own
EngageBattle command. After it returns the session is in
battle mode, session.battle holds a BattleState, and each
round is one ResolveBattleRound command with one
BattleDeclaration per living, able party member,
dispatched through the session's private handler table. No command ends the battle. It ends
from inside, when the party is wiped, when every monster group is dead or routed, or when the whole
party retreats. A victory hands control straight to
end_encounter.
The results reach a front end as events from osrlib.crawl.events:
BattleStartedEvent,
BattleRoundEvent opening each round,
SpellDeclaredEvent for every cast declared that round,
GroupMovedEvent as the range track changes,
MonsterFledEvent when a side breaks,
MonstersLeftBehindEvent for the helpless a fleeing
side abandons, and BattleEndedEvent reporting victory,
defeat, or flight. The kernel's own attack, damage, saving throw, initiative, and morale events come
back interleaved with those.
A round wraps the kernel in the OSE SRD's sequence: declaration, initiative, then per side morale,
movement, missiles, magic, and melee, with slow-weapon actors last. Every resolution step is a
function in osrlib.core.combat or
osrlib.core.spells, among them
roll_initiative,
resolve_attack,
resolve_breath,
morale_triggers, and
cast_spell. What this module adds to them is the ordering, the state
it hands each of them (the RNG streams, the effects ledger, the clock, the entity registry), the
range track the kernel has no notion of, and the party's formation. Call those
kernel functions directly when you want one resolution and no session at all. The guide on using the
rules without a session walks that path.
The combat space is the abstract per-group range track, the Bard's Tale convention, as a documented
adaptation (see the
adaptations register, the page listing where
osrlib commits to one reading of an ambiguous rule or supplies a default behind a Ruleset flag).
Each monster group sits at a distance from the party, closes at its encounter rate, and fights at
MELEE_RANGE_FEET. Party ranks derive from marching order
under the ruleset's formation_width_limit flag, and the width is the frontage the party's own space
offers at FIGHTER_FRONTAGE_FEET to a combatant: two
abreast in a ten-foot passage, and a room's shorter side in a room.
The machine detects spell disruption, meaning a declared caster who is successfully attacked or fails
a save after initiative resolves against them and before their own action. It checks morale on its
own, with no command for it. It ends each single-use protection as it is spent. Invisibility breaks
when the member attacks, throws or unleashes an item, or turns undead, and the kernel's
cast_spell is what breaks it on a cast. An incoming attack on a
target under mirror image pops one figment instead, hit or miss, because no attack roll is made at
all. A protection ward breaks when the party melees a monster it barred. A concentration spell's
effects release when its caster declares anything other than cast, turn_undead, or hold. Area
footprints resolve deterministically: an area's capacity in creatures is ceil(span / 10) × width,
filled in stable spawn order, cones reach-limited, with the engaged party front rank appended under
the ruleset's aoe_friendly_fire flag.
The monster and NPC-party sides act through a pluggable
ActionPolicy you can substitute per encounter side.
ScriptedPolicy and
NpcPartyPolicy ship as the defaults, and both draw only from
the monster_action stream, so a policy of your own never shifts an attack or damage draw. Initiative
still resolves in side blocks, because the SRD's phase sequence runs per side rather than per
combatant, and the sides order by their best individual total.
Typical usage:
from osrlib.core.alignment import Alignment
from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.crawl.adventure import Adventure, TownSpec
from osrlib.crawl.commands import (
BattleDeclaration,
EngageBattle,
EnterDungeon,
ResolveBattleRound,
SessionMode,
SpawnMonsters,
)
from osrlib.crawl.dungeon import DungeonSpec, Edge, EdgeKind, LevelSpec
from osrlib.crawl.party import Party
from osrlib.crawl.session import GameSession
rules = Ruleset()
draw = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM)
members = [
create_character(
name=name,
class_id="fighter",
alignment=Alignment.LAWFUL,
ruleset=rules,
stream=draw,
).character
for name in ("Hild", "Osric")
]
level = LevelSpec(
number=1,
width=2,
height=1,
entrance=(0, 0),
edges={"1,0:west": Edge(kind=EdgeKind.OPEN)},
)
crypt = DungeonSpec(id="crypt", name="The Old Crypt", levels=(level,))
town = TownSpec(name="Threshold", travel_turns={"crypt": 1})
adventure = Adventure(name="A First Delve", town=town, dungeons=(crypt,))
session = GameSession.new(Party(members=members), adventure, seed=7)
session.execute(EnterDungeon(dungeon_id="crypt"))
# Spawn a group already within reach, then choose to fight it.
session.execute(SpawnMonsters(template_id="goblin", count_fixed=2, distance_feet=5))
session.execute(EngageBattle())
assert session.mode is SessionMode.BATTLE
assert session.battle.round == 0
# One command is one round: a declaration per living, able member, every group named by id.
group_id = session.encounter.groups[0].id
result = session.execute(
ResolveBattleRound(
declarations=tuple(
BattleDeclaration(character_id=member.id, action="attack", target_group_id=group_id) for member in members
)
)
)
assert result.accepted
assert session.battle.round == 1
assert result.events[0].code == "battle.round.started"
assert "combat.initiative.rolled" in [event.code for event in result.events]
FIGHTER_FRONTAGE_FEET
module-attribute
Feet of frontage one combatant needs in order to fight side by side with the next.
The party's front rank is as wide as its own fighting space divided by this, so a ten-foot passage holds two abreast and a room holds as many as its shorter side allows. A member outside the front rank is rejected for declaring a melee attack. The rank check does not apply to a missile attack, so the back ranks can still shoot.
RAW prints one number for this and leaves the rest to judgement: "The referee should judge the number of opponents that can attack a single combatant, bearing in mind the combatant's size and the available space around them. 10' passage: Enough space for at most 2-3 characters to fight side-by-side." osrlib takes the conservative end of that range, two in ten feet and so five feet each, and then applies it to whatever space the party is actually standing in.
To play without a width limit, turn off the ruleset's formation_width_limit flag, which puts every
living member in the front rank. Changing this constant instead would move the frontage of every space
in the game at once.
FLEE_EXIT_FEET
module-attribute
How far, in feet, a routed group runs before the battle lets it go.
A group that breaks morale turns and runs its full movement rate each round. Once its distance passes this, the group is marked fled: it takes no further action, an attack declared against it is rejected as naming an unknown group, and a battle in which every group has fled or died ends in victory. Inside that window the party can still chase it down or shoot it in the back, which is what the window is for. A group that is merely afraid rather than broken counts as routed on the same terms, once it too is past this distance.
MELEE_RANGE_FEET
module-attribute
The gap, in feet, at which a group on the range track is close enough to trade blows.
A group closing on the party stops here rather than at zero, a melee attack declared against a group further off than this is rejected, and a monster's melee resolves with this as the distance handed to the kernel. It is also where the monster groups stand when a chase collapses into a fight and the pursuers catch the party.
The abstract track has no distance between this and zero, so treat the value as fixed rather than as a
setting: every reach and range check in the module reads it. To give a weapon longer reach, put the
range data on the weapon, which is what
validate_attack reads.
NPC_PARTY_MORALE
module-attribute
The morale score a group of NPC adventurers checks against, on the usual 2 to 12 scale.
Monsters have a morale score in their stat block, but adventurers in the OSE SRD do not, so osrlib
uses the score printed for the Veteran, its own low-level adventurer monster, rather than inventing
one. This is a documented adaptation (see the
adaptations register, the page listing where
osrlib commits to one reading of an ambiguous rule or supplies a default behind a Ruleset flag).
A score of 9 holds on a 2d6 total of 9 or less once the situational modifier is added, and breaks
above that.
The battle machinery reads this value directly, so every NPC adventurer group in the game checks against the same score and there is no per-group override.
ActionPolicy
Bases: Protocol
The monster side's brain: what decides, each round, what one monster group does.
Write a class with a choose method matching this protocol when you want tactics of your own, and
register it for a group by its id on session.action_policies, a plain dict the session keeps and
never serializes. A group with no entry there gets
ScriptedPolicy, or
NpcPartyPolicy when its members are NPC adventurers. Since
policies are code rather than state, re-register yours after loading a save, the way you
re-register listeners.
Every policy draws only from the monster_action stream, so a policy of your own never shifts an
attack or damage roll and the rest of the fight stays reproducible from the same seed. Return
whatever actions your tactics call for: the round handler skips one the situation no longer allows
rather than raising. The shipped policies never cast, because monster casting is tagged for manual
resolution in the data.
Examples:
from osrlib.core.alignment import Alignment
from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.crawl.adventure import Adventure, TownSpec
from osrlib.crawl.battle import MonsterAction
from osrlib.crawl.commands import (
BattleDeclaration,
EngageBattle,
EnterDungeon,
ResolveBattleRound,
SpawnMonsters,
)
from osrlib.crawl.dungeon import DungeonSpec, Edge, EdgeKind, LevelSpec
from osrlib.crawl.party import Party
from osrlib.crawl.session import GameSession
rules = Ruleset()
draw = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM)
hild = create_character(
name="Hild",
class_id="fighter",
alignment=Alignment.LAWFUL,
ruleset=rules,
stream=draw,
).character
level = LevelSpec(
number=1,
width=2,
height=1,
entrance=(0, 0),
edges={"1,0:west": Edge(kind=EdgeKind.OPEN)},
)
crypt = DungeonSpec(id="crypt", name="The Old Crypt", levels=(level,))
town = TownSpec(name="Threshold", travel_turns={"crypt": 1})
adventure = Adventure(name="A First Delve", town=town, dungeons=(crypt,))
session = GameSession.new(Party(members=[hild]), adventure, seed=7)
session.execute(EnterDungeon(dungeon_id="crypt"))
session.execute(SpawnMonsters(template_id="goblin", count_fixed=2, distance_feet=30))
session.execute(EngageBattle())
# Monsters that hold their ground and never swing.
class StandStillPolicy:
def choose(self, session, group, stream):
return [MonsterAction(monster_id=monster_id, kind="hold") for monster_id in group.monster_ids]
# Register the policy for one group, by group id.
group = session.encounter.groups[0]
session.action_policies[group.id] = StandStillPolicy()
result = session.execute(
ResolveBattleRound(
declarations=(BattleDeclaration(character_id=hild.id, action="hold"),),
)
)
assert result.accepted
assert group.distance_feet == 30 # the goblins never close
assert hild.current_hp == hild.max_hp # and never swing
choose
choose(session, group, stream) -> list[MonsterAction]
Choose this round's actions for one monster group.
The round handler calls this once per group per round, after morale has resolved and before anything on that side moves or attacks.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
GameSession
|
The running session. Read the party, the registry, and the effects ledger through it, and don't mutate it here. |
required |
group
|
EncounterGroup
|
The group to act. Its |
required |
stream
|
RngStream
|
The |
required |
Returns:
| Type | Description |
|---|---|
list[MonsterAction]
|
The actions to resolve, in the order they should resolve. Return an empty list for a group that does nothing. Skip the dead and the incapacitated: the handler ignores actions for them anyway. |
BattleState
Bases: BaseModel
A battle in progress: the round counter and the per-round bookkeeping a fight needs to keep.
You get this from session.battle, which is None whenever no battle is running. It is an overlay
on the encounter rather than a replacement for it: the monster groups, their distances, and their
treasure stay on session.encounter, and this holds only what the fight itself has to remember.
It serializes with the session, so a saved game resumes mid-battle. Read it and render from it.
The round handler is what writes every field.
To change a fight while it runs, send a referee command from
osrlib.crawl.commands rather than writing to this model. Every referee
command is legal in battle mode, so a referee can grant an item, award experience, set a flag,
or advance the clock mid-fight, and the change replays from a save because the command goes
through the command log. SpawnMonsters and
SpawnNpcParty are the exception: both reject while an
encounter is open, so a fresh side cannot join a fight already underway.
round
class-attribute
instance-attribute
round: int = 0
Rounds resolved so far. It is 0 between start_battle and
the first ResolveBattleRound, except that a monsters'
free round counts as round 1.
started_round
instance-attribute
started_round: int
The session clock's round count when the battle opened.
monsters_hold_rounds
class-attribute
instance-attribute
monsters_hold_rounds: int = 0
Rounds the monster side still owes to the party's surprise. A round in which this is above zero spends one and the monsters do nothing at all.
morale
class-attribute
instance-attribute
morale: MoraleTracker = MoraleTracker()
The kernel's MoraleTracker for this battle. It records each
group's held checks, and a group that has held two stops checking.
morale_acted
class-attribute
instance-attribute
The morale triggers already spent, per group id. Each trigger fires one check per group per battle, so a side that has already checked on losing its leader does not check again for the same reason.
fired_last_round
class-attribute
instance-attribute
The ids of party members who loosed a missile last round, which is what the kernel's reload rule reads.
melee_engagements
class-attribute
instance-attribute
Per party member id, the monsters that member has actually closed with. A monster barred from a warded character by protection from evil may attack them anyway once it is on this list, which is RAW's own clause.
MonsterAction
Bases: BaseModel
One monster's or NPC adventurer's chosen action for a round: what an action policy returns.
An ActionPolicy builds a list of these and the round handler
resolves them in order. The model is frozen, so build a new one rather than editing one. Nothing
validates a policy's output ahead of time: an action the situation does not allow, like a melee against
a target that died earlier in the round, is skipped when the handler reaches it.
monster_id
instance-attribute
monster_id: str
The acting combatant's entity id, which must be one of its own group's monster_ids.
kind
instance-attribute
kind: str
What the combatant does. "close" advances the whole group one encounter rate toward the party
and stops at MELEE_RANGE_FEET, and only the first such
action in a round moves the group. "melee" attacks target_id. "breath" looses the monster's
breath weapon over the party. "hold" does nothing. "npc_shoot", "npc_cast", and "npc_drink"
are the NPC adventurer actions: a missile attack on target_id, a cast of spell_id, and a
swallowed potion named by item_id.
target_id
class-attribute
instance-attribute
target_id: str | None = None
The target's entity id for "melee", "npc_shoot", and a single-target "npc_cast". None for
an area spell, which takes its own targets from the footprint rule.
spell_id
class-attribute
instance-attribute
spell_id: str | None = None
The spell to cast, for "npc_cast".
spell_mode
class-attribute
instance-attribute
spell_mode: str | None = None
Which mode of that spell to use, for "npc_cast".
item_id
class-attribute
instance-attribute
item_id: str | None = None
The magic item instance id to use, for "npc_drink".
NpcPartyPolicy
The default ActionPolicy for a group of NPC adventurers.
A group whose members are NPC adventurers rather than monsters runs on one of these unless you
registered something else for it on session.action_policies. The OSE SRD gives no tactics of its
own for an opposing party of adventurers, so osrlib supplies these, as a documented adaptation
(see the
adaptations register, the page listing where
osrlib commits to one reading of an ambiguous rule or supplies a default behind a Ruleset
flag).
Each living member picks the first of these that applies. A caster holding a memorized healing
spell heals the group's most wounded member below half hit points, taking the lowest ratio of
current to maximum and breaking ties by id. A member below half hit points whose group has no
healing spell left drinks a healing potion it carries, which is the only item use these tactics
make. A caster holding a memorized attack spell that osrlib resolves without a referee casts it at
the party, highest spell level first and ties broken by spell id, taking area targets through the
footprint rule and a single target uniformly from the rank it can reach. A member with a missile
weapon shoots while the gap is wider than
MELEE_RANGE_FEET. Failing all of that, the member closes
and melees, the same as a monster.
These casts post their declarations at the top of the round and are disruptable exactly as the
party's are, because RAW's disruption trigger does not care which side declared. Every choice draws
from the monster_action stream alone.
choose
choose(session, group, stream) -> list[MonsterAction]
Choose this round's actions for one group of NPC adventurers.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
GameSession
|
The running session. |
required |
group
|
EncounterGroup
|
The group to act. |
required |
stream
|
RngStream
|
The |
required |
Returns:
| Type | Description |
|---|---|
list[MonsterAction]
|
One |
ScriptedPolicy
The default ActionPolicy for monsters: breath, then close and melee.
Every monster group runs on one of these unless you registered something else for it on
session.action_policies, so you construct one yourself only to wrap it.
Monsters whose data includes a scripted pattern follow it. A breath weapon with a daily use count
opens with breath, then takes breath or melee with equal chance while uses remain, which is how the
OSE SRD's dragons fight. A breath weapon gated on a chance in six rolls that gate each round, as
the hellhound's does. Otherwise a group further off than
MELEE_RANGE_FEET closes, and once at that range each
monster picks its target uniformly from the party rank it can reach. Monster missile routines have
no structured range data, so osrlib treats them the way it treats melee, closing first and then
attacking, as a documented adaptation (see the
adaptations register, the page listing where
osrlib commits to one reading of an ambiguous rule or supplies a default behind a Ruleset
flag). These groups never cast,
because monster spell casting is tagged for manual resolution in the data.
Substitute a policy of your own when you want a side to hold a chokepoint, concentrate its
attacks, back off, or cast. NpcPartyPolicy is the shipped
example of a policy that does more than this one.
choose
choose(session, group, stream) -> list[MonsterAction]
Choose this round's actions for one monster group.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
GameSession
|
The running session. |
required |
group
|
EncounterGroup
|
The group to act. |
required |
stream
|
RngStream
|
The |
required |
Returns:
| Type | Description |
|---|---|
list[MonsterAction]
|
One |
start_battle
start_battle(session, *, party_free_round: bool = False, monsters_free_round: bool = False) -> list[Event]
Open battle on the session's current encounter: the range track takes over.
Call this when your own code decides the talking is over. There must be an open encounter on the
session, because the fight runs on that encounter's groups and their distances. On return the
session is in battle mode and session.battle holds a
BattleState. From there, each round is one
ResolveBattleRound command.
Use EngageBattle instead when the party is the one choosing
to fight. That command goes through the session's command log, so the fight replays from a save,
and it works out the surprise arguments and a mid-chase turn-and-fight for you. The encounter
procedure calls this function itself for every other opening, so a front end that only issues
commands never calls it at all.
A group whose morale score is 2 routs the moment battle starts, per RAW, which can end the battle
before a round has run. A surprise advantage becomes one free round for the side that holds it: the
monsters' free round resolves inside this call, with the party unable to answer, while the party's
free round holds the monsters through the first ResolveBattleRound.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
GameSession
|
The running session, with |
required |
party_free_round
|
bool
|
True when the monsters were surprised. The monster side then sits out the first round. |
False
|
monsters_free_round
|
bool
|
True when the party was surprised and the stance is hostile or attacking. The monsters act once inside this call, before the party's first declaration. |
False
|
Returns:
| Type | Description |
|---|---|
list[Event]
|
The opening events: |
Examples:
from osrlib.core.alignment import Alignment
from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.crawl.adventure import Adventure, TownSpec
from osrlib.crawl.battle import start_battle
from osrlib.crawl.commands import EnterDungeon, SessionMode, SpawnMonsters
from osrlib.crawl.dungeon import DungeonSpec, Edge, EdgeKind, LevelSpec
from osrlib.crawl.party import Party
from osrlib.crawl.session import GameSession
rules = Ruleset()
draw = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM)
hild = create_character(
name="Hild",
class_id="fighter",
alignment=Alignment.LAWFUL,
ruleset=rules,
stream=draw,
).character
level = LevelSpec(
number=1,
width=2,
height=1,
entrance=(0, 0),
edges={"1,0:west": Edge(kind=EdgeKind.OPEN)},
)
crypt = DungeonSpec(id="crypt", name="The Old Crypt", levels=(level,))
town = TownSpec(name="Threshold", travel_turns={"crypt": 1})
adventure = Adventure(name="A First Delve", town=town, dungeons=(crypt,))
session = GameSession.new(Party(members=[hild]), adventure, seed=7)
session.execute(EnterDungeon(dungeon_id="crypt"))
session.execute(SpawnMonsters(template_id="goblin", count_fixed=2, distance_feet=30))
# The party caught the goblins flat-footed, so it gets the first round free.
events = start_battle(session, party_free_round=True)
assert [event.code for event in events] == ["battle.started"]
assert session.mode is SessionMode.BATTLE
assert session.battle.round == 0
assert session.battle.monsters_hold_rounds == 1