Skip to content

osrlib.crawl.exploration

The exploration turn: movement, doors, searching, traps, light, rest, and wandering checks.

These handlers cover the commands of the two session modes outside combat: exploring, where the party walks a dungeon, and town, where it shops, rests, and buys healing. Two of them also serve encounter mode, because the party can hand out and use gear while monsters stand in front of it: DropItems and UseItem. You don't call a handler here yourself. You build one of the command models in osrlib.crawl.commands, like MoveParty, Search or Rest, and pass it to GameSession.execute. The session looks the command's class up in its own private handler table and runs the handler it finds, which is one function taking (session, command) and returning (rejections, events). You get back a CommandResult that contains either Rejection models saying why the command was refused, or the event models of osrlib.crawl.events saying what happened. Validation is a pure pre-phase, so a rejected command rolls no dice, changes no state, and costs no game time.

The rest of the public surface here is the per-turn bookkeeping that runs on the session's cadence rather than on a command: exploration_rate, check_fatigue, consume_provisions, wandering_interval and wandering_check. GameSession.advance_turns calls each of them at its own moment, so a front end that moves time by executing commands doesn't have to. Call them yourself to read the party's current numbers for a status display, or to run one of the checks when you drive the clock some other way.

Movement accrues on the session's odometer in thirds of a foot: stepping into an unexplored cell costs 30 units and stepping into an explored one costs 10, which is the SRD's rule that familiar ground moves three times as fast. When the accrued total reaches three times the party's exploration rate, the clock advances one full turn and the odometer resets. An action that costs a turn advances a whole turn outright and absorbs whatever partial move was pending.

Trap resolution draws on the exploration stream: the 2-in-6 spring check, the saving throws, the damage, and the volley counts. Durations rolled when a condition attaches draw on the effects stream instead, matching every other effect attachment in the engine.

Typical usage:

from osrlib.core.abilities import AbilityScore
from osrlib.core.alignment import Alignment
from osrlib.core.character import Character
from osrlib.crawl.adventure import Adventure, TownSpec
from osrlib.crawl.commands import EnterDungeon, MoveParty
from osrlib.crawl.dungeon import Direction, DungeonSpec, Edge, EdgeKind, LevelSpec
from osrlib.crawl.exploration import exploration_rate
from osrlib.crawl.party import Party
from osrlib.crawl.session import GameSession

hero = Character(
    name="Hild",
    class_id="fighter",
    race="human",
    level=1,
    xp=0,
    scores={ability: 12 for ability in AbilityScore},
    alignment=Alignment.LAWFUL,
    max_hp=8,
    current_hp=8,
)
level = LevelSpec(number=1, width=2, height=1, entrance=(0, 0), edges={"1,0:west": Edge(kind=EdgeKind.OPEN)})
adventure = Adventure(
    name="A First Delve",
    town=TownSpec(name="Threshold"),
    dungeons=(DungeonSpec(id="crypt", name="The Old Crypt", levels=(level,)),),
)
session = GameSession.new(Party(members=[hero]), adventure, seed=7)
session.execute(EnterDungeon(dungeon_id="crypt"))

moved = session.execute(MoveParty(direction=Direction.EAST))
assert [event.code for event in moved.events] == ["exploration.party.moved"]

blocked = session.execute(MoveParty(direction=Direction.EAST))  # the level is two cells wide
assert not blocked.accepted
assert blocked.rejections[0].code == "exploration.move.blocked"

print(exploration_rate(session))
# 120

DEPRIVATION_KIND module-attribute

DEPRIVATION_KIND = 'deprivation'

The effect kind under which the going-without penalty is attached.

Query it the same way as FATIGUE_KIND. Nothing attaches under this kind unless the deprivation_penalties ruleset flag is on, because the penalties for going hungry or thirsty are optional. The day counters that decide it are kept either way, in session.deprivation, by consume_provisions. The effect applies −1 to attack rolls, attaches on the first day a member goes without food or water, and is released on the day they are short of neither.

EXHAUSTED_DEFINITION module-attribute

