Skip to content

osrlib.crawl.session

The running game: GameSession, the one object a front end drives.

Build a session from a Party and an Adventure with GameSession.new, or restore one with load_game. From there the loop is the same every time: build a command from osrlib.crawl.commands, pass it to GameSession.execute, and render the CommandResult that comes back. A refused command comes back with its reasons and changed nothing. An accepted one comes back with the events it caused, which you turn into lines with format_message or with a renderer of your own. Draw your screens from GameSession.view rather than from the session's own attributes, and save the game with save_game.

The session keeps what the rules engine underneath leaves to its caller: the seeded random streams, the id allocator, the effects ledger, the clock, the registry of characters and live monsters, the flag store, the trigger marks, the journal, the quest states, the listeners and their state, the command and event logs, the session mode, and the dungeon state. That is why a save is one object and a replay from the same seed reaches the same game.

Which commands the session will accept depends on its SessionMode: town between delves, exploring on a dungeon grid, encounter when something has been met, battle once blows are struck, and the two endings, game_over and victory. A command that doesn't belong to the current mode is refused with session.command.wrong_mode, and each command class documents the modes it's legal in.

To extend the game without changing the engine, register a listener (see Listener) and use session flags. A listener sees each command's events and reacts by issuing ordinary commands, so everything it does is logged and replayed like anything else.

Typical usage:

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, MoveParty
from osrlib.crawl.dungeon import Direction, DungeonSpec, Edge, EdgeKind, LevelSpec
from osrlib.crawl.party import Party
from osrlib.crawl.session import GameSession
from osrlib.messages import format_message
from osrlib.persistence import load_game, save_game

rules = Ruleset()
stream = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM)
hero = create_character(
    name="Hild",
    class_id="fighter",
    alignment=Alignment.LAWFUL,
    ruleset=rules,
    stream=stream,
).character
corridor = 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=(corridor,))
adventure = Adventure(name="A First Delve", town=TownSpec(name="Threshold"), dungeons=(crypt,))

session = GameSession.new(Party(members=[hero]), adventure, seed=7)
session.execute(EnterDungeon(dungeon_id="crypt"))

result = session.execute(MoveParty(direction=Direction.EAST))
print([format_message(event) for event in result.events if event.visibility is Visibility.PLAYER])
# ['The party moves to (1, 0), facing east.']

view = session.view(Visibility.PLAYER)
print(view.mode, view.location.position)
# exploring (1, 0)

restored = load_game(save_game(session))
print(restored.view(Visibility.PLAYER) == view)
# True

ADJUDICATION_STREAM module-attribute

ADJUDICATION_STREAM = StreamName.ADJUDICATION

The name of the stream a referee's own dice roll draws from.

RollDice uses it. It's kept off the streams the rules use, so a roll for weather or a rumour never shifts a later attack or save.

DARKNESS_EFFECT_KINDS module-attribute

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

The effect kinds that put a party's light out while they run.

A darkness effect on any living member suppresses the party's light entirely, because the printed radius of the spell swallows a marching party. Some of them block infravision too.

ENCOUNTER_STREAM module-attribute

ENCOUNTER_STREAM = StreamName.ENCOUNTER

The name of the stream the encounter procedure draws from.

Covers surprise, encounter distance, reaction rolls, and the distraction check during a chase. See WANDERING_STREAM for how stream names are used.

EXPLORATION_STREAM module-attribute

EXPLORATION_STREAM = StreamName.EXPLORATION

The name of the stream the exploration procedures draw from.

Covers forcing doors, listening, searching, trap springs, lighting a tinder box, and thief skill checks. See WANDERING_STREAM for how stream names are used.

LIGHT_EFFECT_KINDS module-attribute

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

The effect kinds that count as the party carrying light: a torch or lantern, and the light spells.

GameSession.party_light tests an effect's kind against this set. Read it when you are writing content that attaches a light of its own and you want the engine to treat it as light.

MONSTER_ACTION_STREAM module-attribute

MONSTER_ACTION_STREAM = StreamName.MONSTER_ACTION

The name of the stream a monster action policy draws from.

