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.
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.
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
|
party
instance-attribute
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
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
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
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
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
mode = SessionMode.TOWN
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
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
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
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
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
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
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
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
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 the party last rested, which is what the fatigue cadence counts. A rest resets it.
wandering_counter
instance-attribute
Turns since the last wandering check. Reaching the level's interval fires the check and resets this.
noise_since_check
instance-attribute
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
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
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
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
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 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
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 |
dict[str, object]
|
events share, and |
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
|
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
|
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
|
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
|
required |
registry
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: |
dict[str, Any]
|
for members and NPCs, |
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 | Character | None
|
|
MonsterInstance | Character | None
|
id. |
member
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 |
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
|
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 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
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
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
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
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 |
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
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
|
|
required |
Returns:
| Type | Description |
|---|---|
PlayerView | RefereeView
|
A |
PlayerView | RefereeView
|
|
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
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.
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
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.