EXHAUSTED_DEFINITION = EffectDefinition(
    kind=EXHAUSTED_KIND,
    condition=Condition.EXHAUSTED,
    stacking="ignore",
    modifiers=(
        ModifierSpec(kind="attack_bonus", value=-2),
        ModifierSpec(kind="damage_bonus", value=-2),
        ModifierSpec(kind="attack_penalty_of_attackers", value=2),
    ),
)

The blueprint for exhaustion: −2 to attack rolls, −2 to damage rolls, and −2 to armour class.

The encounter procedure attaches this to every living member when a pursuit runs its full length and the party gets away, and three turns of Rest release it. It is exported so that a front end or a replacement encounter procedure can attach the same exhaustion through EffectsLedger.attach instead of building a second definition, which would leave a member under two penalties at once. To ask whether a member is exhausted, query EXHAUSTED_KIND rather than this object.

The definition grants Condition.EXHAUSTED and stacks as ignore, so attaching it to an already exhausted member does nothing. It has no duration, so the ledger keeps it until something releases it: rest is what does that in play, and time alone won't. The −2 to armour class is modelled as an attack_penalty_of_attackers modifier of +2, so attackers of an exhausted creature get +2 to hit, which is exactly descending armour class worsened by 2.

EXHAUSTED_KIND module-attribute

EXHAUSTED_KIND = 'exhausted'

The effect kind under which the post-pursuit exhaustion penalty is attached.

Query it the same way as FATIGUE_KIND. A party that keeps running for the full length of a pursuit and gets away is exhausted when the chase ends, and three turns of Rest clear it. EXHAUSTED_DEFINITION is the blueprint that gets attached under this kind.

FATIGUE_KIND module-attribute

FATIGUE_KIND = 'fatigue'

The effect kind under which the unrested-fatigue penalty is attached.

Pass it with a member's id to EffectsLedger.active_on to ask whether that member is fatigued: session.ledger.active_on(member.id, FATIGUE_KIND) returns the live effects, so an empty list means rested. That query is how a status display reports the condition, and it is what check_fatigue itself checks before attaching anything. The effect applies −1 to attack rolls and −1 to damage rolls, lasts until something releases it, and a Rest command is what releases it in play.

HEALING_SERVICES module-attribute

HEALING_SERVICES: dict[HealingService, tuple[str, int]] = {
    "cure_light_wounds": ("cure_light_wounds", 25),
    "cure_serious_wounds": ("cure_serious_wounds", 100),
    "cure_disease": ("cure_disease", 150),
    "neutralize_poison": ("neutralize_poison", 150),
    "remove_curse": ("remove_curse_c", 200),
    "raise_dead": ("raise_dead", 1500),
}

The temple's healing services: each service name mapped to its spell id and its price in gp.

The keys are typed HealingService, the same closed set PurchaseHealing accepts in its service field, so read this to build a price list for a town screen and to check the party's coin against a price before you send the command. The spell id is the entry the purchase resolves through, which is why remove curse maps to remove_curse_c, the cleric list's version of that spell rather than the magic-user list's.

The service names and the prices are a documented adaptation over the SRD's open-ended base-town prose (see the adaptations register). Nothing here models availability. The size of the town, the standing of its temple, and whether a cleric is in today are game questions left to your front end. To charge your own prices, take the payment yourself and use the referee commands. Don't edit the prices here either: the purchase handler reads them at the moment of sale, and every session in the process shares them.

from osrlib.crawl.exploration import HEALING_SERVICES

print(HEALING_SERVICES["remove_curse"])
# ('remove_curse_c', 200)

check_fatigue

check_fatigue(session) -> list[Event]

Attach the unrested-fatigue penalty once the party has gone too long without a rest.

The SRD's dungeon rule is that a party rests one turn in every six, and a party that presses on takes −1 to attack rolls and −1 to damage rolls until it does. This is the check for that rule: it reads the session's turns_since_rest counter, and when the counter has reached the threshold it attaches the fatigue effect to every living member who isn't fatigued already.

GameSession.advance_turns calls this once for every turn of time the party spends in the field, so a front end that moves time by executing commands doesn't call it. Call it yourself when you drive the clock some other way. To ask whether the party is already fatigued, query FATIGUE_KIND against the ledger instead of calling this. A Rest command releases the effect and returns the counter to zero.