It's kept apart from the combat stream so that changing how monsters choose their actions, or registering a policy of your own, never shifts the dice a fight would have rolled.

WANDERING_STREAM module-attribute

WANDERING_STREAM = StreamName.WANDERING

The name of the stream the wandering-monster procedure draws from.

Pass it to RngStreams.get on a session's streams to get the same generator the engine uses for the check die, the encounter-table roll, monster counts, and variant picks. Every draw in osrlib comes from a named stream so that adding a roll in one procedure cannot shift the dice another procedure would have drawn. You rarely need this yourself: the engine draws from it while it runs the cadence.

DeathRecord

Bases: BaseModel

When and how one character died, kept for the spells that care.

The session writes one per dead party member into GameSession.death_records, keyed by character id, as soon as the death happens. Revival reads it: neutralize poison has a window measured in rounds and needs to know whether poison was the killer, and raise dead counts the days since.

The record is frozen, and a member who dies again gets a new one.

round instance-attribute

round: int

Where the clock stood at the death, in rounds since the session began. Both revival windows are measured from here.

cause instance-attribute

cause: str

What did it: "poison" when the killing blow was a failed poison save or a poison running its course, otherwise the kind of the resolution that killed them, like "damage". Only the poison and non-poison distinction changes what the rules allow.

DefeatedMonsterRecord

Bases: BaseModel

One defeated creature, kept until the experience award is paid.

The encounter's conclusion appends one per defeated creature to GameSession.defeated_monsters, and GameSession.award_adventure_xp adds up their xp and clears the list. Under a ruleset that awards immediately, the list is cleared at each encounter's end instead.

Its fields are the same facts MonsterDefeatedEvent reports.

monster_id instance-attribute

monster_id: str

The session id of the creature that was defeated.

template_id instance-attribute

template_id: str

What it was: a monster catalog id, or "npc:<class id>" for an NPC adventurer.

outcome instance-attribute

outcome: str

How it went out: "slain" or "routed". Both count as defeated for the award.

xp instance-attribute

xp: int

What it's worth in experience.

DeprivationState

Bases: BaseModel

How long one member has gone without food and without water.

The session keeps one per member in GameSession.deprivation, and the day boundary updates it: a day with the supply resets that track to zero, a day without it adds one. Whether the count brings a penalty depends on the ruleset option deprivation_penalties, described in the adaptations register, the page that lists where osrlib settles an ambiguous rule or supplies a default.

food_days class-attribute instance-attribute

food_days: int = 0

Consecutive days this member has gone without food.

water_days class-attribute instance-attribute

water_days: int = 0

Consecutive days this member has gone without water.

worst property

worst: int

Return the worse of the two counts, which is the one the schedule reads.

The tracks don't stack: going without both food and water is as bad as going without the worse of them, not twice as bad.

Returns:

Type Description
int

The larger of food_days and water_days.

GameSession

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

A game in progress: the one object you execute commands against and read state from.

Make one with GameSession.new, or get one back from load_game or replay_game. Then run the loop: build a command, hand it to execute, render the result, and draw from view rather than from the attributes below, because a view is the projection that knows what a player may see. Extend the game with register_listener and session flags.

Everything that happened is on event_log and every accepted command on command_log, so save_game and load_game round-trip a session, and replaying the log from the same seed reaches the same game.

The attributes are public because a referee front end and the persistence layer read them, and they are documented for that reader. Writing to them yourself puts the session out of step with its own logs, and a replay won't match it.

Build a session from parts that are already in hand.

This constructor does no validation of the adventure's references and assigns no character ids. Call GameSession.new to start a game, or load_game to restore one. Both come through here.

Parameters:

Name Type Description Default
party Party

The party, in marching order, with ids already assigned.

required
adventure Adventure

The adventure content.

required
ruleset Ruleset

The ruleset in play.

required
streams RngStreams

The seeded random streams.

required
master_seed int

The seed those streams came from, kept so a save can rebuild them.

required

Raises:

Type Description
ContentValidationError

If the adventure bundles monster or item ids that collide with the shipped catalogs or with each other. This is the check that still runs for load_game, which trusts the rest of a saved adventure.

