osrlib.crawl.encounter
Decide whether and how a fight starts: surprise, distance, reaction, parley, evasion, pursuit.
An encounter is the state a session is in once the party and a monster group are aware of each
other and before anyone has swung. This module opens one, runs it round by round, and closes it. It
takes a GameSession whose party stands on a dungeon cell and
monster instances already in the session registry, and it hands the fighting itself to
osrlib.crawl.battle.
start_encounter is the entry point. After it returns,
the session is in encounter mode and each of Parley,
Evade, Wait,
TurnUndead, and
EngageBattle runs one encounter round through the session's
private handler table, with the monsters acting per their stance after it.
end_encounter closes the encounter and puts the session
back in exploring.
You rarely call start_encounter yourself. The session's
SpawnMonsters and
SpawnNpcParty handlers, the wandering-monster check, and
entry into a keyed area all call it for you. Call it directly when your own content decides that a
group has just come into view.
The results reach a front end as events from osrlib.crawl.events:
a SurpriseRolledEvent per side,
EncounterStartedEvent reporting the count and the
distance, StanceChangedEvent whenever the reaction
moves, EvasionEvent and
PursuitEvent while the party runs,
ExhaustionEvent when a chase runs its course, a
MonsterDefeatedEvent per monster at the close, and
EncounterEndedEvent with the outcome. The reaction
roll itself posts the kernel's
ReactionRolledEvent at referee visibility.
The stance is the monsters' current disposition, and the stance map resolves bands the OSE SRD
leaves to a human referee. A reaction of 2 or less attacks now. A 3 to 5 is hostile: the monsters
attack at the end of the next encounter round unless the party has begun evading or has improved
the stance by parley. A 6 to 8 is uncertain, so the monsters hold and posture, and the reaction
re-rolls next round with no modifier. A 9 to 11 is indifferent, and the party may pass, parley, or
withdraw freely. A 12 or more is friendly. Only the attacking and hostile stances pursue an evading
party, 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).
RAW leaves pursuit itself to the referee, and osrlib keys it to low reactions.
The distance roll is bounded by the space it happens in. RAW rolls 2d6 × 10' only "if there is
uncertainty", and the walls around the party's cell resolve that uncertainty, so the rolled distance
caps at the longest straight sight line the cell affords: the room's own span in a room, the whole
passage down a corridor, never shortened by darkness. A caller that supplies distance_feet is
never capped, because the referee places what the referee spawns.
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 EnterDungeon, Parley, 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"))
# Spawning a group opens the encounter: surprise, distance, and reaction all resolve here.
session.execute(SpawnMonsters(template_id="goblin", count_fixed=2, distance_feet=30))
assert session.mode is SessionMode.ENCOUNTER
assert session.encounter.stance == "indifferent" # seed 7 rolls a reaction of 10 on 2d6
assert session.encounter.groups[0].distance_feet == 30
# One encounter command is one round beat: this one rerolls the reaction with Hild's CHA modifier.
result = session.execute(Parley(character_id=hild.id))
assert result.accepted
assert session.encounter.round == 1
PURSUIT_ROUND_CAP
module-attribute
The round at which a running pursuit gives up and the party gets away, exhausted.
A pursuit normally ends before this: the gap closes to 5' and the pursuers catch the party into
battle, or a sack dropped behind the party distracts them. Neither happens when the two sides run at
the same rate, because the gap then never changes, so the cap is the terminal escape valve. Reaching
it attaches the exhausted condition to every living member, posts an
ExhaustionEvent and a
PursuitEvent with code encounter.pursuit.escaped, and closes
the encounter with the outcome "escaped".
The pursuit code reads this value directly rather than a ruleset setting, so a front end that wants a
shorter chase ends the encounter itself with
end_encounter instead of changing the constant.
EncounterGroup
Bases: BaseModel
One monster group in an encounter: its members, its distance, and the treasure it carries.
You get these from session.encounter.groups. The encounter procedure builds them and the battle
machinery updates them in place, so you never construct one yourself outside a save file. The
group, not the individual monster, is the unit the fight works in: initiative rolls per side,
morale breaks a whole group at once, and a battle order names a group through target_group_id on
BattleDeclaration.
id
instance-attribute
id: str
The session-scoped group id, group-NNNN, from the session's allocator. This is what a battle
declaration's target_group_id names.
label
instance-attribute
label: str
The group's display name: the monster template's name for a spawned or keyed group, and the encounter table row's name for a wandering one.
monster_ids
class-attribute
instance-attribute
The members' entity ids in spawn order, each resolvable through
GameSession.combatant. Dead members stay in the
list, so filter on the dead condition rather than on membership.
distance_feet
class-attribute
instance-attribute
The gap between the party and this group on the range track, in feet. Monsters close at their
encounter rate and stop at MELEE_RANGE_FEET. A routed
group runs the other way.
fleeing
class-attribute
instance-attribute
fleeing: bool = False
True once the group has broken morale and turned to run. It is still on the track and still takes hits in the back.
fled
class-attribute
instance-attribute
fled: bool = False
True once the group has run past FLEE_EXIT_FEET and left
the fight, or once a group with morale 2 routed at the moment battle opened. An attack declared
against it is rejected as naming an unknown group.
member_treasure
class-attribute
instance-attribute
member_treasure: dict[str, TreasureBundle] = {}
The bundle each member carries, keyed by monster id, generated at spawn from the individual treasure types (P through T). A slain member's bundle drops as loot when the encounter closes, and a routed member takes its own away.
group_treasure
class-attribute
instance-attribute
group_treasure: TreasureBundle | None = None
The bundle the group shares, generated at spawn from the group treasure types (U and V), or None when the group carries none. It drops only when every member is defeated, never when any member routed or fled.
EncounterState
Bases: BaseModel
An open encounter: the monster groups, the stance, the surprise result, and any chase.
You get this from session.encounter, which is None whenever no encounter is open. It serializes
with the session, so a saved game restores mid-encounter. Treat it as something to read and render
from, not to edit: this module's own command handlers and the battle machinery own every field on
it.
kind
instance-attribute
kind: str
How the encounter came about: "wandering" from the wandering-monster check, "keyed" from an
area the adventure stocked, or "spawned" from a referee command.
area_ref
class-attribute
instance-attribute
area_ref: str | None = None
The keyed area's state reference, when kind is "keyed". When every monster in the encounter
ends up slain or routed, the close records this reference as resolved, so entering the area again
starts no second fight.
groups
class-attribute
instance-attribute
groups: list[EncounterGroup] = Field(min_length=1)
The monster groups, each an EncounterGroup, in the
order they were spawned. There is always at least one.
stance
class-attribute
instance-attribute
stance: str | None = None
The monsters' current disposition as a ReactionResult
value: "attacks", "hostile", "uncertain", "indifferent", or "friendly". None only between
construction and the first reaction roll.
round
class-attribute
instance-attribute
round: int = 0
started_round
instance-attribute
started_round: int
The session clock's round count when the encounter opened. The close uses it to charge the encounter its minimum one turn.
party_surprised
class-attribute
instance-attribute
party_surprised: bool = False
True when the party lost the surprise roll and gave up a round.
monsters_surprised
class-attribute
instance-attribute
monsters_surprised: bool = False
True when the monsters lost the surprise roll. Both sides surprised is momentary confusion, and neither side gains anything.
monsters_skip_rounds
class-attribute
instance-attribute
monsters_skip_rounds: int = 0
Round beats the monsters still owe to their own surprise. Each beat they sit out spends one, and a battle that opens while any remain gives the party a free round.
hostile_deadline
class-attribute
instance-attribute
hostile_deadline: int | None = None
The encounter round at which a hostile group attacks, set each time the stance turns hostile. None until a hostile result comes up. It is read only while the stance is still hostile, so a stance the party talked back up ignores it.
evading
class-attribute
instance-attribute
evading: bool = False
True once the party has declared an evasion, which suspends a hostile group's deadline while the party backs away.
pursuit
class-attribute
instance-attribute
pursuit: PursuitState | None = None
The PursuitState while a chase runs, else None.
PursuitState
Bases: BaseModel
A chase in progress: the gap between the running party and its pursuers, updated each round.
Read it off session.encounter.pursuit, which is None until an
Evade command fails to shake a hostile group and the chase opens.
While it is set, the encounter is a chase rather than a standoff: parley and turning are rejected,
and Wait runs another chase round instead of another encounter
round.
round
class-attribute
instance-attribute
round: int = 0
Rounds run so far in this chase, counting from 1. The chase ends in escape at
PURSUIT_ROUND_CAP.
gap_feet
class-attribute
instance-attribute
The distance between the party and its pursuers, in feet. It opens at the nearest pursuing group's encounter distance, and each round it changes by the party's running rate less the slowest pursuing group's. At 5' or less the pursuers catch the party into battle.
end_encounter
Close the open encounter: record defeats, drop loot, release effects, and settle the clock.
Call this when the encounter is over on terms your own content decided: the party talked its way
past, or walked away, or the standoff finished. You seldom call it yourself, because a
victory, an evasion, an escape, and a successful turning all call it from inside the encounter and
battle handlers. There must be an encounter open when you call it: with session.encounter set to
None it raises AttributeError on the first line, because nothing guards the dereference. On
return session.encounter is None and the session is back in exploring mode, unless the party
is dead, in which case the session's own wipe check has already taken over.
Every monster that ended slain or routed (fled, still fleeing, or turned) gets a
MonsterDefeatedEvent and a record on
session.defeated_monsters with its experience value. Slain monsters drop the treasure they
carried onto the party's cell as a pile, and routed ones take theirs away. Every effect still
running on any of the encounter's monsters then releases, because the fiction moves on and a dead
troll's pending revival is narration rather than game state. Under a ruleset that
awards experience immediately, the pooled experience divides and applies here and the record list
clears with it. Otherwise the records wait for the party's return to town.
The clock advances to whichever is later: the next turn boundary, or one full turn after the
encounter opened. An encounter that opened mid-turn is therefore charged its full turn and can
close mid-turn, since the boundary clause only guarantees the boundary is reached. The wandering
monster cadence stays suspended across the whole encounter. That whole-turn charge absorbs any
part-turn of walking the party had banked, so session.odometer_thirds resets to 0.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
GameSession
|
The running session, with |
required |
outcome
|
str
|
The label recorded on the
|
required |
Returns:
| Type | Description |
|---|---|
list[Event]
|
The defeat events, the immediate experience award when the ruleset uses one, the
|
Raises:
| Type | Description |
|---|---|
AttributeError
|
If no encounter is open on the session. |
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.commands import EnterDungeon, SessionMode, SpawnMonsters
from osrlib.crawl.dungeon import DungeonSpec, Edge, EdgeKind, LevelSpec
from osrlib.crawl.encounter import end_encounter
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))
opened_at = session.clock.rounds
# The party talks its way clear: close the encounter on your own terms.
events = end_encounter(session, "evaded")
assert [event.code for event in events] == ["encounter.ended"]
assert session.encounter is None
assert session.mode is SessionMode.EXPLORING
assert session.clock.rounds == opened_at + 60 # a turn is 60 rounds, and the encounter owes one
start_encounter
start_encounter(
session,
*,
groups: list[tuple[str, list]],
kind: str,
area_ref: str | None = None,
distance_feet: int | None = None,
monsters_roll_surprise: bool = True,
monsters_aware: bool = False,
party_aware: bool = False,
pinned_stance: ReactionResult | None = None
) -> list[Event]
Open an encounter on a session: surprise, distance, reaction, and their first consequences.
Call this when your own content decides a monster group has come into view. Spawn the monsters
first with GameSession.spawn so their instances are in
the session registry, then pass them here as (label, instances) pairs. On return the session is
in encounter mode and session.encounter holds an
EncounterState you can read and render from. Drive the
encounter with the encounter commands after that, and let
end_encounter close it. An attacking stance opens battle
before this function returns, so check session.battle as well as session.mode.
Use SpawnMonsters instead when a referee wants a fight
on the party's current cell. That command rolls the count, spawns the instances, and calls
this for you, and because it goes through the session's command log the encounter replays from a
save. Reach for this function when you need an argument the command does not expose, like a stance
fixed in advance or monsters that never roll for surprise.
Wandering monsters never roll for surprise, because they come "moving in the direction of the
party". A keyed area marked aware, a failed attempt to force a door, and a party carrying a light
each skip the monsters' roll as well, and a successful listen marks the party aware. The party is
surprised on a d6 of 1 or 2, and on 1 to 3 when it carries no light and not every living member
has infravision, 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, under the blind-party adaptation).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
GameSession
|
The running session. Its party must be standing on a dungeon cell, and no encounter may already be open. |
required |
groups
|
list[tuple[str, list]]
|
One |
required |
kind
|
str
|
How the encounter came about: |
required |
area_ref
|
str | None
|
The keyed area's state reference, when |
None
|
distance_feet
|
int | None
|
The starting gap for every group, in feet. None rolls 2d6 × 10 on the encounter stream and caps the result at the party cell's sight line. A value you supply is used as given. |
None
|
monsters_roll_surprise
|
bool
|
False when these monsters can never be surprised, as wandering monsters cannot. |
True
|
monsters_aware
|
bool
|
True when the monsters already expect intruders, which skips their roll. |
False
|
party_aware
|
bool
|
True when the party has heard the room, which skips its own roll. |
False
|
pinned_stance
|
ReactionResult | None
|
A |
None
|
Returns:
| Type | Description |
|---|---|
list[Event]
|
The opening events, in resolution order: one
|
Examples:
from osrlib.core.alignment import Alignment
from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character
from osrlib.core.rng import RngStreams
from osrlib.core.ruleset import Ruleset
from osrlib.core.tables import ReactionResult
from osrlib.crawl.adventure import Adventure, TownSpec
from osrlib.crawl.commands import EnterDungeon, SessionMode
from osrlib.crawl.dungeon import DungeonSpec, Edge, EdgeKind, LevelSpec
from osrlib.crawl.encounter import start_encounter
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"))
# Spawn the instances, then run the encounter procedure over them with a stance fixed in advance.
goblins = session.spawn("goblin", 2)
events = start_encounter(
session,
groups=[("goblin", goblins)],
kind="spawned",
distance_feet=30,
pinned_stance=ReactionResult.INDIFFERENT,
)
assert [type(event).__name__ for event in events] == [
"SurpriseRolledEvent",
"SurpriseRolledEvent",
"EncounterStartedEvent",
"StanceChangedEvent",
]
assert session.mode is SessionMode.ENCOUNTER
assert session.encounter.stance == "indifferent"
assert session.encounter.groups[0].monster_ids == ["monster-0001", "monster-0002"]