The threshold is six unrested turns, or three when the deprivation_penalties ruleset flag is on and some living member has gone a full day without food or water.

Parameters:

Name Type Description Default
session GameSession

The running session.

required

Returns:

Type Description
list[Event]

The attachment events, closed by a FatigueEvent with

list[Event]

code exploration.fatigue.gained, when at least one member gained the effect on this call.

list[Event]

The list is empty before the threshold, and empty when every living member is already

list[Event]

fatigued, so calling this again during the same unrested stretch attaches nothing. A member

list[Event]

who joins an already-tired party gains the effect on the next call.

Examples:

from osrlib.core.abilities import AbilityScore
from osrlib.core.alignment import Alignment
from osrlib.core.character import Character
from osrlib.crawl.adventure import Adventure, TownSpec
from osrlib.crawl.commands import EnterDungeon
from osrlib.crawl.dungeon import DungeonSpec, LevelSpec, WanderingSpec
from osrlib.crawl.exploration import FATIGUE_KIND, check_fatigue
from osrlib.crawl.party import Party
from osrlib.crawl.session import GameSession

hero = Character(
    name="Hild",
    class_id="fighter",
    race="human",
    level=1,
    xp=0,
    scores={ability: 12 for ability in AbilityScore},
    alignment=Alignment.LAWFUL,
    max_hp=8,
    current_hp=8,
)
# chance_in_six=0 keeps a wandering monster from interrupting the six turns.
level = LevelSpec(number=1, width=1, height=1, entrance=(0, 0), wandering=WanderingSpec(chance_in_six=0))
adventure = Adventure(
    name="A First Delve",
    town=TownSpec(name="Threshold"),
    dungeons=(DungeonSpec(id="crypt", name="The Old Crypt", levels=(level,)),),
)
session = GameSession.new(Party(members=[hero]), adventure, seed=7)
session.execute(EnterDungeon(dungeon_id="crypt"))

# advance_turns runs the check itself, so the fatigue event is already in its events.
events, interrupted = session.advance_turns(6)
assert not interrupted
assert "exploration.fatigue.gained" in [event.code for event in events]
assert session.ledger.active_on(hero.id, FATIGUE_KIND)

print(check_fatigue(session))
# []

consume_provisions

consume_provisions(session) -> list[Event]

Feed and water every living member for one day, and report who went short.

One call covers one day. Each living member eats a standard ration, falling back to an iron ration when they have no standard one, since fresh food spoils first. Each drinks from a carried waterskin, which isn't used up, so carrying one covers the day with no per-pint bookkeeping. In town nobody goes short: carried rations are still eaten, and a member with none is fed anyway.

GameSession.advance_rounds crosses each day boundary and calls this on its own, and every path that moves the clock goes through it, so a front end that moves time by executing commands doesn't call this. Call it yourself when you drive the clock some other way.

A day met resets that member's counter for that resource in session.deprivation, and a day missed raises it by one. The counters are kept whatever the ruleset says, so you can show a hunger indicator without turning the penalties on. Under the deprivation_penalties flag the counters also cost the member something, on a schedule drawn from the SRD's examples as a documented adaptation (see the adaptations register): −1 to attack rolls and fatigue twice as fast from one day, halved movement from two, and 1d4 damage a day from three. Food and water don't stack, so the worse of the two tracks is the one that applies.

Parameters:

Name Type Description Default
session GameSession

The running session.

required

Returns:

Type Description
list[Event]

One ProvisionsEvent per living member per resource,

list[Event]

food then water, coded exploration.provisions.consumed or exploration.provisions.short.

list[Event]

Under the flag, each member's pair is followed by the events of the deprivation schedule:

list[Event]

the effect attaching or releasing, and the damage from the third day on.

Examples:

from osrlib.core.abilities import AbilityScore
from osrlib.core.alignment import Alignment
from osrlib.core.character import Character
from osrlib.crawl.adventure import Adventure, TownSpec
from osrlib.crawl.commands import EnterDungeon
from osrlib.crawl.dungeon import DungeonSpec, LevelSpec
from osrlib.crawl.exploration import consume_provisions
from osrlib.crawl.party import Party
from osrlib.crawl.session import GameSession