party instance-attribute

party = party

The party, in marching order. Order decides who is in the front rank in a fight, and ReorderParty is the only command that changes it.

adventure instance-attribute

adventure = adventure

The adventure being played: its town, dungeons, quests, and any content it bundles. It is frozen, and a save contains a copy of it, so a saved game needs no other file to load.

ruleset instance-attribute

ruleset = ruleset

The ruleset in play: the options that decide the readings osrlib leaves open, like when experience is awarded. See the adaptations register, the page that lists where osrlib settles an ambiguous rule or supplies a default.

streams instance-attribute

streams = streams

The session's named random streams. Everything the engine rolls comes from one of them, and a save exports their positions, which is what makes a restored game continue the same way.

master_seed instance-attribute

master_seed = master_seed

The seed the streams were built from. It's in the save and in no view, because knowing it would let a player predict every roll to come.

allocator instance-attribute

allocator = IdAllocator()

The source of session ids. Characters, monsters, effects, and generated caches are numbered from here as <kind>-NNNN, in order, never as random ids, so two runs of the same commands name things identically.

ledger instance-attribute

ledger = EffectsLedger()

The live effects: spells running, conditions, a torch burning down. The clock advances it, and its expiries and ticks arrive as kernel events.

clock instance-attribute

clock = GameClock()

The game clock. clock.rounds is how much time has passed since the session began, and the turn and day boundaries it crosses drive the rest, wandering, and provision cadences.

mode instance-attribute

Which SessionMode the session is in, and so which commands it will accept. A new session starts in town.

dungeon_state instance-attribute

dungeon_state = DungeonState()

Everything the play has written over the authored map: where the party is, which cells it has walked and seen, door state, found and sprung traps, drop piles, and generated caches. The authored dungeon itself never changes.

monsters instance-attribute

monsters: dict[str, MonsterInstance] = {}

The live monsters, keyed by session id. Spawning adds to it, and nothing removes a defeated monster, so a later event can still name what it was.

npcs instance-attribute

npcs: dict[str, Character] = {}

The live NPC adventurers, keyed by session id. They are characters rather than monsters, and they fight with the party's own rules.

flags instance-attribute

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

The session flag store: the game's own memory, written by SetFlag and read by an adventure's gates and triggers. Keys and meanings are yours to choose.

fired_triggers instance-attribute

fired_triggers: list[str] = []

The ids of the triggers that have fired, in the order they first fired. It answers "has this fired before". The log is where each firing is recorded.

journal instance-attribute

journal: list[JournalEntry] = []

The journal beats, in the order they were written. A player view contains the same list, which is where a front end should read it from.

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)
}

The live state of every quest the adventure authored, keyed by quest id, in the order it authored them. See QuestState.

listener_state instance-attribute

listener_state: dict[str, dict] = {}

Each registered listener's state, keyed by its key. It's saved and restored with the session, so a listener re-registered after a load picks up where it left off.

listeners instance-attribute

listeners: list[Listener] = []

The registered listeners, in the order they run. Listeners are code, so they aren't saved: register them again after a load.

command_log instance-attribute

command_log: list[Command] = []

Every accepted command, in order. Refused commands are absent, because they changed nothing. replay_game re-executes this list from the master seed to rebuild the session.

event_log instance-attribute

event_log: list[Event | dict] = []

Everything that has happened, in order. Entries are events. A session restored from a save may also contain a raw mapping for an event this version of the library has no class for, which it keeps rather than dropping.

death_records instance-attribute

death_records: dict[str, DeathRecord] = {}

When and how each dead party member died, keyed by character id. See DeathRecord.

defeated_monsters instance-attribute

defeated_monsters: list[DefeatedMonsterRecord] = []

The creatures defeated since the last award, which is what the experience award adds up. See DefeatedMonsterRecord.

deprivation instance-attribute

deprivation: dict[str, DeprivationState] = {}

Each member's food and water counts, keyed by character id. See DeprivationState.

treasure_snapshot_cp instance-attribute

treasure_snapshot_cp: int | None = None

