Skip to content

osrlib.crawl.session

GameSession: the command loop, the event log, listeners, flags, and views.

The session is the front end's one object. Build it with GameSession.new (or restore one with load_game), feed player intent to GameSession.execute as typed commands from osrlib.crawl.commands, and render the Events that come back. The session owns what the kernel leaves to its caller: the RngStreams (master seed), the IdAllocator, the EffectsLedger, the GameClock, the entity registry (characters and live monster instances), the flag store, the trigger fired-marks, the journal, the quest state, the listener-state store, the command and event logs, the mode, and the crawl state.

execute(command) runs a pure validation pre-phase: a rejected command consumes no RNG draws, no clock time, mutates nothing, and is excluded from the command log. Accepted commands mutate, append their events to the log, then registered listeners run in registration order, their events appended to the same result and log. Each command class documents its legal modes, rejection codes, and events.

For presentation, GameSession.view projects the state at player or referee visibility — render from views and events, never from raw session internals.

ADJUDICATION_STREAM module-attribute

ADJUDICATION_STREAM = 'adjudication'

Stream key for the referee's ad-hoc adjudication rolls — kept off the mechanical streams so a freeform roll never shifts a keyed mechanic's draw sequence.

DARKNESS_EFFECT_KINDS module-attribute

DARKNESS_EFFECT_KINDS = frozenset({'darkness', 'continual_darkness'})

The darkness-family effect kinds — the printed radii swallow a marching party.

ENCOUNTER_STREAM module-attribute

ENCOUNTER_STREAM = 'encounter'

Stream key for encounter-procedure draws: surprise, distance, reaction, distraction.

EXPLORATION_STREAM module-attribute

EXPLORATION_STREAM = 'exploration'

Stream key for exploration draws: forcing, listening, searching, traps, tinder, skills.

LIGHT_EFFECT_KINDS module-attribute

LIGHT_EFFECT_KINDS = frozenset({'light', 'continual_light'})

The light-family effect kinds: torch/lantern attachments and the light spells.

MONSTER_ACTION_STREAM module-attribute

MONSTER_ACTION_STREAM = 'monster_action'

Stream key for the action policy's draws — a policy change never shifts combat draws.

WANDERING_STREAM module-attribute

WANDERING_STREAM = 'wandering'

Stream key for wandering-monster draws: the check die, the d20, counts, variant picks.

DeathRecord

Bases: BaseModel

When and how a character died — the honest inputs for revival windows.