hero = Character(
    name="Hild",
    class_id="fighter",
    race="human",
    level=1,
    xp=0,
    scores={ability: 12 for ability in AbilityScore},
    alignment=Alignment.LAWFUL,
    max_hp=8,
    current_hp=8,
)
level = LevelSpec(number=1, width=1, height=1, entrance=(0, 0))
adventure = Adventure(
    name="A First Delve",
    town=TownSpec(name="Threshold"),
    dungeons=(DungeonSpec(id="crypt", name="The Old Crypt", levels=(level,)),),
)
session = GameSession.new(Party(members=[hero]), adventure, seed=7)
session.execute(EnterDungeon(dungeon_id="crypt"))

events = consume_provisions(session)  # Hild carries no rations and no waterskin
print([(event.code, event.kind) for event in events])
# [('exploration.provisions.short', 'food'), ('exploration.provisions.short', 'water')]

assert session.deprivation[hero.id].worst == 1

exploration_rate

exploration_rate(session) -> int

Return the party's exploration rate in feet per turn.

A party moves at the pace of its slowest living member, so that member's movement rate is the party's. Read it to show a movement allowance, to work out how much ground a turn buys, or to find out whether the party can move at all. A rate of 0 means one of two things: a living member is carrying too much to move, or no member is living. While either holds, the session rejects a MoveParty with exploration.move.cannot_move and the reason overloaded, whichever of the two put the rate at 0.

For one character's own movement allowance rather than the party's, call Character.movement_rate, which is what this reads for each member. A front end showing a per-character rate wants that one.

You don't spend the rate yourself. The session charges each step against it on an odometer and advances the clock a turn when the odometer fills. The value changes as the party's load does, so read it again after anything that changes what the party carries.

Under the deprivation_penalties ruleset flag, a member two or more days into the worse of their food and water tracks moves at half rate, which slows the whole party.

Parameters:

Name Type Description Default
session GameSession

The running session.

required

Returns:

Type Description
int

The slowest living member's movement rate in feet per turn, or 0 when no member is living.

Examples:

from osrlib.core.abilities import AbilityScore
from osrlib.core.alignment import Alignment
from osrlib.core.character import Character
from osrlib.crawl.adventure import Adventure, TownSpec
from osrlib.crawl.commands import EnterDungeon
from osrlib.crawl.dungeon import DungeonSpec, LevelSpec
from osrlib.crawl.exploration import exploration_rate
from osrlib.crawl.party import Party
from osrlib.crawl.session import GameSession

hero = Character(
    name="Hild",
    class_id="fighter",
    race="human",
    level=1,
    xp=0,
    scores={ability: 12 for ability in AbilityScore},
    alignment=Alignment.LAWFUL,
    max_hp=8,
    current_hp=8,
)
level = LevelSpec(number=1, width=1, height=1, entrance=(0, 0))
adventure = Adventure(
    name="A First Delve",
    town=TownSpec(name="Threshold"),
    dungeons=(DungeonSpec(id="crypt", name="The Old Crypt", levels=(level,)),),
)
session = GameSession.new(Party(members=[hero]), adventure, seed=7)
session.execute(EnterDungeon(dungeon_id="crypt"))

print(exploration_rate(session))
# 120

wandering_check

wandering_check(session, *, resting: bool = False) -> tuple[list[Event], bool]

Roll one wandering-monster check, and on a hit spawn the monsters and open the encounter.

GameSession.advance_turns calls this every wandering_interval turns the party spends in the field, so a front end that moves time by executing commands doesn't call it. Call it yourself to drive the cadence from a clock of your own, or to make a check the rules don't schedule, like one a trigger in your adventure asks for.

The check changes the session whether or not it hits: it clears the session's noise flag, since that noise counted for this check and doesn't count again. On a hit it also spawns the monsters into the session registry, opens an encounter, and puts the session into encounter mode, at which point the exploring commands stop being legal and the encounter commands become so. The session's turn loop stops the rest of the span when that happens, and what to do next is yours to decide.

The chance starts from the level's number in six, adds 1 for noise since the last check, adds 1 for light as bright as daylight, and subtracts 1 while the party rests, clamped to the range 0 to 6. When the chance clamps to 0 there is no roll: the check reports a miss and draws nothing.