What the party's treasure was worth, in copper pieces, when it left town, or None when no delve is under way. The award pays for the difference between this and what comes back.

odometer_thirds instance-attribute

odometer_thirds = 0

How much of the current turn the party's steps have used up, in thirds of its movement rate. A full turn's worth advances the clock and resets this.

turns_since_rest instance-attribute

turns_since_rest = 0

Turns since the party last rested, which is what the fatigue cadence counts. A rest resets it.

wandering_counter instance-attribute

wandering_counter = 0

Turns since the last wandering check. Reaching the level's interval fires the check and resets this.

noise_since_check instance-attribute

noise_since_check = False

Whether the party has made noise since the last wandering check, which any attempt to force a door does, whether or not the door opens. Noise raises the next check's chance by one and then clears. A failed attempt also alerts the area beyond the door, which is what denies the party surprise there.

sleep_count instance-attribute

sleep_count = 0

How many nights or days the party has slept through. Preparing spells needs a sleep the caster hasn't already prepared from.

last_prepared_sleep instance-attribute

last_prepared_sleep: dict[str, int] = {}

The sleep_count at which each caster last prepared spells, keyed by character id. It is what enforces one preparation per sleep.

alerted_areas instance-attribute

alerted_areas: list[str] = []

The keyed areas whose occupants have been alerted, as area references. Monsters that heard the party coming aren't surprised when it walks in.

heard_areas instance-attribute

heard_areas: list[str] = []

The keyed areas the party has heard something in, as area references. A party that knows what is behind the door isn't surprised by it.

encounter instance-attribute

encounter: EncounterState | None = None

The encounter under way, or None. It contains the groups, their distances, the stance, and any chase in progress.

battle instance-attribute

battle: BattleState | None = None

The battle under way, or None. It contains the round number and the per-battle trackers.

action_policies instance-attribute

action_policies: dict[str, object] = {}

Action policies for monster groups, keyed by encounter group id, for a game that wants to choose a group's actions itself. Without an entry, a group uses the built-in policy for its kind. Policies are code, so they aren't saved: register them again after a load.

metadata property

metadata: dict[str, object]

Return the versions a client needs to know it's talking to a compatible engine.

Send it in a handshake, or show it on a debug screen. A save contains the same two stamps, and replay_game refuses a log that a different engine version recorded.

Returns:

Type Description
dict[str, object]

A dict with schema_version, the serialized-format version that saves, commands, and

dict[str, object]

events share, and engine_version, the installed library's version.

effective_monsters property

effective_monsters: MonsterCatalog

Return the monster catalog this session resolves template ids against.

It's the shipped catalog plus whatever monsters the adventure bundles. Every part of the engine that turns a template id into a creature reads it: spawning, keyed encounters, wandering rows, listen checks. Use it when you want to look a template up the way the session does, rather than calling load_monsters and missing the adventure's own.

Returns:

Type Description
MonsterCatalog

The catalog. For an adventure that bundles nothing, it's the shipped catalog itself.

effective_equipment property

effective_equipment: EquipmentCatalog

Return the equipment catalog this session resolves item ids against.

It's the shipped catalog plus whatever items the adventure bundles, and every part of the engine that turns an item id into an item reads it: treasure caches, GrantItem, picking a drop pile back up. The town shop is the exception: it sells from the shipped equipment lists, so a bundled item is never on the shelf.

Returns:

Type Description
EquipmentCatalog

The catalog. For an adventure that bundles nothing, it's the shipped catalog itself.

new classmethod

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

Start a new game: validate the adventure, assign character ids, and open in town.

This is where a front end begins. Build characters with create_character, put them in a Party in marching order, load or build an Adventure, and call this. The session comes back in town, at round 0, ready for the first execute. To continue an existing game, use load_game instead.

The adventure is checked here rather than later, so a dangling monster id or a transition to a level that doesn't exist is an error at the start rather than a surprise mid-delve.

The same seed and the same commands produce the same game, which is what makes a bug reproducible and a replay possible. Use a fresh seed per game, and record it.

Parameters:

Name Type Description Default
party Party

The party, in marching order. Members that have no id get one here, as character-NNNN. Members that already have one, like a party loaded from an earlier session, keep it.