cause is "poison" when the killing resolution was a poison save or a poison-delay expiry (feeding neutralize poison's round window), else the source kind; raise dead's day count reads round regardless of cause.

round instance-attribute

round: int

cause instance-attribute

cause: str

DefeatedMonsterRecord

Bases: BaseModel

One defeated monster — the XP award's input.

monster_id instance-attribute

monster_id: str

template_id instance-attribute

template_id: str

outcome instance-attribute

outcome: str

xp instance-attribute

xp: int

DeprivationState

Bases: BaseModel

One member's food and water deprivation counters (worse track applies).

food_days class-attribute instance-attribute

food_days: int = 0

water_days class-attribute instance-attribute

water_days: int = 0

worst property

worst: int

The worse track — the two deprivation tracks don't stack.

GameSession

GameSession(*, party: Party, adventure: Adventure, ruleset: Ruleset, streams: RngStreams, master_seed: int)

A running game: the single entry point for command execution and views.

The loop: execute one command at a time and render its CommandResult; read state through view (player or referee visibility) rather than session attributes; extend the game with register_listener and session flags. Everything that happened is on event_log, every accepted command on command_log, and save_game/load_game round-trip the whole session deterministically: same seed, same commands, same game.

The trigger fired-marks (fired_triggers, in first-fired order), the journal, and the quest block (quests) are engine-owned session state beside the flag store: the lifecycle commands MarkTriggerFired, AddJournalEntry, and the four quest commands are their only writers, so a replay — which runs with no listeners registered — rebuilds all three by re-executing the command log.

Internal constructor — use GameSession.new or load_game.

Raises:

Type Description
ContentValidationError

If the adventure bundles monster or item ids that collide with the shipped catalogs or each other — the typed backstop for load_game, which trusts saved content otherwise.

party instance-attribute

party = party

adventure instance-attribute

adventure = adventure

ruleset instance-attribute

ruleset = ruleset

streams instance-attribute

streams = streams

master_seed instance-attribute

master_seed = master_seed

allocator instance-attribute

allocator = IdAllocator()

ledger instance-attribute

ledger = EffectsLedger()

clock instance-attribute

clock = GameClock()

mode instance-attribute

dungeon_state instance-attribute

dungeon_state = DungeonState()

monsters instance-attribute

monsters: dict[str, MonsterInstance] = {}

npcs instance-attribute

npcs: dict[str, Character] = {}

flags instance-attribute

flags: dict[str, str | int | bool] = {}

fired_triggers instance-attribute

fired_triggers: list[str] = []

journal instance-attribute

journal: list[JournalEntry] = []

quests instance-attribute

quests: dict[str, QuestState] = {
    (quest.id): (
        QuestState(
            status="active" if quest.activation is None else "inactive",
            objectives={
                (objective.id): (ObjectiveState(revealed=not objective.hidden, complete=False))
                for objective in (quest.objectives)
            },
        )
    )
    for quest in (adventure.quests)
}

listener_state instance-attribute

listener_state: dict[str, dict] = {}

listeners instance-attribute

listeners: list[Listener] = []

command_log instance-attribute

command_log: list[Command] = []

event_log instance-attribute

event_log: list[Event | dict] = []

death_records instance-attribute

death_records: dict[str, DeathRecord] = {}

defeated_monsters instance-attribute

defeated_monsters: list[DefeatedMonsterRecord] = []

deprivation instance-attribute

deprivation: dict[str, DeprivationState] = {}

treasure_snapshot_cp instance-attribute

treasure_snapshot_cp: int | None = None

odometer_thirds instance-attribute

odometer_thirds = 0

turns_since_rest instance-attribute

turns_since_rest = 0

wandering_counter instance-attribute

wandering_counter = 0

noise_since_check instance-attribute

noise_since_check = False

sleep_count instance-attribute

sleep_count = 0

last_prepared_sleep instance-attribute

last_prepared_sleep: dict[str, int] = {}

alerted_areas instance-attribute

alerted_areas: list[str] = []

heard_areas instance-attribute

heard_areas: list[str] = []

encounter instance-attribute

encounter: EncounterState | None = None

battle instance-attribute

battle: BattleState | None = None

action_policies instance-attribute

action_policies: dict[str, object] = {}

metadata property

metadata: dict[str, object]

The front-end handshake: the schema and engine versions.

effective_monsters property

effective_monsters: MonsterCatalog

The session's monster catalog: the shipped catalog plus the adventure's bundled templates.

Every engine site that resolves a template id — spawning, keyed encounters, wandering rows, listen checks — resolves against this catalog. For an adventure that bundles nothing it is the shipped catalog (load_monsters's cached object).

effective_equipment property

effective_equipment: EquipmentCatalog

The session's equipment catalog: the shipped catalog plus the adventure's bundled templates.

Every engine site that resolves an authored item id — treasure caches, GrantItem, drop-pile recovery — resolves against this catalog. For an adventure that bundles nothing it is the shipped catalog (load_equipment's cached object). The town shop is the exception: it stocks the shipped equipment lists, so a bundled item is never on sale.

new classmethod

new(party: Party, adventure: Adventure, *, seed: int, ruleset: Ruleset | None = None) -> GameSession

Create a new session, validating the adventure and assigning member ids.

Character ids assign as character-NNNN from the session's allocator in party order. Members that already carry ids keep them (a party loaded from an earlier session).

Parameters:

Name Type Description Default
party Party

The party, in marching order.

required
adventure Adventure

The frozen adventure content.

required
seed int

The master seed.

required
ruleset Ruleset | None

The ruleset in play; defaults to a stock Ruleset().

None

Returns:

Type Description
GameSession

The session, in town, at round 0.

Raises:

Type Description
ContentValidationError

If the adventure has dangling references.

execute

execute(command: Command) -> CommandResult

Execute one command: the pure validation pre-phase, then apply and log.

An accepted command's own bookkeeping runs before its events reach the log and the listeners: party deaths are recorded with their cause, and a command whose events killed the last living member ends the session in game_over with a GameOverEvent closing its result — whatever killed the party, and from whichever mode. A session already in a terminal mode is left alone.

The result carries everything the command caused, in event-log order: the handler's own events, then, per listener in registration order, the events of the commands that listener executed — however deeply nested — followed by the events it authored itself.

Parameters:

Name Type Description Default
command Command

The command to execute.

required

Returns:

Type Description
CommandResult

The result envelope; rejected commands carry rejections and no 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.commands import EnterDungeon
from osrlib.crawl.dungeon import DungeonSpec, LevelSpec
from osrlib.crawl.party import Party
from osrlib.crawl.session import GameSession

rules = Ruleset()
rng = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM)
hero = create_character(
    name="Hild",
    class_id="fighter",
    alignment=Alignment.LAWFUL,
    ruleset=rules,
    stream=rng,
)
level = LevelSpec(number=1, width=1, height=1, entrance=(0, 0))
crypt = DungeonSpec(id="crypt", name="The Old Crypt", levels=(level,))
town = TownSpec(name="Threshold")
adventure = Adventure(name="A First Delve", town=town, dungeons=(crypt,))
session = GameSession.new(Party(members=[hero.character]), adventure, seed=7)

result = session.execute(EnterDungeon(dungeon_id="crypt"))
assert result.accepted and result.events

again = session.execute(EnterDungeon(dungeon_id="crypt"))  # already inside
assert not again.accepted
assert again.rejections[0].code == "session.command.wrong_mode"

register_listener

register_listener(listener: Listener) -> None

Register a listener; it runs after each command in registration order.

Parameters:

Name Type Description Default
listener Listener

The listener; its state snapshots into saves under its key.

required

registry

registry() -> dict[str, Any]

Live entities by id: party members (marching order), then monsters, then NPCs.

combatant

combatant(combatant_id: str) -> object | None

Return the monster or NPC with combatant_id, or None.

The encounter side's lookup: an EncounterGroup's combatant ids span monsters and NPC adventurers, and this resolves both.

Parameters:

Name Type Description Default
combatant_id str

The combatant's entity id.

required

Returns:

Type Description
object | None

The live instance, or None when the id is unknown.

member

member(character_id: str) -> Character

Return the party member with character_id (see Party.member).

spawn

spawn(template_id: str, count: int, *, alignment: Alignment | None = None) -> list[MonsterInstance]

Spawn count instances into the registry, ids from the session allocator.

Parameters:

Name Type Description Default
template_id str

Any id in the session's effective_monsters catalog — shipped (see the monster id index) or bundled by the adventure.

required
count int

How many to spawn.

required
alignment Alignment | None

An alignment override from keyed content.

None

Returns:

Type Description
list[MonsterInstance]

The spawned instances, in spawn order.

advance_rounds

advance_rounds(n: int) -> list[Event]

Advance the clock n rounds through the ledger, translating light expiries.

A light-kind expiry is referee visibility; the session appends the player-facing exploration.light.expired with the source kind. Day-boundary crossings consume provisions.

Parameters:

Name Type Description Default
n int

How many rounds.

required

Returns:

Type Description
list[Event]

The ledger's events plus the player-facing translations.

advance_turns

advance_turns(turns: int, *, resting: bool = False, field: bool | None = None) -> tuple[list[Event], bool]

Advance whole turns one at a time, running the per-turn bookkeeping.

A mid-turn clock snaps to the next turn boundary first — turn-costing actions absorb partial round-time. Each turn: the ledger advances, day boundaries consume provisions, the rest cadence counts (unless resting), and — in the field — the wandering cadence may fire a check that starts an encounter, which stops the advance.

A field span also stops the moment it leaves nobody standing: the cadences belong to the living, so a rest that starves the party out ends at the turn it happened rather than running its remaining hours. The key is field, not the mode — everything a non-field span does is ledger bookkeeping, and a revival window measured in elapsed time has to keep elapsing while the party lies dead.

Parameters:

Name Type Description Default
turns int

How many turns to advance.

required
resting bool

True during a Rest (the cadence doesn't count, and the wandering chance takes the resting −1).

False
field bool | None

Whether the wandering cadence runs; defaults to "exploring in a dungeon" (town time and travel are abstract, no wandering there).

None

Returns:

Type Description
tuple[list[Event], bool]

The events, and True when a wandering encounter interrupted the span.

party_light

party_light() -> tuple[bool, bool]

Return (lit, infravision_allowed) for the party as a whole.

Lit means any living member carries an active light-family effect — unless a darkness-family effect on any member suppresses the party's light while it runs (the printed radii swallow a marching party). Darkness with blocks_infravision disables infravision too.

Returns:

Type Description
tuple[bool, bool]

The pair of party-level light facts.

bright_light

bright_light() -> bool

Whether the party carries daylight-bright light (continual light's data).

RAW modifies the wandering chance for "bright light sources"; osrlib adopts the reading that the torch/lantern flame is the baseline the printed 1-in-6 already assumes, so only brightness == "daylight" counts.

member_has_infravision

member_has_infravision(member: Character) -> bool

Whether one member sees in the dark: the class tag or a spell effect.

party_valuation_cp

party_valuation_cp() -> int

The party's treasure valuation in copper pieces — the award's exact unit.

All members count, including the dead (their carried treasure that made it back is the party's recovery): coin value in cp plus every valuable's value_gp. Magic items and mundane equipment count zero — magical treasure grants no XP per RAW, and mundane-gear salvage sits below the simulation floor by design.

snapshot_treasure

snapshot_treasure() -> None

Record the departure valuation — EnterDungeon's bookkeeping.

award_adventure_xp

award_adventure_xp() -> list[Event]

The end-of-adventure award: defeated monsters plus the valuation delta.

The treasure XP is the delta between the party's valuation now and the departure snapshot — floored to gp once from the cp total, never negative (clamped at zero: a party that lost money learned nothing monetarily). The total divides evenly among living members (floor division, remainder dropped — RAW divides evenly and B/X arithmetic is integer) and applies through apply_xp directly (a command whose handler executed further commands would double-log; AwardXP remains the referee and game surface). Dead members' recovered treasure counts toward the pool; dead members receive no share. A TPK never awards — no one returned. The defeated-monsters ledger clears and the next departure snapshots anew.

award_immediate_xp

award_immediate_xp(amount: int) -> list[Event]

The immediate timing's division: apply one award pool now.

Same division and events as the return award: evenly among living members, floor division, remainder dropped.

view

view(visibility: Visibility) -> PlayerView | RefereeView

Return the projection for a visibility level.

The player view is an enumerated whitelist safe to show at the table; the referee view is the full state minus RNG internals. Neither carries the master seed — it lives only in the save.

Parameters:

Name Type Description Default
visibility Visibility

PLAYER for the safe whitelist, REFEREE for everything but RNG internals.

required

Returns:

Type Description
PlayerView | RefereeView

The frozen view.

Examples:

from osrlib.core.alignment import Alignment
from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character
from osrlib.core.events import Visibility
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
from osrlib.crawl.dungeon import DungeonSpec, LevelSpec
from osrlib.crawl.party import Party
from osrlib.crawl.session import GameSession

rules = Ruleset()
rng = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM)
hero = create_character(
    name="Hild",
    class_id="fighter",
    alignment=Alignment.LAWFUL,
    ruleset=rules,
    stream=rng,
)
level = LevelSpec(number=1, width=1, height=1, entrance=(0, 0))
crypt = DungeonSpec(id="crypt", name="The Old Crypt", levels=(level,))
town = TownSpec(name="Threshold")
adventure = Adventure(name="A First Delve", town=town, dungeons=(crypt,))
session = GameSession.new(Party(members=[hero.character]), adventure, seed=7)
session.execute(EnterDungeon(dungeon_id="crypt"))

player = session.view(Visibility.PLAYER)
referee = session.view(Visibility.REFEREE)
assert player.mode == "exploring"
# The referee sees session flags; the player whitelist has no such field.
assert "flags" in referee.state
assert "flags" not in player.model_dump()
# Neither view leaks the master seed — it lives only in the save.
assert "master_seed" not in referee.state

JournalEntry

Bases: BaseModel

One journal beat: the authored text and the clock position it landed at.

The journal is append-only — entries are never rewritten and never derived from other state — and each entry carries its own rounds stamp because the moment of appending is the only moment it can be captured: a front end renders "when" from the view alone, and a save compacted of its event log still knows when every beat landed.

text class-attribute instance-attribute

text: str = Field(min_length=1)

rounds class-attribute instance-attribute

rounds: int = Field(ge=0)

Listener

Bases: Protocol

The extension-point protocol: games register listeners on the session.

Listeners never mutate game state — they react by executing ordinary commands. handle receives the accumulated events of the command (the events earlier listeners authored included) and the listener's own state snapshot, and returns the events to append plus the new state (snapshotted into saves under key).

A listener that reacts by executing commands returns no events: those commands logged their own, and the result envelope picks them up from the log. Returning them again would log them twice. The returned list is for events a listener authors directly.

Every listener sees every event exactly once. A nested command runs the whole listener loop itself, so the events it produced reach each listener through that nested dispatch and are never dispatched again at the outer level — only the caller's result envelope gathers them up a second time.

key instance-attribute

key: str

handle

handle(events: Sequence[Event], state: dict) -> tuple[list[Event], dict]

React to one command's events.

ObjectiveState

Bases: BaseModel

One objective's live state: whether the party can see it, and whether it is done.

Both flags are monotonic — hidden becomes revealed and incomplete becomes complete, never the other way — because the quest vocabulary authors no repeat. Completing an objective also reveals it: an objective the party finished before anyone announced it is a thing they can now be told about.

revealed instance-attribute

revealed: bool

complete instance-attribute

complete: bool

QuestState

Bases: BaseModel

One quest's live state: its status, and the state of each of its objectives.

status runs inactiveactivecompleted and never backwards. A quest with no authored activation is seeded active at session construction — it is a standing charge, and there is no command channel before the first command — while the rest wait for ActivateQuest.

objectives is keyed by objective id in the order QuestSpec.objectives authored them, so every walk over the block is deterministic.

status instance-attribute

status: Literal['inactive', 'active', 'completed']

objectives instance-attribute

objectives: dict[str, ObjectiveState]