The check die draws from the WANDERING_STREAM, and so do the d20 table roll, the count dice and the picks among a row's variants. A row that names an NPC party generates a real party rather than rolling again, drawing its composition from the NPC_PARTY_STREAM. Hit points for spawned monsters draw from the MONSTER_SPAWN_STREAM, the treasure the spawn carries from the TREASURE_STREAM, and the encounter's surprise, distance, and reaction rolls from the ENCOUNTER_STREAM. Keeping them apart is what lets one part of a game change without moving another part's dice.

Parameters:

Name Type Description Default
session GameSession

The running session.

required
resting bool

True while the party is resting, which applies the −1 to the chance.

False

Returns:

Type Description
list[Event]

The check's events, and True when an encounter opened. The events open with one

bool

WanderingCheckEvent giving the chance and the

tuple[list[Event], bool]

roll, with roll set to None when the chance was 0. A hit adds the spawn events and the

tuple[list[Event], bool]

encounter's opening events after it.

Examples:

from osrlib.core.abilities import AbilityScore
from osrlib.core.alignment import Alignment
from osrlib.core.character import Character
from osrlib.crawl.adventure import Adventure, TownSpec
from osrlib.crawl.commands import EnterDungeon
from osrlib.crawl.dungeon import DungeonSpec, LevelSpec
from osrlib.crawl.exploration import wandering_check
from osrlib.crawl.party import Party
from osrlib.crawl.session import GameSession

hero = Character(
    name="Hild",
    class_id="fighter",
    race="human",
    level=1,
    xp=0,
    scores={ability: 12 for ability in AbilityScore},
    alignment=Alignment.LAWFUL,
    max_hp=8,
    current_hp=8,
)
level = LevelSpec(number=1, width=1, height=1, entrance=(0, 0))
adventure = Adventure(
    name="A First Delve",
    town=TownSpec(name="Threshold"),
    dungeons=(DungeonSpec(id="crypt", name="The Old Crypt", levels=(level,)),),
)
session = GameSession.new(Party(members=[hero]), adventure, seed=7)
session.execute(EnterDungeon(dungeon_id="crypt"))

events, encountered = wandering_check(session)
check = events[0]
print((check.chance, check.roll, encountered))  # 1 in 6, rolled a 4, nothing came
# (1, 4, False)

wandering_interval

wandering_interval(session) -> int

Return how many turns pass between wandering-monster checks on the level the party is on.

Each dungeon level has its own interval, two turns by the book. The session counts turns against this number and, when the count reaches it, runs wandering_check and starts counting again. Read it to tell a player how much dungeon time is left before the next check, or to drive the same cadence from a clock of your own.

Parameters:

Name Type Description Default
session GameSession

The running session.

required

Returns:

Type Description
int

The interval in turns for the level the party is on. Outside a dungeon the return is

int

1000000000, a number the turn counter never reaches, because town time and overland travel

int

run no wandering cadence.

Examples:

from osrlib.core.abilities import AbilityScore
from osrlib.core.alignment import Alignment
from osrlib.core.character import Character
from osrlib.crawl.adventure import Adventure, TownSpec
from osrlib.crawl.commands import EnterDungeon
from osrlib.crawl.dungeon import DungeonSpec, LevelSpec
from osrlib.crawl.exploration import wandering_interval
from osrlib.crawl.party import Party
from osrlib.crawl.session import GameSession

hero = Character(
    name="Hild",
    class_id="fighter",
    race="human",
    level=1,
    xp=0,
    scores={ability: 12 for ability in AbilityScore},
    alignment=Alignment.LAWFUL,
    max_hp=8,
    current_hp=8,
)
level = LevelSpec(number=1, width=1, height=1, entrance=(0, 0))
adventure = Adventure(
    name="A First Delve",
    town=TownSpec(name="Threshold"),
    dungeons=(DungeonSpec(id="crypt", name="The Old Crypt", levels=(level,)),),
)
session = GameSession.new(Party(members=[hero]), adventure, seed=7)

print(wandering_interval(session))  # the session starts in town
# 1000000000

session.execute(EnterDungeon(dungeon_id="crypt"))
print(wandering_interval(session))
# 2