required
adventure Adventure

The adventure content to play.

required
seed int

The master seed every random draw in the session comes from.

required
ruleset Ruleset | None

The ruleset options in play. Defaults to a stock Ruleset.

None

Returns:

Type Description
GameSession

The session, in town, at round 0, with an empty command log.

Raises:

Type Description
ContentValidationError

If the adventure refers to something that doesn't exist, such as an unknown monster or item id or a transition with no destination.

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.dungeon import DungeonSpec, LevelSpec
from osrlib.crawl.party import Party
from osrlib.crawl.session import GameSession

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

session = GameSession.new(Party(members=[hero]), adventure, seed=7)
print(session.mode.value, session.clock.rounds, hero.id)
# town 0 character-0001

execute

execute(command: Command) -> CommandResult

Execute one command and return everything it caused.

This is the loop a front end runs: build a command from osrlib.crawl.commands, pass it here, check accepted, and render either the rejections or the events. Nothing else advances the game, and nothing else is logged, so a game built on this method can always be replayed.

Validation runs first and changes nothing: a refused command draws no dice, spends no game time, mutates no state, and stays out of the command log. Treat a rejection as the fiction saying no rather than as an error, and show it to the player in your own words from its code and fields.

An accepted command applies, and then its own bookkeeping runs before anything reaches the log: a party member's death is recorded with what killed them, and a command whose events left nobody standing ends the session in game_over with a GameOverEvent closing its result. A session that has already ended is left where it is.

The result contains the whole chain in log order: the handler's own events, then, for each registered listener in turn, the events of the commands that listener executed, however deeply nested, followed by the events it authored itself. So one result is enough to render the full reaction, and you don't have to read event_log to catch the rest.

Parameters:

Name Type Description Default
command Command

The command to execute.

required

Returns:

Type Description
CommandResult

The result envelope. A refused command contains rejections and no events. An accepted

CommandResult

one contains events and no rejections.

Raises:

Type Description
ValueError

If the command class has no handler, which means it was defined outside osrlib rather than built from osrlib.crawl.commands.

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

result = session.execute(EnterDungeon(dungeon_id="crypt"))
print(result.accepted, [event.code for event in result.events])
# True ['exploration.location.entered']

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

register_listener

register_listener(listener: Listener) -> None

Register a listener, which then runs after every accepted command.

Listeners run in the order they were registered, after the command's own handler. This is how a game adds behavior without changing the engine. See Listener for what one looks like and what it may do.

Register them again after load_game or replay_game: a listener is code and isn't saved, though its state is, and comes back under its key. A replay runs with no listeners registered, since the commands they issued are already in the log.

Parameters:

Name Type Description Default
listener Listener

The listener to register. Its state is snapshotted into saves under its key, so use a key that stays the same across releases of your game.

required

registry

registry() -> dict[str, Any]

Return every live entity in the session, keyed by id.

Party members come first in marching order, then monsters, then NPC adventurers. The engine hands this to the rules resolutions that need to look a target up by id. Use it when you are resolving something yourself. For anything you are drawing, read a view instead.

Returns:

Type Description
dict[str, Any]

A fresh dict from entity id to the live object: Character

dict[str, Any]

for members and NPCs, MonsterInstance for

dict[str, Any]

monsters. Editing the dict doesn't change the session. Editing the objects in it

dict[str, Any]

does.

combatant

combatant(combatant_id: str) -> MonsterInstance | Character | None

Return the monster or NPC adventurer with this id, or None.

An EncounterGroup holds ids that can be either, and this resolves both without you having to know which. For a party member, call member. For everything at once, call registry.

Parameters:

Name Type Description Default
combatant_id str

The entity id, as it appears on an encounter group or an event.

required

Returns:

Type Description
MonsterInstance | Character | None

The live MonsterInstance or

MonsterInstance | Character | None

Character, or None when no live entity has that

MonsterInstance | Character | None

id.

member

member(character_id: str) -> Character

Return the party member with this id.

The ids are the ones events use, so this is how you get from an event to the character it's about. It is Party.member with the session's own party filled in.

