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
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
Stream key for encounter-procedure draws: surprise, distance, reaction, distraction.
EXPLORATION_STREAM
module-attribute
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
Stream key for the action policy's draws — a policy change never shifts combat draws.
WANDERING_STREAM
module-attribute
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.
DefeatedMonsterRecord
DeprivationState
Bases: BaseModel
One member's food and water deprivation counters (worse track applies).
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 |
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)
}
metadata
property
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 |
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
Live entities by id: party members (marching order), then monsters, then NPCs.
combatant
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 |
member
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
|
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 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 |
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
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
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
Record the departure valuation — EnterDungeon's bookkeeping.
award_adventure_xp
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
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
|
|
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.
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.
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.
QuestState
Bases: BaseModel
One quest's live state: its status, and the state of each of its objectives.
status runs inactive → active → completed 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.