Parameters:

Name Type Description Default
character_id str

The member's session id, like "character-0001".

required

Returns:

Type Description
Character

The member, living or dead.

Raises:

Type Description
ValueError

If no member of the party has that id.

spawn

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

Spawn monsters into the session and return them.

Each instance rolls its own hit points from the seeded spawn stream and takes an id from the session allocator, and lands in monsters where the rest of the engine can find it. Spawning alone puts nothing in front of the party: the encounter procedure is what fields them. A referee wanting both at once should execute SpawnMonsters, which spawns and opens the encounter in one logged command.

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 to give them instead of the template's, for keyed content whose author wants, say, lawful goblins.

None

Returns:

Type Description
list[MonsterInstance]

The new instances, in spawn order.

Raises:

Type Description
ValueError

If the catalog has no such template id.

advance_rounds

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

Advance the clock by rounds and return what happened while it moved.

The commands advance time themselves, so you call this only when you are resolving something outside the command set. A referee moving the clock from a front end should execute AdvanceTime, which comes through here and is logged.

Time passing isn't nothing: effects tick and expire, a light burning out puts the party in the dark, and each day boundary crossed consumes rations and water. A light expiring is a referee-visibility record in the ledger, so the session adds the player-facing LightEvent beside it, naming what went out.

For whole turns with the exploration cadences (rest, wandering), call advance_turns instead. Rounds alone run no cadence.

Parameters:

Name Type Description Default
n int

How many rounds to advance.

required

Returns:

Type Description
list[Event]

The ledger's own events plus the light translations and any provisions events, in the

list[Event]

order they happened.

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.

This is the time path the exploration commands use, and the one to call when you are resolving elapsed time yourself. A clock standing part way through a turn snaps to the next turn boundary first, so an action that costs a turn absorbs the part-turn the party had already walked off.

Each turn: the ledger advances, a day boundary consumes provisions, the rest cadence counts unless the party is resting, and, in the field, the wandering cadence may fire a check. A check that produces an encounter stops the advance where it is, because the party now has something else to deal with, and the second return value says so.

A span in the field also stops the moment nobody is left standing, since the cadences belong to the living. Out of the field it keeps going, because 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 while the party is resting, which keeps the rest cadence from counting and lowers the wandering chance by one.

False
field bool | None

Whether the wandering cadence runs. Defaults to "the party is exploring a dungeon", which is the only place wandering monsters are rolled for. Town time and travel are abstract.

None

Returns:

Type Description
list[Event]

The events, and True when a wandering encounter interrupted the span before it ran

bool

out.

party_light

party_light() -> tuple[bool, bool]

Return whether the party has light, and whether infravision works.

Light gates most of exploration, so this is what a front end asks before it dims the screen or greys out a search button, and what the engine asks before it lets the party read, search, or see an encounter coming.

The party has light when any living member carries an active light-family effect. A darkness-family effect on any living member puts that out while it runs, because the printed radius of the spell swallows a marching party, and some darkness blocks infravision as well.

Returns:

Type Description
tuple[bool, bool]

A pair: whether the party is lit, and whether infravision is allowed.

bright_light

bright_light() -> bool

Return whether the party is carrying daylight-bright light.

The wandering-monster chance goes up for a party that can be seen coming. osrlib reads the flame of a torch or lantern as the baseline the printed chance already assumes, so only a light whose data says its brightness is daylight counts here, which in the shipped catalog means continual light. See the adaptations register, the page that lists where osrlib settles an ambiguous rule or supplies a default.

Returns:

Type Description
bool

True when a living member carries a light effect whose brightness is daylight.

member_has_infravision

member_has_infravision(member: Character) -> bool

Return whether one member can see in the dark.

Either the class has it, as the demi-human classes do, or a spell has granted it. The engine asks this when it decides whether a character can act in the dark and when it sets the party's surprise threshold.

Parameters:

Name Type Description Default
member Character

The member to test.

required

Returns:

Type Description
bool

True when that member has infravision.

party_valuation_cp

party_valuation_cp() -> int

Return what the party's treasure is worth right now, in copper pieces.

The award is measured in copper so that no rounding is lost on the way, and converted to gold once at the end. The session takes one of these when the party leaves town and another when it comes back, and the difference is the treasure experience.

Every member counts, the dead included, because treasure carried out on a body still came home. Magic items and mundane gear count nothing: magical treasure grants no experience, and selling off used gear is below the level of detail osrlib simulates.

Returns:

Type Description
int

The coins, in copper, plus every valuable's listed value, converted to copper.

snapshot_treasure

snapshot_treasure() -> None

Record what the party is worth as it leaves town, for the return award.

EnterDungeon calls it, so a front end doesn't have to. Call it yourself only when your game starts a delve some other way.

award_adventure_xp

award_adventure_xp() -> list[Event]

Pay the end-of-adventure experience award and return its events.

TravelToTown calls it under the default ruleset, where experience is awarded for making it back alive, so a front end doesn't call it itself.

The award is what the defeated creatures were worth plus what the treasure gained since the party left town is worth, one experience point per gold piece, never less than zero: a party that came home poorer learned nothing from it. The total divides evenly among the survivors, rounded down, and applies to each of them. The dead count toward the treasure that came home and take no share, and a party that lost everyone is awarded nothing, because nobody returned to tell it.

Whatever happens, the defeated-creature list is cleared and the departure snapshot reset, so the next delve starts from scratch.

Returns:

Type Description
list[Event]

The AdventureXpAwardEvent and then each

list[Event]

survivor's own award and level events, or nothing at all when there's no award to

list[Event]

make.

award_immediate_xp

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

Divide one pool of experience among the survivors now, and return its events.

This is the path a ruleset set to award immediately takes at the end of each encounter and on each haul taken. It divides the same way the return award does: evenly among the living, rounded down, remainder dropped. To award a specific character a specific amount, execute AwardXP instead, which is logged and replayed.

Parameters:

Name Type Description Default
amount int

The pool to divide. Zero or less awards nothing.

required

Returns:

Type Description
list[Event]

Each survivor's award event and, where one levelled, the level event, or nothing when

list[Event]

there's nobody alive or the share rounds to zero.

view

view(visibility: Visibility) -> PlayerView | RefereeView

Return a projection of the session at one visibility level.

Draw from a view rather than from the session's attributes. The player view is an enumerated whitelist of exactly what a player may be shown, so a front end built on it cannot leak the map it hasn't explored, the monster hit points, or the referee's rolls. The referee view contains the rest, for a referee screen, an LLM running the game, or a test, one typed field per group the save keeps: view.monsters[0].current_hp and view.flags["key"] read off it with the models this reference documents.

A networked game keeps the session and the referee view on the server and sends the client the player view, or the player-visibility events. Neither view contains the master seed, which lives only in the save.

Views are frozen and built fresh from the current state each time, never from the event log, so call this again after each command rather than holding one.

Parameters:

Name Type Description Default
visibility Visibility

PLAYER for the safe whitelist, REFEREE for everything but the random streams' internals.

required

Returns:

Type Description
PlayerView | RefereeView

A PlayerView or a

PlayerView | RefereeView

RefereeView, to match the level asked for.

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

player = session.view(Visibility.PLAYER)
referee = session.view(Visibility.REFEREE)
print(player.mode, player.party[0].name)
# exploring Hild

# The referee sees the session flags; the player whitelist has no such field.
print(referee.flags, "flags" in player.model_dump())
# {} False

# Neither view carries the master seed.
print("master_seed" in referee.model_dump())
# False

JournalEntry

Bases: BaseModel

One beat of the adventure's story, with the moment it landed.

The journal is the party's own record, and AddJournalEntry and the quest lifecycle commands are what write it. Read the whole journal from a player view, where it appears as a tuple of these in the order they were written.

Entries are appended and never rewritten, and each one is stamped as it is written, because that is the only moment the time can be captured: a front end renders "when" from the view alone, and a save whose event log was left out still says when every beat landed.

text class-attribute instance-attribute

text: str = Field(min_length=1)

The beat as it was written. It's content the game or the adventure supplied, never prose the engine wrote, and it's never empty.

rounds class-attribute instance-attribute

rounds: int = Field(ge=0)

Where the clock stood when the entry was appended, in rounds since the session began.

Listener

Bases: Protocol

The extension point: an object a game registers to react to what happens.

Write a class with a key and a handle method, and register an instance with GameSession.register_listener. After every accepted command, each listener is handed that command's events and its own state, in registration order. This is how a game adds behavior of its own (an authored trap that teleports, a curse that speaks up, a score) without touching the engine.

A listener never mutates game state directly. It reacts by executing ordinary commands on the session, which keeps everything it does inside the command log, so a replay from the seed produces the same game. Because those nested commands log their own events, a listener that reacts that way returns no events of its own: returning them too would put them in the log twice. The list it returns is for events it authors itself, which nothing else would have logged.

Every listener sees every event exactly once. A nested command runs the whole listener loop itself, so the events it produced reach each listener there and are not handed round again at the outer level.

Listener state is snapshotted into saves under key and handed back on the next call, so a listener needs no storage of its own. Listeners themselves are code and aren't saved, so register them again after load_game.

Examples:

from collections.abc import Sequence

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

rules = Ruleset()
stream = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM)
hero = create_character(
    name="Hild",
    class_id="fighter",
    alignment=Alignment.LAWFUL,
    ruleset=rules,
    stream=stream,
).character
corridor = 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=(corridor,))
adventure = Adventure(name="A First Delve", town=TownSpec(name="Threshold"), dungeons=(crypt,))

class StepCounter:
    key = "step_counter"

    def handle(self, events: Sequence[Event], state: dict) -> tuple[list[Event], dict]:
        steps = state.get("steps", 0)
        steps += sum(1 for event in events if event.code == "exploration.party.moved")
        return [], {"steps": steps}

session = GameSession.new(Party(members=[hero]), adventure, seed=7)
session.register_listener(StepCounter())
session.execute(EnterDungeon(dungeon_id="crypt"))
session.execute(MoveParty(direction=Direction.EAST))
print(session.listener_state["step_counter"])
# {'steps': 1}

key instance-attribute

key: str

The listener's name, unique within the session. Its state is saved and restored under this key, so keep it stable across releases of your game.

handle

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

React to one command's events.

Parameters:

Name Type Description Default
events Sequence[Event]

The command's events so far, in order, including the ones listeners registered before this one authored. Treat it as read-only.

required
state dict

This listener's state as it was left last time, and an empty dict on the first call. It must be JSON-serializable, because it goes into saves.

required

Returns:

Type Description
list[Event]

The events this listener authored itself, which the session appends to the result and

dict

the log, and the state to keep. Return an empty list when the listener reacted by

tuple[list[Event], dict]

executing commands: their events are already logged.

ObjectiveState

Bases: BaseModel

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

The session seeds one per authored objective and keeps them in QuestState.objectives. A player view shows the revealed objectives of active quests, and the referee view shows them all.

Both flags only ever go one way, from hidden to revealed and from incomplete to complete, because the quest vocabulary has no word for undoing either. Completing an objective reveals it too, so an objective the party finished before anyone announced it is something they can now be told about.

revealed instance-attribute

revealed: bool

Whether the party may be shown this objective. It starts true unless the adventure marked the objective hidden, and RevealObjective turns it on.

complete instance-attribute

complete: bool

Whether the objective is done. CompleteObjective turns it on, and turns revealed on with it.

QuestState

Bases: BaseModel

One quest's live state: where it stands, and where each of its objectives stands.

The session builds one per quest the adventure authored and keeps them in GameSession.quests, keyed by quest id. The four quest commands are their only writers, so a replay of the command log rebuilds them exactly.

A quest whose author wrote no activation clause starts active, because it's a standing charge and there's no command channel before the first command. The rest wait for ActivateQuest.

status instance-attribute

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

Where the quest stands. It runs inactive to active to completed and never goes back.

objectives instance-attribute

objectives: dict[str, ObjectiveState]

The state of each objective, keyed by objective id, in the order QuestSpec.objectives authored them, so a walk over them is the same every run.