diff --git a/src/osrlib/crawl/events.py b/src/osrlib/crawl/events.py
index a4fe229..3b1ab11 100644
--- a/src/osrlib/crawl/events.py
+++ b/src/osrlib/crawl/events.py
@@ -1,16 +1,74 @@
-"""The crawl event types, the combined registry, and the any-event parser.
-
-Crawl events subclass the core [`Event`][osrlib.core.events.Event] base, inheriting
-the emission contract (frozen, `extra="ignore"`, dotted snake_case codes, declared
-outcome-bearing code sets, visibility). `CRAWL_EVENT_CLASSES` joins the kernel tuple
-in [`ALL_EVENT_CLASSES`][osrlib.crawl.events.ALL_EVENT_CLASSES] and the
-[`AnyEvent`][osrlib.crawl.events.AnyEvent] union;
-[`parse_any_event`][osrlib.crawl.events.parse_any_event] covers both and the session
-log uses it.
-
-Visibility follows B/X's hidden-roll doctrine: referee-rolled dice (detection,
-surprise, reaction, wandering checks) are referee events, and the player-facing
-events carry behavior and outcomes only — a silent listen is genuinely ambiguous.
+"""The crawl event catalog: typed records of everything a session does.
+
+Every command you run through
+[`GameSession.execute`][osrlib.crawl.session.GameSession.execute] comes back with a
+[`CommandResult`][osrlib.crawl.commands.CommandResult] whose `events` tuple contains
+instances of the classes here and of the kernel classes in
+[`osrlib.core.events`][osrlib.core.events]. Read them in order, drop the ones your
+reader may not see, and turn each one into a line with
+[`format_message`][osrlib.messages.format_message] or with a renderer of your own
+keyed on the event's `code`. The same objects accumulate on `GameSession.event_log`,
+and [`save_game`][osrlib.persistence.save_game] writes them into a save.
+
+Every event has a `code`, an `event_type`, and a `visibility`. The code is a message
+code, dot-separated snake_case namespaced by subsystem (`exploration.door.opened`),
+and it's what a renderer keys on. The event type is the wire discriminator that names
+the class, so a serialized event rebuilds into the right one. The visibility says who
+may see the event: `player` for what the table learns, `referee` for the rolls and
+bookkeeping B/X keeps behind the screen, like a detection die or a wandering check. A
+class that can report more than one outcome declares its whole code set in
+`allowed_codes`, and an instance uses one of them.
+
+An event never contains English prose written by the engine. It contains facts and a
+code, so a front end can localize, and a narrator can write its own line from the
+same facts. The exception is a `narrative` field: that is text the adventure's author
+wrote, passed through as content.
+
+[`CRAWL_EVENT_CLASSES`][osrlib.crawl.events.CRAWL_EVENT_CLASSES] is the registry of
+the classes in this module, and
+[`ALL_EVENT_CLASSES`][osrlib.crawl.events.ALL_EVENT_CLASSES] adds the kernel ones in
+front of it. [`AnyEvent`][osrlib.crawl.events.AnyEvent] is the union of all of them
+for typing and JSON Schema, and
+[`parse_any_event`][osrlib.crawl.events.parse_any_event] turns a serialized record
+back into an event.
+
+Typical usage:
+
+```python
+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
+
+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([event.code for event in result.events])
+# ['exploration.party.moved']
+table = [format_message(event) for event in result.events if event.visibility is Visibility.PLAYER]
+print(table)
+# ['The party moves to (1, 0), facing east.']
+```
"""
from collections.abc import Mapping
@@ -85,49 +143,100 @@
class PartyMovedEvent(Event):
- """The party moved or turned; `x`/`y`/`facing` are the resulting pose.
+ """The party moved a cell or turned in place, and here is where it now stands.
- A blocked move is a rejection (`exploration.move.blocked`), never an event:
- moving into a wall is an in-fiction invalid command, not a game state change.
+ Emitted by [`MoveParty`][osrlib.crawl.commands.MoveParty] with the cell it
+ stepped into, and by [`TurnParty`][osrlib.crawl.commands.TurnParty] with the
+ unchanged cell and the new facing. It's what a first-person front end redraws
+ from.
+
+ A move that a wall, a closed door, or the edge of the map stops is a rejection
+ (`exploration.move.blocked`) rather than an event: walking into a wall changes
+ nothing about the game, so nothing is reported.
"""
allowed_codes: ClassVar[frozenset[str]] = frozenset({"exploration.party.moved", "exploration.party.turned"})
+ """`exploration.party.moved` for a step into a new cell, `exploration.party.turned` for a
+ turn on the spot."""
event_type: Literal["party_moved"] = "party_moved"
+ """The wire discriminator, `party_moved`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: where the party stands is the party's own business."""
x: int
+ """The column the party is in after the command, counting from zero at the level's west edge."""
y: int
+ """The row the party is in after the command, counting from zero at the level's north edge."""
facing: str
+ """The direction the party now faces, as the lowercase value of a
+ [`Direction`][osrlib.crawl.dungeon.Direction] (`"north"`, `"east"`, `"south"`, `"west"`). A
+ move faces the way it went, so this changes on a step as well as on a turn."""
class LocationEnteredEvent(Event):
- """The party crossed a location boundary.
-
- `location_kind` is `area`, `level`, `dungeon`, or `town`; `location_id` is the
- area or dungeon id (`"town"` for town). `level_number` rides level and dungeon
- entries, and `dungeon_id` rides area entries — an area id is scoped to its
- level, so an area entry needs all three to name where the party is, while level
- and dungeon entries carry the dungeon id in `location_id` and town has neither.
+ """The party crossed into a new area, level, dungeon, or town.
+
+ Emitted whenever the party's location changes at one of those four scales:
+ [`EnterDungeon`][osrlib.crawl.commands.EnterDungeon] on arrival at a dungeon,
+ [`UseStairs`][osrlib.crawl.commands.UseStairs] on a level or dungeon change,
+ [`MoveParty`][osrlib.crawl.commands.MoveParty] on stepping into a keyed area,
+ [`TravelToTown`][osrlib.crawl.commands.TravelToTown] on arriving back in town,
+ and [`PlaceParty`][osrlib.crawl.commands.PlaceParty] when a referee puts the
+ party somewhere.
+
+ Which fields are filled depends on the scale, because an area id is unique only
+ within its level: an area entry names the area, its level number, and its dungeon,
+ a level or dungeon entry names the dungeon in `location_id` with the level number
+ beside it, and a town entry names neither. Use it to swap the screen's header, and
+ read the text the party can see from
+ [`GameSession.view`][osrlib.crawl.session.GameSession.view].
"""
allowed_codes: ClassVar[frozenset[str]] = frozenset({"exploration.location.entered"})
+ """The only message code this event uses."""
event_type: Literal["location_entered"] = "location_entered"
+ """The wire discriminator, `location_entered`."""
code: str = "exploration.location.entered"
+ """The message code, always `exploration.location.entered`. The scale is in `location_kind`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: arriving somewhere is the first thing the table is told."""
location_kind: str
+ """Which scale was crossed: `"area"`, `"level"`, `"dungeon"`, or `"town"`."""
location_id: str
+ """What was entered: the area id for an area entry, the dungeon id for a level or dungeon
+ entry, and `"town"` for the town."""
level_number: int | None = None
+ """The level the party is on, for area, level, and dungeon entries, and `None` for town."""
dungeon_id: str | None = None
+ """The dungeon the area belongs to, filled on area entries only. The other kinds already name
+ the dungeon in `location_id`."""
narrative: str | None = None
- """The authored success text of the gate on the transition that was taken, when the
- author wrote one. Authored text on an event is content data in a structured field,
- not engine-baked English: the event still carries its message code and its facts,
- and the default formatter appends this line verbatim after the templated one."""
+ """The success text the adventure's author wrote on the gate that was crossed, when there was
+ one, else `None`. A gate is the condition an author puts on a transition, like a door that
+ opens only for a key. This is content rather than prose the engine wrote: the event still has
+ its code and its facts, and [`format_message`][osrlib.messages.format_message] appends this line
+ after the templated one."""
class DoorEvent(Event):
- """A door changed state; the edge is named by its cell and direction."""
+ """A door changed state, named by the cell it borders and the side it sits on.
+
+ Emitted by the door commands,
+ [`OpenDoor`][osrlib.crawl.commands.OpenDoor],
+ [`CloseDoor`][osrlib.crawl.commands.CloseDoor],
+ [`ForceDoor`][osrlib.crawl.commands.ForceDoor],
+ [`PickLock`][osrlib.crawl.commands.PickLock], and
+ [`WedgeDoor`][osrlib.crawl.commands.WedgeDoor], and by the commands that leave a
+ cell or a level, because doors the party opened swing shut behind it. A referee's
+ [`SetDoorState`][osrlib.crawl.commands.SetDoorState] emits it too, at referee
+ visibility, since a door set open from behind the screen isn't something the
+ party watched happen.
+
+ A door belongs to the edge between two cells, so the same door can be named from
+ either side. Redraw from `x`, `y`, and `direction` rather than tracking door
+ identity yourself.
+ """
allowed_codes: ClassVar[frozenset[str]] = frozenset(
{
@@ -140,172 +249,338 @@ class DoorEvent(Event):
"exploration.door.unlocked",
}
)
+ """`exploration.door.opened` and `.closed` for the plain cases, `.forced` for a door shouldered
+ open and `.stuck` for the attempt that failed, `.unlocked` for a lock picked, `.wedged` for a
+ door spiked in place, and `.swung_shut` for a door the party opened closing behind it."""
event_type: Literal["door"] = "door"
+ """The wire discriminator, `door`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility by default. The referee's `SetDoorState` overrides it to referee."""
x: int
+ """The column of the cell the door edge is named from."""
y: int
+ """The row of the cell the door edge is named from."""
direction: str
+ """Which side of that cell the door is on, as a lowercase
+ [`Direction`][osrlib.crawl.dungeon.Direction] value."""
character_id: str | None = None
+ """The member who acted, for a force, a stuck attempt, or a picked lock, and `None` when the party
+ acted as one or when nobody did, as with a door swinging shut."""
narrative: str | None = None
- """The authored success text of the door's gate, when the opening satisfied one.
- Authored text on an event is content data in a structured field, not engine-baked
- English: the event still carries its message code and its facts, and the default
- formatter appends this line verbatim after the templated one."""
+ """The success text the author wrote on the door's gate, when opening it satisfied one, else
+ `None`. Content rather than engine prose: the event still has its code and its facts, and the
+ default formatter appends this line after the templated one."""
class ListenedEvent(Event):
- """What the listener heard — heard-something or silence, genuinely ambiguous.
+ """Someone listened at a door, and either heard something or heard nothing.
+
+ Emitted by [`ListenAtDoor`][osrlib.crawl.commands.ListenAtDoor], after the
+ referee-visibility [`DetectionRolledEvent`][osrlib.crawl.events.DetectionRolledEvent]
+ that reports the die.
- Undead make no noise, so the referee-side roll (which rides
- [`DetectionRolledEvent`][osrlib.crawl.events.DetectionRolledEvent]) happens
- whether or not anything is there; silence never says which.
+ Silence is ambiguous, and it's meant to stay that way. Undead make no
+ noise, and the roll happens whether or not anything is on the other side, so
+ `exploration.listen.silent` tells the party nothing about what is there. Render
+ it as an empty result, not as an all-clear.
"""
allowed_codes: ClassVar[frozenset[str]] = frozenset({"exploration.listen.heard", "exploration.listen.silent"})
+ """`exploration.listen.heard` when noise came through, `exploration.listen.silent` when
+ none did."""
event_type: Literal["listened"] = "listened"
+ """The wire discriminator, `listened`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: what a character heard is theirs to know, while the die behind it is
+ not."""
character_id: str
+ """The member who listened."""
direction: str
+ """The side of the party's cell that was listened at, as a lowercase
+ [`Direction`][osrlib.crawl.dungeon.Direction] value."""
class DetectionRolledEvent(Event):
- """A referee-rolled detection die: search, listen, and trap-spring checks.
-
- Rolled whether or not anything is there (the no-leak convention); `roll` is
- `None` when a zero chance consumed no die.
+ """A referee-rolled detection die: a search, a listen, a lock, or a trap trigger.
+
+ Emitted alongside the player-facing result of
+ [`Search`][osrlib.crawl.commands.Search],
+ [`ListenAtDoor`][osrlib.crawl.commands.ListenAtDoor],
+ [`PickLock`][osrlib.crawl.commands.PickLock],
+ [`InspectTreasure`][osrlib.crawl.commands.InspectTreasure], and
+ [`RemoveTreasureTrap`][osrlib.crawl.commands.RemoveTreasureTrap], and whenever a
+ trap gets its chance to spring.
+
+ The die is rolled whether or not there's anything to find, so that a failure and
+ an empty cell look the same from the table. That is why this event is referee
+ visibility: showing it to players would leak the answer the roll was hiding. A
+ referee front end, or an LLM running the game, reads it to know what actually
+ happened.
"""
allowed_codes: ClassVar[frozenset[str]] = frozenset({"exploration.detection.rolled"})
+ """The only message code this event uses."""
event_type: Literal["detection_rolled"] = "detection_rolled"
+ """The wire discriminator, `detection_rolled`."""
code: str = "exploration.detection.rolled"
+ """The message code, always `exploration.detection.rolled`."""
visibility: Visibility = Visibility.REFEREE
+ """Referee visibility: the roll is the part B/X keeps behind the screen."""
character_id: str | None = None
+ """The member who rolled, or `None` for a check nobody made, like a trap's own chance to
+ go off."""
kind: str
+ """What was being checked: `"listening"`, one of the search kinds
+ (`"secret_doors"`, `"room_traps"`, `"construction"`), `"open_locks"`, `"treasure_traps"`, or
+ `"trap_spring"` for a trap's chance to fire."""
chance: int
+ """The number the roll had to come in at or under. The listening, search, and trap-spring
+ kinds are X-in-6 chances rolled on a d6. The thief skills `open_locks` and `treasure_traps`
+ are percentages rolled on d100."""
roll: int | None = None
+ """What came up, or `None` when the chance was zero and no die was rolled, as for a character
+ with no chance at all of noticing construction tricks."""
passed: bool
+ """Whether the check succeeded. A failed check and a nothing-there cell are deliberately
+ indistinguishable from the player's side."""
class SearchCompletedEvent(Event):
- """A search finished: what it revealed, or nothing (which is ambiguous)."""
+ """A search of the party's cell finished, naming whatever it turned up.
+
+ Emitted by [`Search`][osrlib.crawl.commands.Search] and by
+ [`InspectTreasure`][osrlib.crawl.commands.InspectTreasure] once the roll has been
+ made, after the referee-visibility
+ [`DetectionRolledEvent`][osrlib.crawl.events.DetectionRolledEvent].
+
+ An empty result means the searcher found nothing, which isn't the same as there
+ being nothing: each character gets one attempt per cell per kind, and another
+ character may still find it.
+ """
allowed_codes: ClassVar[frozenset[str]] = frozenset({"exploration.search.found", "exploration.search.nothing"})
+ """`exploration.search.found` when `found` is non-empty, `exploration.search.nothing`
+ otherwise."""
event_type: Literal["search_completed"] = "search_completed"
+ """The wire discriminator, `search_completed`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: what the search turned up is the party's to act on."""
character_id: str
+ """The member who searched."""
kind: str
+ """What was searched for: `"secret_doors"`, `"room_traps"`, `"construction"`, or
+ `"treasure_traps"` for a treasure feature inspected by a thief."""
found: tuple[str, ...] = ()
+ """What turned up, as references like `"secret_door:north"`, `"room_trap:"`, or
+ `"construction:"`, and empty when nothing did. A found secret door becomes
+ passable, and a found trap no longer springs on the party."""
class TrapEvent(Event):
- """A trap outcome the party perceives.
-
- `.sprung` when a trap goes off, `.found` when a search or inspection reveals
- one, `.removed` on a successful removal, `.safe` when a *known* trap's trigger
- resolved without springing — never emitted for unknown traps (the spring die
- rides the referee-visibility
- [`DetectionRolledEvent`][osrlib.crawl.events.DetectionRolledEvent], no-leak).
+ """A trap did something the party can perceive: it fired, or was found, or was dealt with.
+
+ Emitted by the commands that can set a trap off or look for one:
+ [`MoveParty`][osrlib.crawl.commands.MoveParty],
+ [`OpenDoor`][osrlib.crawl.commands.OpenDoor],
+ [`Search`][osrlib.crawl.commands.Search],
+ [`TakeTreasure`][osrlib.crawl.commands.TakeTreasure],
+ [`InspectTreasure`][osrlib.crawl.commands.InspectTreasure], and
+ [`RemoveTreasureTrap`][osrlib.crawl.commands.RemoveTreasureTrap]. A trap that
+ fires resolves at once, so its damage and saves follow in the same result as
+ kernel events.
+
+ A trap the party doesn't know about that fails to fire produces no event here.
+ Only its die goes into the referee-visibility
+ [`DetectionRolledEvent`][osrlib.crawl.events.DetectionRolledEvent], so an
+ uneventful step looks like a step on safe ground.
"""
allowed_codes: ClassVar[frozenset[str]] = frozenset(
{"exploration.trap.sprung", "exploration.trap.safe", "exploration.trap.found", "exploration.trap.removed"}
)
+ """`exploration.trap.sprung` when a trap goes off, `.found` when a search or inspection
+ reveals one, `.removed` when a thief disarms one, and `.safe` when a trap the party already
+ knows about got its chance and didn't fire."""
event_type: Literal["trap"] = "trap"
+ """The wire discriminator, `trap`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: the party feels the trap go off, or sees the one it found."""
trap_ref: str
+ """Which trap this is, as `"::"`. The session
+ records the same reference as found, sprung, or removed, so a trap is reported once and
+ stays dealt with."""
character_id: str | None = None
+ """The member who set it off, found it, or removed it, or `None` when the trap fired on the
+ party as a whole."""
class ItemAcquiredEvent(Event):
- """Items or coins entered a character's inventory."""
+ """Items or coins landed in a character's inventory.
+
+ Emitted by [`TakeTreasure`][osrlib.crawl.commands.TakeTreasure] once per carrier
+ who took a share, by
+ [`PurchaseEquipment`][osrlib.crawl.commands.PurchaseEquipment] in town, and by
+ the referee's [`GrantItem`][osrlib.crawl.commands.GrantItem] and
+ [`GrantCoins`][osrlib.crawl.commands.GrantCoins].
+
+ It reports what changed hands, not what the character now carries. Read the
+ inventory itself from [`GameSession.view`][osrlib.crawl.session.GameSession.view]
+ when you need the full sheet.
+ """
allowed_codes: ClassVar[frozenset[str]] = frozenset({"exploration.item.acquired"})
+ """The only message code this event uses."""
event_type: Literal["item_acquired"] = "item_acquired"
+ """The wire discriminator, `item_acquired`."""
code: str = "exploration.item.acquired"
+ """The message code, always `exploration.item.acquired`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: the party knows what it picked up."""
character_id: str
+ """The member whose pack the goods went into."""
item_ids: tuple[str, ...] = ()
+ """What was acquired, one entry per item: a catalog id for mundane gear, repeated when several
+ of the same thing arrived, and a session-scoped instance id for a valuable or a magic item, so
+ an unidentified item's true nature stays hidden."""
coins_gp_value: int = 0
+ """The coins acquired, converted to their value in gold pieces, and zero when only items
+ changed hands."""
class ItemConsumedEvent(Event):
- """One carried item was used up — a gate's toll paid, a spike driven home.
+ """One carried item was used up: a toll paid, a spike driven home.
+
+ Emitted when a gate's condition takes the item it names, which happens on
+ [`OpenDoor`][osrlib.crawl.commands.OpenDoor],
+ [`ForceDoor`][osrlib.crawl.commands.ForceDoor], and
+ [`UseStairs`][osrlib.crawl.commands.UseStairs], and by
+ [`WedgeDoor`][osrlib.crawl.commands.WedgeDoor] for the iron spike it drives.
- `item_id` follows the acquisition masking rule: a mundane consumption carries
- the catalog id, a magic one the instance's session-scoped `instance_id`, never
- its `template_id` — an unidentified item's true identity never rides a
- player-visible event.
+ It says the item is gone. It isn't the event for a potion drunk or a scroll
+ read, which are [`ItemUsedEvent`][osrlib.crawl.events.ItemUsedEvent].
"""
allowed_codes: ClassVar[frozenset[str]] = frozenset({"exploration.item.consumed"})
+ """The only message code this event uses."""
event_type: Literal["item_consumed"] = "item_consumed"
+ """The wire discriminator, `item_consumed`."""
code: str = "exploration.item.consumed"
+ """The message code, always `exploration.item.consumed`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: the party sees what it spent."""
character_id: str
+ """The member whose pack the item came out of."""
item_id: str
+ """What was consumed: the catalog id for mundane gear, and the session-scoped instance id for
+ a magic item, never its template id, so an unidentified item's identity never reaches a
+ player-visible event."""
class ItemsDroppedEvent(Event):
- """Items or coins dropped onto the party's cell (or the pursuit trail)."""
+ """Items or coins were dropped, onto the party's cell or behind it as bait.
+
+ Emitted by [`DropItems`][osrlib.crawl.commands.DropItems], and by
+ [`Evade`][osrlib.crawl.commands.Evade] when the party throws treasure or food to
+ a pursuer to buy time.
+
+ What lands on a cell goes into that cell's drop pile, which
+ [`TakeTreasure`][osrlib.crawl.commands.TakeTreasure] can pick back up. What is
+ scattered during a flight is gone.
+ """
allowed_codes: ClassVar[frozenset[str]] = frozenset({"exploration.item.dropped"})
+ """The only message code this event uses."""
event_type: Literal["items_dropped"] = "items_dropped"
+ """The wire discriminator, `items_dropped`."""
code: str = "exploration.item.dropped"
+ """The message code, always `exploration.item.dropped`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: the party knows what it let go of."""
character_id: str
+ """The member who dropped them."""
item_ids: tuple[str, ...] = ()
+ """What was dropped, in the same id form the acquisition used: catalog ids for mundane gear,
+ instance ids for valuables and magic items."""
coins_gp_value: int = 0
+ """The coins dropped, as their value in gold pieces."""
class ItemsLeftBehindEvent(Event):
- """Treasure the party could not carry, left where it lay.
+ """Treasure the party could not carry, left lying where it was found.
- Emitted by [`TakeTreasure`][osrlib.crawl.commands.TakeTreasure] when a haul
- exceeds the carriers' remaining maximum load: the remainder lands in the drop
- pile on the party's cell, so nothing is destroyed and a lightened party can come
- back for it.
+ Emitted by [`TakeTreasure`][osrlib.crawl.commands.TakeTreasure] when the haul is
+ heavier than the carriers' remaining capacity.
+
+ Nothing is destroyed. The remainder goes into the drop pile on the party's cell,
+ so a party that comes back lighter can take another `TakeTreasure` and get the
+ rest.
"""
allowed_codes: ClassVar[frozenset[str]] = frozenset({"exploration.item.left_behind"})
+ """The only message code this event uses."""
event_type: Literal["items_left_behind"] = "items_left_behind"
+ """The wire discriminator, `items_left_behind`."""
code: str = "exploration.item.left_behind"
+ """The message code, always `exploration.item.left_behind`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: the party can see the pile it's walking away from."""
item_ids: tuple[str, ...] = ()
+ """What stayed behind, as catalog ids for mundane gear and instance ids for valuables and
+ magic items."""
coins_gp_value: int = 0
+ """The coins left behind, as their value in gold pieces."""
class ItemsGivenEvent(Event):
- """Items or coins handed from one party member to another.
+ """Items or coins passed from one party member to another.
- `character_id` is the giver, `recipient_id` the companion who took the goods.
+ Emitted by [`GiveItems`][osrlib.crawl.commands.GiveItems]. Update both characters
+ on your inventory screen when it arrives: nothing enters or leaves the party, so
+ the party's total is unchanged.
"""
allowed_codes: ClassVar[frozenset[str]] = frozenset({"exploration.item.given"})
+ """The only message code this event uses."""
event_type: Literal["items_given"] = "items_given"
+ """The wire discriminator, `items_given`."""
code: str = "exploration.item.given"
+ """The message code, always `exploration.item.given`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: the party arranged the handover."""
character_id: str
+ """The member who handed the goods over."""
recipient_id: str
+ """The member who took them."""
item_ids: tuple[str, ...] = ()
+ """What was handed over, as catalog ids for mundane gear and instance ids for valuables and
+ magic items."""
coins_gp_value: int = 0
+ """The coins handed over, as their value in gold pieces."""
class LightEvent(Event):
- """A light source changed state.
-
- `source` is the item or effect kind (`torch`, `lantern`, `light`); `.failed`
- is a failed tinder-box attempt; `.expired` is the session's player-facing
- translation of the ledger's referee-visibility expiry.
+ """A light source was lit, went out, failed to catch, or burned away.
+
+ Emitted by [`LightSource`][osrlib.crawl.commands.LightSource],
+ [`ExtinguishSource`][osrlib.crawl.commands.ExtinguishSource], and
+ [`UseItem`][osrlib.crawl.commands.UseItem] for an item that glows. The session
+ also emits the expiry form whenever the clock runs a light out, which can happen
+ inside any command that passes time.
+
+ Light gates most of exploration: searching, reading, and seeing at all need it
+ unless a character has infravision. Read the party's current state from
+ [`GameSession.party_light`][osrlib.crawl.session.GameSession.party_light] rather
+ than adding these events up.
"""
allowed_codes: ClassVar[frozenset[str]] = frozenset(
@@ -316,116 +591,262 @@ class LightEvent(Event):
"exploration.light.expired",
}
)
+ """`exploration.light.lit` when a source catches, `.extinguished` when it's put out on
+ purpose, `.failed` when a tinder box doesn't catch, and `.expired` when a burning source runs
+ out on the clock."""
event_type: Literal["light"] = "light"
+ """The wire discriminator, `light`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: the party watches the light go. The ledger's own expiry record behind it
+ is referee visibility, and the session translates it into this player-facing form."""
character_id: str | None = None
+ """The member carrying the source, or `None` when the light belongs to no one in the party."""
source: str
+ """What is burning: `"torch"`, `"lantern"`, `"oil"` for a lit pool, `"sword"` for a blade that
+ glows, or the effect kind for a light cast as a spell."""
class RestedEvent(Event):
- """A rest completed or was interrupted; `kind` is `turn`, `night`, or `day`."""
+ """A rest finished, or was interrupted before it could.
+
+ Emitted by [`Rest`][osrlib.crawl.commands.Rest]. A completed rest clears the
+ unrested-fatigue penalty, credits running exhaustion, and, for a full day, heals
+ naturally. An interrupted one does none of that, because something wandered in.
+ """
allowed_codes: ClassVar[frozenset[str]] = frozenset({"exploration.rest.rested", "exploration.rest.interrupted"})
+ """`exploration.rest.rested` when the rest ran to its end, `exploration.rest.interrupted` when
+ a wandering encounter cut it short."""
event_type: Literal["rested"] = "rested"
+ """The wire discriminator, `rested`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: the party knows whether it got its rest."""
kind: str
+ """How long the party tried to rest: `"turn"` for the one-turn breather the dungeon rule
+ calls for, `"night"`, or `"day"`."""
class FatigueEvent(Event):
- """The party gained or recovered from the unrested-fatigue penalty."""
+ """The party picked up the unrested penalty, or shook it off.
+
+ B/X asks a party to rest one turn in every six while it's in a dungeon. A party
+ that doesn't gets a penalty until it does, and these two codes are when the
+ penalty lands and when it lifts.
+
+ Emitted while exploring, by any command that crosses a turn boundary, and
+ recovered by [`Rest`][osrlib.crawl.commands.Rest].
+ """
allowed_codes: ClassVar[frozenset[str]] = frozenset({"exploration.fatigue.gained", "exploration.fatigue.recovered"})
+ """`exploration.fatigue.gained` when the party misses its rest,
+ `exploration.fatigue.recovered` when a rest clears it."""
event_type: Literal["fatigue"] = "fatigue"
+ """The wire discriminator, `fatigue`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: the party feels it, and the penalty is on their sheets."""
class ProvisionsEvent(Event):
- """A day-boundary provision outcome: consumed, or short (food or water)."""
+ """A day passed, and a character either ate and drank or went without.
+
+ Emitted once per living member per kind whenever the clock crosses a day
+ boundary, which can happen inside any command that passes time, and most often
+ inside a [`Rest`][osrlib.crawl.commands.Rest].
+
+ Going short starts a deprivation count on that member. Whether that count brings
+ a penalty depends on the ruleset option `deprivation_penalties`, described in
+ [the adaptations register](https://mmacy.github.io/osrlib-python/adaptations/),
+ the page that lists where osrlib settles an ambiguous rule or supplies a default.
+ In town nobody ever runs short.
+ """
allowed_codes: ClassVar[frozenset[str]] = frozenset(
{"exploration.provisions.consumed", "exploration.provisions.short"}
)
+ """`exploration.provisions.consumed` when the day's food or water was there,
+ `exploration.provisions.short` when it was not."""
event_type: Literal["provisions"] = "provisions"
+ """The wire discriminator, `provisions`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: an empty pack is the party's problem to solve."""
character_id: str
+ """The member whose rations or water this was."""
kind: str
+ """Which supply the event is about: `"food"` or `"water"`. Each member gets one of each per
+ day."""
class WanderingCheckEvent(Event):
- """A wandering-monster check fired (referee bookkeeping).
+ """The wandering-monster cadence came round and the referee rolled for it.
+
+ Emitted while the party is in a dungeon, by any command that crosses the turn
+ the cadence lands on, most often [`MoveParty`][osrlib.crawl.commands.MoveParty]
+ or [`Rest`][osrlib.crawl.commands.Rest]. When the check hits, the encounter
+ opens in the same result and the command that was spending time stops there.
- `roll` is `None` when the clamped chance was 0 and the roll was skipped.
+ It's referee visibility because the party has no way of knowing a check was
+ made, only of meeting what it produced.
"""
allowed_codes: ClassVar[frozenset[str]] = frozenset({"exploration.wandering.checked"})
+ """The only message code this event uses."""
event_type: Literal["wandering_check"] = "wandering_check"
+ """The wire discriminator, `wandering_check`."""
code: str = "exploration.wandering.checked"
+ """The message code, always `exploration.wandering.checked`."""
visibility: Visibility = Visibility.REFEREE
+ """Referee visibility: the check is made behind the screen, and only its result walks in."""
chance: int
+ """The X-in-6 chance the check needed, after the level's own rate and any adjustment like
+ the lower chance while resting."""
roll: int | None = None
+ """The d6 that was rolled, or `None` when the chance came out at zero and no die was
+ rolled."""
encounter: bool
+ """Whether the check produced an encounter. When it did, the encounter's own events follow in
+ the same result."""
class EncounterStartedEvent(Event):
- """An encounter opened: visible monster names and counts only.
-
- The surprise *rolls* are referee events; the outcomes ride here — being
- surprised is felt in the fiction.
+ """The party has met something, and here is what it sees.
+
+ Emitted when an encounter opens, whichever way it did: walking into a keyed area
+ with [`MoveParty`][osrlib.crawl.commands.MoveParty],
+ [`EnterDungeon`][osrlib.crawl.commands.EnterDungeon] or
+ [`UseStairs`][osrlib.crawl.commands.UseStairs] arriving on one, a wandering
+ check, or a referee's [`SpawnMonsters`][osrlib.crawl.commands.SpawnMonsters] or
+ [`SpawnNpcParty`][osrlib.crawl.commands.SpawnNpcParty]. The session mode becomes
+ `encounter`, where the party can talk, run, wait, or fight.
+
+ It contains only what the party can see: a name, a count, a distance.
+ The dice behind the meeting are reported on
+ [`SurpriseRolledEvent`][osrlib.crawl.events.SurpriseRolledEvent] and on the
+ reaction roll, both at referee visibility. The two surprise outcomes are here,
+ because being caught off guard is something the party lives through.
"""
allowed_codes: ClassVar[frozenset[str]] = frozenset({"encounter.started"})
+ """The only message code this event uses."""
event_type: Literal["encounter_started"] = "encounter_started"
+ """The wire discriminator, `encounter_started`."""
code: str = "encounter.started"
+ """The message code, always `encounter.started`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: this is the moment the table is told what walked in."""
monster_name: str
+ """The label of the first group in the encounter, as the party would name it. An NPC
+ adventuring party appears under a label like `"Basic Adventurers"`, with its roster kept
+ behind the screen."""
count: int
+ """How many creatures there are across every group in the encounter."""
distance_feet: int
+ """How far away they are, in feet, when the encounter opens. Battle starts from this distance
+ and closes from there."""
party_surprised: bool = False
+ """Whether the party was caught off guard, which costs it the first beat of the fight."""
monsters_surprised: bool = False
+ """Whether the monsters were caught off guard, which gives the party a free round if the fight
+ starts."""
class SurpriseRolledEvent(Event):
- """One side's surprise die (referee); `roll` is `None` when the side never rolls."""
+ """One side's surprise die, rolled behind the screen.
+
+ Emitted twice when an encounter opens, once for each side, before
+ [`EncounterStartedEvent`][osrlib.crawl.events.EncounterStartedEvent] reports the
+ outcomes to the table.
+
+ A side that cannot be surprised doesn't roll: a party that already knows what is
+ in the room, or monsters that heard the party coming or can see its light.
+ """
allowed_codes: ClassVar[frozenset[str]] = frozenset({"encounter.surprise.rolled"})
+ """The only message code this event uses."""
event_type: Literal["surprise_rolled"] = "surprise_rolled"
+ """The wire discriminator, `surprise_rolled`."""
code: str = "encounter.surprise.rolled"
+ """The message code, always `encounter.surprise.rolled`."""
visibility: Visibility = Visibility.REFEREE
+ """Referee visibility: the die is the referee's, and the result reaches the table through the
+ encounter event."""
side: str
+ """Which side rolled: `"party"` or `"monsters"`."""
threshold: int
+ """The number on a d6 at or under which that side is surprised. It is 2 as a rule, and 3 for a
+ party moving in the dark without infravision."""
roll: int | None = None
+ """The d6 that came up, or `None` when this side never had to roll."""
surprised: bool
+ """Whether this side was surprised."""
class StanceChangedEvent(Event):
- """The monsters' stance, as behavior — the reaction roll itself is referee."""
+ """The monsters' attitude toward the party changed, as behavior the party can read.
+
+ Emitted when an encounter opens with its first reaction, when
+ [`Parley`][osrlib.crawl.commands.Parley] talks the monsters into a different
+ mood, when an uncertain stance resolves on the next beat, and when
+ [`TurnUndead`][osrlib.crawl.commands.TurnUndead] settles the matter by making the
+ survivors hostile.
+
+ The 2d6 reaction roll behind it is a kernel event at referee visibility. What
+ reaches the party is how the creatures are acting.
+ """
allowed_codes: ClassVar[frozenset[str]] = frozenset({"encounter.stance.changed"})
+ """The only message code this event uses."""
event_type: Literal["stance_changed"] = "stance_changed"
+ """The wire discriminator, `stance_changed`."""
code: str = "encounter.stance.changed"
+ """The message code, always `encounter.stance.changed`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: behavior is visible even when the roll behind it isn't."""
stance: str
+ """How the monsters are acting now, as a
+ [`ReactionResult`][osrlib.core.combat.ReactionResult] value: `"attacks"`, `"hostile"`,
+ `"uncertain"`, `"indifferent"`, or `"friendly"`. An attacking stance opens battle in the same
+ result."""
class EvasionEvent(Event):
- """An evasion attempt resolved: immediate success, or a pursuit begins."""
+ """The party tried to get away, and either did or has a pursuit on its hands.
+
+ Emitted by [`Evade`][osrlib.crawl.commands.Evade]. Getting clear at once ends the
+ encounter there. Otherwise a chase begins, and its beats arrive as
+ [`PursuitEvent`][osrlib.crawl.events.PursuitEvent]s, starting in this same
+ result.
+ """
allowed_codes: ClassVar[frozenset[str]] = frozenset({"encounter.evasion.succeeded", "encounter.evasion.pursuit"})
+ """`encounter.evasion.succeeded` when the party is away clean, `encounter.evasion.pursuit`
+ when something gives chase."""
event_type: Literal["evasion"] = "evasion"
+ """The wire discriminator, `evasion`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: the party knows whether it's being followed."""
class PursuitEvent(Event):
- """One pursuit beat: the round's gap, a distraction, escape, or capture."""
+ """One beat of a chase: the gap, a distraction taken, an escape, or a capture.
+
+ Emitted by [`Evade`][osrlib.crawl.commands.Evade] once the chase is on, and by
+ [`Wait`][osrlib.crawl.commands.Wait] and
+ [`DropItems`][osrlib.crawl.commands.DropItems] for each further beat, which is
+ how the party keeps running or throws something behind it.
+
+ A capture opens battle at once, and an escape ends the encounter. A chase that
+ runs long enough tires the party out, which arrives as
+ [`ExhaustionEvent`][osrlib.crawl.events.ExhaustionEvent].
+ """
allowed_codes: ClassVar[frozenset[str]] = frozenset(
{
@@ -435,165 +856,331 @@ class PursuitEvent(Event):
"encounter.pursuit.caught",
}
)
+ """`encounter.pursuit.round` for a beat where the chase goes on, `.distracted` when dropped
+ treasure or food stops the pursuers, `.escaped` when the party gets clear, and `.caught` when
+ the pursuers close to arm's length and battle opens."""
event_type: Literal["pursuit"] = "pursuit"
+ """The wire discriminator, `pursuit`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: the party can see how close the chase is."""
round: int
+ """Which beat of the chase this is, counting from one."""
gap_feet: int
+ """How far ahead the party is, in feet, at the end of this beat. It never goes below zero, and
+ at five feet or less the pursuers have caught up."""
class ExhaustionEvent(Event):
- """The party gained or recovered from running exhaustion (30 rounds, −2s)."""
+ """The party ran itself ragged, or has rested long enough to recover.
+
+ Running flat out for a long chase costs a party 2 on its attack and damage rolls
+ and makes it 2 easier to hit, until it rests. Emitted by
+ [`Evade`][osrlib.crawl.commands.Evade] and [`Wait`][osrlib.crawl.commands.Wait]
+ when a chase runs its full length, and recovered by
+ [`Rest`][osrlib.crawl.commands.Rest] once three turns of rest have been
+ credited.
+ """
allowed_codes: ClassVar[frozenset[str]] = frozenset(
{"encounter.exhaustion.gained", "encounter.exhaustion.recovered"}
)
+ """`encounter.exhaustion.gained` when the running catches up with the party,
+ `encounter.exhaustion.recovered` when enough rest clears it."""
event_type: Literal["exhaustion"] = "exhaustion"
+ """The wire discriminator, `exhaustion`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: the penalty is on the party's own sheets."""
class EncounterEndedEvent(Event):
- """The encounter concluded; the clock owes at least one full turn."""
+ """The encounter is over, however it went.
+
+ Emitted once the last group has been dealt with: beaten, evaded, escaped from, or
+ driven off. The session goes back to `exploring`, and the clock owes at least one
+ full turn, so time passes with this event even when the fight was short.
+ """
allowed_codes: ClassVar[frozenset[str]] = frozenset({"encounter.ended"})
+ """The only message code this event uses."""
event_type: Literal["encounter_ended"] = "encounter_ended"
+ """The wire discriminator, `encounter_ended`."""
code: str = "encounter.ended"
+ """The message code, always `encounter.ended`. The ending is in `outcome`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: the party knows the encounter is behind it."""
outcome: str
+ """How it ended: `"victory"` when the monsters were beaten, `"evaded"` when the party got away
+ before or during a fight, `"escaped"` when a chase ran out, or `"turned"` when undead were
+ driven off."""
class BattleStartedEvent(Event):
- """Battle began: the range-track machine takes over."""
+ """Blows have been struck: the encounter became a battle.
+
+ Emitted when a fight opens, by [`EngageBattle`][osrlib.crawl.commands.EngageBattle]
+ when the party attacks, and on its own when the monsters do, which can happen
+ the moment an encounter opens, when a parley goes wrong, when undead are
+ presented with a holy symbol, or when a chase ends in capture.
+
+ The session mode becomes `battle`, where
+ [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] is the only play
+ command the session accepts. Positions are no longer cells: each monster group
+ has a distance from the party, and closing or pulling back moves that number.
+ """
allowed_codes: ClassVar[frozenset[str]] = frozenset({"battle.started"})
+ """The only message code this event uses."""
event_type: Literal["battle_started"] = "battle_started"
+ """The wire discriminator, `battle_started`."""
code: str = "battle.started"
+ """The message code, always `battle.started`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: the party is in it."""
class BattleRoundEvent(Event):
- """A battle round began."""
+ """A battle round began.
+
+ Emitted at the top of every
+ [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound], before the
+ declarations post and initiative is rolled. It's the marker a transcript can
+ group the rest of the round's events under.
+ """
allowed_codes: ClassVar[frozenset[str]] = frozenset({"battle.round.started"})
+ """The only message code this event uses."""
event_type: Literal["battle_round"] = "battle_round"
+ """The wire discriminator, `battle_round`."""
code: str = "battle.round.started"
+ """The message code, always `battle.round.started`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: everyone at the table knows which round it is."""
round: int
+ """Which round of this battle is starting, counting from one."""
class SpellDeclaredEvent(Event):
- """A spell declaration posted — table-visible per RAW."""
+ """Somebody declared a spell, before anyone knows who acts first.
+
+ Emitted by [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] for
+ each caster who declared one, party member or NPC alike, at the top of the round.
+
+ B/X has declarations posted before initiative on purpose: a caster who takes
+ damage before their turn loses the spell, and the other side can act on knowing
+ what is coming. The disruption itself arrives later in the round as a kernel
+ event.
+ """
allowed_codes: ClassVar[frozenset[str]] = frozenset({"battle.spell.declared"})
+ """The only message code this event uses."""
event_type: Literal["spell_declared"] = "spell_declared"
+ """The wire discriminator, `spell_declared`."""
code: str = "battle.spell.declared"
+ """The message code, always `battle.spell.declared`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: a declaration is made out loud at the table."""
caster_id: str
+ """Who is casting: a party member's id, or an NPC adventurer's."""
spell_id: str
+ """Which spell was declared, as its catalog id."""
reversed: bool = False
+ """Whether the reversed form was declared, for a spell that has one."""
class GroupMovedEvent(Event):
- """A group's range-track distance changed."""
+ """A monster group's distance from the party changed.
+
+ Emitted by [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound]
+ whenever the range track moves: the party closing on a group or backing away
+ from every group, monsters closing to strike, and a broken group running for the
+ exit.
+
+ Distance decides what can reach what, so this is the event a battle screen
+ redraws its ranks from.
+ """
allowed_codes: ClassVar[frozenset[str]] = frozenset({"battle.group.moved"})
+ """The only message code this event uses."""
event_type: Literal["group_moved"] = "group_moved"
+ """The wire discriminator, `group_moved`."""
code: str = "battle.group.moved"
+ """The message code, always `battle.group.moved`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: the party watches them come on or draw off."""
group_id: str
+ """Which group moved, as its encounter group id."""
distance_feet: int
+ """How far that group now stands from the party, in feet, after the move. Melee happens at the
+ track's shortest step."""
class MonsterFledEvent(Event):
- """A monster group broke: fled the battle or surrendered."""
+ """A monster group broke and ran.
+
+ Emitted by [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] when
+ a group fails a morale check, and at the opening of a battle for a group whose
+ morale is so low it never fights at all. A running group keeps moving away each
+ round and is gone once it's far enough out. Its members still count as defeated
+ for the adventure's experience award.
+ """
allowed_codes: ClassVar[frozenset[str]] = frozenset({"battle.side.fled", "battle.side.surrendered"})
+ """`battle.side.fled` is what the engine's own resolution emits. `battle.side.surrendered` is
+ reserved for a group that gives itself up, which the encounter state models but no engine path
+ currently produces. A game that adjudicates a surrender itself can use that code."""
event_type: Literal["monster_fled"] = "monster_fled"
+ """The wire discriminator, `monster_fled`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: the party sees them break."""
group_id: str
+ """Which group broke, as its encounter group id."""
class MonstersLeftBehindEvent(Event):
- """A routing group left its helpless members where they lie.
+ """A group that ran left its helpless members lying where they were.
- Fleeing is movement, and a member who cannot move (asleep, paralysed, held by a
- *web*) cannot run: the runners split off and keep fleeing under the original
- group id while the helpless stay behind as the new group `group_id`, at the
- distance the side broke from.
+ Emitted by [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] when
+ a broken group has members who cannot run, because they are asleep, paralysed, or
+ held.
+
+ The runners keep the original group and go on fleeing, and the ones left behind
+ become a new group at the distance the side broke from, so the party can finish
+ them, take what they carry, or walk past.
"""
allowed_codes: ClassVar[frozenset[str]] = frozenset({"battle.group.left_behind"})
+ """The only message code this event uses."""
event_type: Literal["monsters_left_behind"] = "monsters_left_behind"
+ """The wire discriminator, `monsters_left_behind`."""
code: str = "battle.group.left_behind"
+ """The message code, always `battle.group.left_behind`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: the party can see who was abandoned."""
group_id: str
+ """The new group the helpless members were put into."""
source_group_id: str
+ """The group that ran off without them."""
count: int
+ """How many were left behind."""
class MonsterDefeatedEvent(Event):
- """One monster defeated — feeds the adventure's XP award.
+ """One monster is out of the fight, and here is what it was worth.
+
+ Emitted once per defeated creature when the encounter concludes, which follows
+ the last [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] or an
+ escape that leaves the fight behind.
- Emitted per monster at battle end with `outcome` `slain`, `routed`, or
- `surrendered`; `xp` is the template's printed award.
+ These are the entries the adventure's experience award adds up, and the award
+ itself arrives later, as
+ [`AdventureXpAwardEvent`][osrlib.crawl.events.AdventureXpAwardEvent] on the trip
+ back to town, or at once when the ruleset awards immediately.
"""
allowed_codes: ClassVar[frozenset[str]] = frozenset({"battle.monster.defeated"})
+ """The only message code this event uses."""
event_type: Literal["monster_defeated"] = "monster_defeated"
+ """The wire discriminator, `monster_defeated`."""
code: str = "battle.monster.defeated"
+ """The message code, always `battle.monster.defeated`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: the party sees them fall or flee."""
monster_id: str
+ """The session id of the creature that was defeated."""
template_id: str
+ """What it was: a monster catalog id, or `"npc:"` for a defeated NPC adventurer."""
outcome: str
+ """How it went out: `"slain"`, `"routed"` when it fled or was turned, or `"surrendered"`."""
xp: int
+ """What it's worth: the monster catalog's printed award, or the level-based award for an NPC
+ adventurer."""
class BattleEndedEvent(Event):
- """The battle ended: victory, the party fled, or defeat."""
+ """The battle is over: won, quit, or lost.
+
+ Emitted by [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound], and
+ by [`EngageBattle`][osrlib.crawl.commands.EngageBattle] when the opposition
+ breaks before the first exchange.
+
+ A victory ends the encounter with it. A retreat may leave the party in a chase
+ rather than clear of the fight. A defeat means nobody is left standing, and a
+ [`GameOverEvent`][osrlib.crawl.events.GameOverEvent] closes the same result.
+ """
allowed_codes: ClassVar[frozenset[str]] = frozenset(
{"battle.ended.victory", "battle.ended.fled", "battle.ended.defeat"}
)
+ """`battle.ended.victory` when no opposition is left fighting, `battle.ended.fled` when the
+ party pulled out, and `battle.ended.defeat` when the party fell."""
event_type: Literal["battle_ended"] = "battle_ended"
+ """The wire discriminator, `battle_ended`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: the fight is the party's own."""
class HoardGeneratedEvent(Event):
- """A lair hoard, carried bundle, or area treasure generated (referee).
+ """Treasure was rolled up and placed, before anyone has found it.
- Referee visibility — contents are itemized here and players learn by finding.
- `cache_ref` is the engine-created cache's state reference (or the group id for
- carried bundles); value and counts summarize the generation.
+ Emitted when the party first enters an area whose author declared treasure, and
+ when a keyed encounter's monsters bring their lair hoard with them. The goods go
+ into a cache the party has to find and open with
+ [`TakeTreasure`][osrlib.crawl.commands.TakeTreasure].
+
+ It's referee visibility, and it itemizes everything: telling the players would
+ be telling them what is in the room. A referee front end, or an LLM running the
+ game, reads it to know what is there.
"""
allowed_codes: ClassVar[frozenset[str]] = frozenset({"treasure.hoard.generated"})
+ """The only message code this event uses."""
event_type: Literal["hoard_generated"] = "hoard_generated"
+ """The wire discriminator, `hoard_generated`."""
code: str = "treasure.hoard.generated"
+ """The message code, always `treasure.hoard.generated`."""
visibility: Visibility = Visibility.REFEREE
+ """Referee visibility: the contents are the answer to a question the party hasn't asked
+ yet."""
cache_ref: str
+ """The id of the cache the treasure went into, allocated by the session as `cache-NNNN`. The
+ party reaches it through the cell it sits on, not through this id."""
treasure_types: tuple[str, ...] = ()
+ """The treasure-type letters that were rolled, one entry per roll, so a hoard rolled from two
+ letters lists both."""
coins_gp_value: int = 0
+ """The coins in the hoard, as their value in gold pieces."""
valuable_ids: tuple[str, ...] = ()
+ """The session-scoped instance ids of the gems and jewellery in the hoard."""
magic_item_ids: tuple[str, ...] = ()
+ """The session-scoped instance ids of the magic items in the hoard."""
class ItemUsedEvent(Event):
- """A magic item used: a potion drunk (or mixed), a scroll read, a device activated.
-
- `items.device.inert` is a rejection code, not an event — activating an
- exhausted device costs nothing, the same as a blocked move. Charges
- never appear here: they are referee-only forever (RAW, undiscoverable).
+ """A magic item was used: a potion drunk, a scroll read, a device fired.
+
+ Emitted by [`UseItem`][osrlib.crawl.commands.UseItem] out of combat and by
+ [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] for an item used
+ in a fight. Whatever the item does follows in the same result as kernel events.
+
+ Using an item for the first time is what identifies it, so an
+ [`ItemIdentifiedEvent`][osrlib.crawl.events.ItemIdentifiedEvent] and possibly a
+ [`CurseRevealedEvent`][osrlib.crawl.events.CurseRevealedEvent] come just before
+ this one. Firing a device that has nothing left in it is a rejection
+ (`items.device.inert`) rather than an event, because it costs the party nothing.
+ Charges never appear on any event: how many uses an item has left is the
+ referee's to know.
"""
allowed_codes: ClassVar[frozenset[str]] = frozenset(
@@ -605,386 +1192,642 @@ class ItemUsedEvent(Event):
"items.device.activated",
}
)
+ """`items.potion.drunk` for a potion taken on its own, `items.potion.mixed` when it meets
+ another still running, which loses both and lays the drinker out for three turns,
+ `items.scroll.read` for a scroll, `items.scroll.cursed` for one whose script was baneful, and
+ `items.device.activated` for a rod, staff, wand, or other device."""
event_type: Literal["item_used"] = "item_used"
+ """The wire discriminator, `item_used`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: the party watched it happen."""
character_id: str
+ """The member who used the item."""
instance_id: str
+ """The session-scoped id of the item used, never its template id, so an item the party hasn't
+ identified keeps its secret."""
manual: tuple[str, ...] = ()
+ """The item's printed text, for the items whose effect the engine doesn't resolve, like a
+ treasure map or a curse the game narrates. Empty when the engine resolved the effect itself.
+ Show these lines to the table and adjudicate them yourself."""
class ItemIdentifiedEvent(Event):
- """A magic item identified — a first meaningful use of it is the trigger."""
+ """A magic item gave itself away, and the party now knows what it is.
+
+ Emitted the first time an item is used in a way that reveals it, which happens
+ inside [`UseItem`][osrlib.crawl.commands.UseItem],
+ [`EquipItem`][osrlib.crawl.commands.EquipItem],
+ [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound], and the
+ referee's [`IdentifyItem`][osrlib.crawl.commands.IdentifyItem].
+
+ Before this, the item's `instance_id` is all any player-visible event named.
+ After it, the party can be shown the template's name.
+ """
allowed_codes: ClassVar[frozenset[str]] = frozenset({"items.item.identified"})
+ """The only message code this event uses."""
event_type: Literal["item_identified"] = "item_identified"
+ """The wire discriminator, `item_identified`."""
code: str = "items.item.identified"
+ """The message code, always `items.item.identified`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: this is the moment the party learns what it has."""
instance_id: str
+ """The session-scoped id of the item, the same id the earlier events used."""
template_id: str
+ """What it turned out to be, as its magic item catalog id."""
class CurseRevealedEvent(Event):
- """A cursed item revealed its true nature — and pins itself to its bearer."""
+ """A cursed item showed its true nature, and won't let go.
+
+ Emitted alongside [`ItemIdentifiedEvent`][osrlib.crawl.events.ItemIdentifiedEvent]
+ the first time a cursed item is used or worn. From here the bearer is stuck with
+ it until something removes the curse.
+ """
allowed_codes: ClassVar[frozenset[str]] = frozenset({"items.curse.revealed"})
+ """The only message code this event uses."""
event_type: Literal["curse_revealed"] = "curse_revealed"
+ """The wire discriminator, `curse_revealed`."""
code: str = "items.curse.revealed"
+ """The message code, always `items.curse.revealed`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: the bearer finds out the hard way."""
character_id: str
+ """The member the curse has attached itself to."""
instance_id: str
+ """The session-scoped id of the cursed item."""
template_id: str
+ """What the item is, as its magic item catalog id."""
class NpcPartySpawnedEvent(Event):
- """An NPC adventuring party generated and fielded (referee — the full roster).
+ """An NPC adventuring party was rolled up and put on the board.
+
+ Emitted by the referee's
+ [`SpawnNpcParty`][osrlib.crawl.commands.SpawnNpcParty] and by a wandering roll
+ that comes up adventurers, before the encounter opens.
- The player-facing `EncounterStartedEvent` names "adventurers" and the count;
- the roster, classes, and levels are the referee's.
+ It's referee visibility and contains the whole roster. What the party sees is the
+ [`EncounterStartedEvent`][osrlib.crawl.events.EncounterStartedEvent], which names
+ them as adventurers and gives a count. Their classes and levels are something to
+ find out by talking or by fighting.
"""
allowed_codes: ClassVar[frozenset[str]] = frozenset({"encounter.npc_party.spawned"})
+ """The only message code this event uses."""
event_type: Literal["npc_party_spawned"] = "npc_party_spawned"
+ """The wire discriminator, `npc_party_spawned`."""
code: str = "encounter.npc_party.spawned"
+ """The message code, always `encounter.npc_party.spawned`."""
visibility: Visibility = Visibility.REFEREE
+ """Referee visibility: the roster is what the party doesn't get to read off a sheet."""
party_kind: str
+ """Which table the party was rolled from: `"basic"` or `"expert"`."""
npc_ids: tuple[str, ...]
+ """The session ids of its members, in roster order. The other three tuples line up with this
+ one."""
class_ids: tuple[str, ...]
+ """Each member's class, as a class catalog id."""
levels: tuple[int, ...]
+ """Each member's level."""
alignment: str
+ """The party's alignment, as a lowercase [`Alignment`][osrlib.core.alignment.Alignment] value.
+ It decides how they are played more than how they roll."""
class AdventureXpAwardEvent(Event):
- """The end-of-adventure XP award: the totals and the per-head share."""
+ """The delve paid out: what the party earned and what each survivor takes.
+
+ Emitted by [`TravelToTown`][osrlib.crawl.commands.TravelToTown] under the default
+ ruleset, where experience is awarded for making it back. Each survivor's own
+ [`XpAwardedEvent`][osrlib.crawl.events.XpAwardedEvent] follows it, and a level
+ gained follows that.
+
+ Treasure counts by what the party carried out compared with what it carried in,
+ so goods still lying in the dungeon are worth nothing yet. A party that lost
+ everyone awards nothing, because nobody came back to spend it.
+ """
allowed_codes: ClassVar[frozenset[str]] = frozenset({"session.xp.adventure_award"})
+ """The only message code this event uses."""
event_type: Literal["adventure_xp_award"] = "adventure_xp_award"
+ """The wire discriminator, `adventure_xp_award`."""
code: str = "session.xp.adventure_award"
+ """The message code, always `session.xp.adventure_award`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: the award is the point of coming back."""
monster_xp: int
+ """What the defeated creatures were worth, added up across the whole delve."""
treasure_xp: int
+ """What the recovered treasure was worth, one experience point per gold piece of value gained
+ since the party left town, and never less than zero."""
share: int
+ """What each survivor receives: the total divided by the number of survivors, rounded down."""
survivors: tuple[str, ...]
+ """The members who made it back, in marching order. The dead count toward the treasure that
+ came home but take no share."""
class TreasureSoldEvent(Event):
- """Valuables sold in town at full value (the 1-gp-1-XP identity kept clean)."""
+ """Valuables were sold in town, and the coins are in the purse.
+
+ Emitted by [`SellTreasure`][osrlib.crawl.commands.SellTreasure]. Gems and
+ jewellery sell for their full listed value, which keeps one gold piece worth one
+ experience point however treasure is converted.
+ """
allowed_codes: ClassVar[frozenset[str]] = frozenset({"town.treasure.sold"})
+ """The only message code this event uses."""
event_type: Literal["treasure_sold"] = "treasure_sold"
+ """The wire discriminator, `treasure_sold`."""
code: str = "town.treasure.sold"
+ """The message code, always `town.treasure.sold`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: the party made the sale."""
character_id: str
+ """The member who sold them and now holds the coins."""
instance_ids: tuple[str, ...]
+ """The session-scoped ids of the valuables that were sold."""
gp_value: int
+ """What they fetched, in gold pieces."""
class HealingPurchasedEvent(Event):
- """A temple healing service purchased and cast."""
+ """A temple service was paid for and cast.
+
+ Emitted by [`PurchaseHealing`][osrlib.crawl.commands.PurchaseHealing] in town,
+ followed by the kernel events of the spell itself.
+ """
allowed_codes: ClassVar[frozenset[str]] = frozenset({"town.healing.purchased"})
+ """The only message code this event uses."""
event_type: Literal["healing_purchased"] = "healing_purchased"
+ """The wire discriminator, `healing_purchased`."""
code: str = "town.healing.purchased"
+ """The message code, always `town.healing.purchased`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: the party bought it."""
character_id: str
+ """The member the service was cast on, and whose purse paid for it."""
service: str
+ """Which service was bought, as the key the town's price list uses."""
cost_gp: int
+ """What it cost, in gold pieces."""
class FlagSetEvent(Event):
- """A session flag changed (referee — content wiring is the game's secret).
+ """A session flag was written.
+
+ Emitted by the referee's [`SetFlag`][osrlib.crawl.commands.SetFlag]. Flags are
+ the game's own memory: an adventure's triggers and gates read them through a
+ [`FlagEqualsCondition`][osrlib.crawl.gates.FlagEqualsCondition], and a game can
+ keep whatever else it wants there.
- The flag store a [`FlagEqualsCondition`][osrlib.crawl.gates.FlagEqualsCondition]
- reads is the same one this event reports being written.
+ It's referee visibility, because what the game is keeping track of isn't part
+ of the fiction the party is in.
"""
allowed_codes: ClassVar[frozenset[str]] = frozenset({"session.flag.set"})
+ """The only message code this event uses."""
event_type: Literal["flag_set"] = "flag_set"
+ """The wire discriminator, `flag_set`."""
code: str = "session.flag.set"
+ """The message code, always `session.flag.set`."""
visibility: Visibility = Visibility.REFEREE
+ """Referee visibility: the wiring behind the game stays behind the screen."""
key: str
+ """Which flag was written."""
value: str | int | bool
+ """What it was set to. Writing an existing key replaces its value."""
class MonstersSpawnedEvent(Event):
- """Monsters spawned into the session registry (referee bookkeeping)."""
+ """Monsters were put into the session by the referee.
+
+ Emitted by [`SpawnMonsters`][osrlib.crawl.commands.SpawnMonsters], before the
+ encounter that fields them opens in the same result.
+
+ It's referee visibility and contains ids rather than a description. The party
+ learns what walked in from
+ [`EncounterStartedEvent`][osrlib.crawl.events.EncounterStartedEvent].
+ """
allowed_codes: ClassVar[frozenset[str]] = frozenset({"session.monsters.spawned"})
+ """The only message code this event uses."""
event_type: Literal["monsters_spawned"] = "monsters_spawned"
+ """The wire discriminator, `monsters_spawned`."""
code: str = "session.monsters.spawned"
+ """The message code, always `session.monsters.spawned`."""
visibility: Visibility = Visibility.REFEREE
+ """Referee visibility: this is bookkeeping, not a moment in the fiction."""
template_id: str
+ """What was spawned, as a monster catalog id."""
monster_ids: tuple[str, ...]
+ """The session ids of the new instances, in spawn order. They are what every later event about
+ those creatures names."""
class XpAwardedEvent(Event):
- """An XP award applied to one character."""
+ """One character was awarded experience.
+
+ Emitted wherever an award lands: inside the end-of-adventure award on
+ [`TravelToTown`][osrlib.crawl.commands.TravelToTown], at each encounter's end
+ when the ruleset awards immediately, and from the referee's
+ [`AwardXP`][osrlib.crawl.commands.AwardXP]. When the award crosses a threshold, a
+ [`CharacterLeveledUpEvent`][osrlib.crawl.events.CharacterLeveledUpEvent] for the
+ same member follows it at once.
+ """
allowed_codes: ClassVar[frozenset[str]] = frozenset({"session.xp.awarded"})
+ """The only message code this event uses."""
event_type: Literal["xp_awarded"] = "xp_awarded"
+ """The wire discriminator, `xp_awarded`."""
code: str = "session.xp.awarded"
+ """The message code, always `session.xp.awarded`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: it goes on the character's sheet."""
character_id: str
+ """The member who received it."""
award: int
+ """The award as the session handed it over, before the character's own adjustment."""
modified_award: int
+ """What was actually added, after the class's prime-requisite percentage, rounded down. This
+ is the number to show beside the character."""
level_after: int
+ """The member's level once the award was applied."""
class CharacterLeveledUpEvent(Event):
- """One character gained a level — the award's threshold crossing made visible.
-
- Fires immediately after the member's own
- [`XpAwardedEvent`][osrlib.crawl.events.XpAwardedEvent] whenever an XP award
- crosses a level threshold, whichever surface awarded it (the end-of-adventure
- award, the immediate timing, or the referee's
- [`AwardXP`][osrlib.crawl.commands.AwardXP]). While the Hit Dice count still
- grows, `hp_roll` is the raw die; past name level the gain is the flat-bonus
- delta with no die, so `hp_roll` is `None` and `con_applied` is false. `title`
- is the class's level title at `level_after`, `None` past the printed title
- list (the SRD's lists run only through name level).
+ """A character crossed a threshold and gained a level.
+
+ Emitted immediately after that member's own
+ [`XpAwardedEvent`][osrlib.crawl.events.XpAwardedEvent], whichever award crossed
+ the threshold. A character gains at most one level per award.
"""
allowed_codes: ClassVar[frozenset[str]] = frozenset({"session.level.gained"})
+ """The only message code this event uses."""
event_type: Literal["leveled_up"] = "leveled_up"
+ """The wire discriminator, `leveled_up`."""
code: str = "session.level.gained"
+ """The message code, always `session.level.gained`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: the table has been waiting for this one."""
character_id: str
+ """The member who levelled."""
level_before: int
+ """The level held before the award."""
level_after: int
+ """The level held after it, one higher."""
hp_gained: int
+ """How many hit points were added, the die and the constitution adjustment together, or the
+ flat bonus past the class's last Hit Die."""
hp_roll: int | None
+ """The hit die that was rolled, or `None` past the class's last Hit Die, where levels bring a
+ flat bonus and no die."""
con_applied: bool
+ """Whether the constitution adjustment was applied, which happens only when a die was rolled."""
title: str | None
+ """The class's title for the new level, or `None` past the printed list of titles."""
class TimeAdvancedEvent(Event):
- """The clock advanced (referee bookkeeping); `rounds_total` is the new position."""
+ """The referee moved the clock.
+
+ Emitted by [`AdvanceTime`][osrlib.crawl.commands.AdvanceTime]. The time passes
+ with all its usual bookkeeping, so effect expiries, light burning out, and
+ provisions for a day crossed all arrive in the same result, but no wandering
+ check runs: a referee moving the clock decides for themselves what walks in.
+ """
allowed_codes: ClassVar[frozenset[str]] = frozenset({"session.time.advanced"})
+ """The only message code this event uses."""
event_type: Literal["time_advanced"] = "time_advanced"
+ """The wire discriminator, `time_advanced`."""
code: str = "session.time.advanced"
+ """The message code, always `session.time.advanced`."""
visibility: Visibility = Visibility.REFEREE
+ """Referee visibility: the clock is the referee's instrument."""
n: int
+ """How many units were asked for."""
unit: str
+ """Which unit, as a lowercase [`TimeUnit`][osrlib.core.clock.TimeUnit] value: `"round"`,
+ `"turn"`, or `"day"`."""
rounds_total: int
+ """Where the clock now stands, in rounds since the session began. It's the same number
+ `GameSession.clock.rounds` holds."""
class GameOverEvent(Event):
- """The session ended: the party was wiped out, however it happened.
+ """Every party member is dead and the session has ended.
- A lost battle, a save-or-die trap, a fall, starvation, or a poison that
- finished the last member under the referee's clock all report the same
- ending, and the session is in `game_over` when it lands.
+ Emitted by whatever command's events killed the last member: a lost battle, a
+ trap, a fall down a chute, starvation, a poison that finished someone while the
+ referee was moving the clock. It closes that command's result, and the session
+ mode becomes `game_over`.
+
+ Play commands are refused from there. A referee can still act, and
+ [`PlaceParty`][osrlib.crawl.commands.PlaceParty] is the way out, because carrying
+ the fallen back to town is the first step of a revival.
"""
allowed_codes: ClassVar[frozenset[str]] = frozenset({"session.game_over"})
+ """The only message code this event uses."""
event_type: Literal["game_over"] = "game_over"
+ """The wire discriminator, `game_over`."""
code: str = "session.game_over"
+ """The message code, always `session.game_over`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: it's the party's ending."""
reason: str
+ """Why the session ended, as a short phrase. Every ending reports the party falling. What
+ killed them is in the events just before it."""
class DiceRolledEvent(Event):
- """An authorial dice roll resolved (referee — the referee's hidden adjudication rolls).
+ """The referee rolled dice for something the rules don't cover.
+
+ Emitted by [`RollDice`][osrlib.crawl.commands.RollDice]. The roll comes off the
+ session's own adjudication stream, kept apart from the streams the rules use, so
+ a referee rolling for weather or a rumour never shifts the dice a later attack or
+ save would have drawn.
- Emitted by [`RollDice`][osrlib.crawl.commands.RollDice] when a referee resolves a
- freeform *chance* outcome by rolling through the seeded session. Carries the
- expression that was rolled, the `total`, and each individual die result in
- `rolls`.
+ It's referee visibility: a hidden adjudication isn't automatically the table's
+ to see. Show it to the players yourself when the ruling was made in the open.
"""
allowed_codes: ClassVar[frozenset[str]] = frozenset({"adjudication.dice_rolled"})
+ """The only message code this event uses."""
event_type: Literal["dice_rolled"] = "dice_rolled"
+ """The wire discriminator, `dice_rolled`."""
code: str = "adjudication.dice_rolled"
+ """The message code, always `adjudication.dice_rolled`."""
visibility: Visibility = Visibility.REFEREE
+ """Referee visibility: the referee decides what to share."""
expression: str
+ """What was rolled, as the dice expression that was asked for, like `"2d6+1"`."""
total: int
+ """The result, dice and modifier together."""
rolls: tuple[int, ...]
+ """Each die's own result, in roll order, so a transcript can show the dice rather than only
+ the sum."""
class TriggerFiredEvent(Event):
- """An authored trigger fired (referee — trigger wiring is the game's secret).
+ """An authored trigger fired.
- Emitted for every [`MarkTriggerFired`][osrlib.crawl.commands.MarkTriggerFired],
- a mark of an already-fired trigger included: session state records that a
- trigger has fired, and these events record each firing.
+ Emitted by [`MarkTriggerFired`][osrlib.crawl.commands.MarkTriggerFired], every
+ time, including a repeat of a trigger that has fired before: the session records
+ that a trigger has fired at all, and these events are the record of each firing.
- `narrative` is the trigger's authored beat for the firing — content data in a
- structured field, not engine-baked English: the event still carries its message
- code and its facts, and the default formatter appends the line verbatim after
- the templated one. It rides a referee-visibility event because trigger wiring is
- the game's secret; a beat written for the table is a journal entry.
+ It's referee visibility, because which clause fired is the wiring behind the
+ game. A beat written for the table goes in the journal, and arrives as
+ [`JournalEntryAddedEvent`][osrlib.crawl.events.JournalEntryAddedEvent] or as one
+ of the quest events.
"""
allowed_codes: ClassVar[frozenset[str]] = frozenset({"session.trigger.fired"})
+ """The only message code this event uses."""
event_type: Literal["trigger_fired"] = "trigger_fired"
+ """The wire discriminator, `trigger_fired`."""
code: str = "session.trigger.fired"
+ """The message code, always `session.trigger.fired`."""
visibility: Visibility = Visibility.REFEREE
+ """Referee visibility: trigger wiring is the game's own."""
trigger_id: str
+ """Which trigger fired, as the id the adventure gave it."""
narrative: str | None = None
+ """The beat the author wrote for this firing, or `None`. Content rather than engine prose: the
+ default formatter appends it after the templated line. It reaches a referee-visibility event,
+ so put anything meant for the table in the journal instead."""
class JournalEntryAddedEvent(Event):
- """A beat was appended to the session journal — the whole entry, as written.
-
- Player-visible: the journal is written for the table. The authored `text` is
- content data in a structured field, not engine-baked English — the event still
- carries its message code and its facts — and `rounds` is the clock position the
- entry landed at, the same stamp the stored entry carries.
-
- It is not the only event a growing journal emits. A quest beat appends its entry
- and reports itself through its own lifecycle event — the whole set is this event
- plus [`QuestActivatedEvent`][osrlib.crawl.events.QuestActivatedEvent],
- [`ObjectiveRevealedEvent`][osrlib.crawl.events.ObjectiveRevealedEvent],
- [`ObjectiveCompletedEvent`][osrlib.crawl.events.ObjectiveCompletedEvent], and
- [`QuestCompletedEvent`][osrlib.crawl.events.QuestCompletedEvent] — because
- emitting both for one beat would report the same line to the table twice. A
- client that wants the whole journal reads it from the view.
+ """A beat was written into the session journal.
+
+ Emitted by [`AddJournalEntry`][osrlib.crawl.commands.AddJournalEntry]. The
+ journal is the party's own record of the adventure, appended in order and never
+ rewritten, and it's part of what
+ [`GameSession.view`][osrlib.crawl.session.GameSession.view] shows a player.
+
+ It isn't the only event a growing journal produces. A quest beat appends its
+ entry and reports itself through its own lifecycle event instead, so the table
+ isn't told the same line twice. Read the whole journal from the view, and read
+ these events to know when a line arrived.
"""
allowed_codes: ClassVar[frozenset[str]] = frozenset({"session.journal.entry_added"})
+ """The only message code this event uses."""
event_type: Literal["journal_entry_added"] = "journal_entry_added"
+ """The wire discriminator, `journal_entry_added`."""
code: str = "session.journal.entry_added"
+ """The message code, always `session.journal.entry_added`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: the journal is written for the table."""
text: str
+ """The beat as it was written. Content the game or the adventure supplied, not prose the
+ engine wrote."""
rounds: int
+ """Where the clock stood when the beat landed, in rounds since the session began. The stored
+ entry has the same stamp."""
class NoteRecordedEvent(Event):
- """A referee annotation was recorded (referee — and it changes no state).
+ """A referee note was recorded, and no game state changed.
- The report of a machine-issued record — a dropped consequence, a cascade cut
- short — or of a referee's own margin note.
+ Emitted by [`RecordNote`][osrlib.crawl.commands.RecordNote]. Games use it to leave
+ a machine-written note in the log, like a consequence that could not be applied,
+ and referees use it for their own margin notes.
"""
allowed_codes: ClassVar[frozenset[str]] = frozenset({"session.note.recorded"})
+ """The only message code this event uses."""
event_type: Literal["note_recorded"] = "note_recorded"
+ """The wire discriminator, `note_recorded`."""
code: str = "session.note.recorded"
+ """The message code, always `session.note.recorded`."""
visibility: Visibility = Visibility.REFEREE
+ """Referee visibility: a note is for the person running the game."""
text: str
+ """The note as it was written."""
class QuestActivatedEvent(Event):
- """An authored quest came into play — the table's news, not the wiring behind it.
-
- Player-visible: a quest the party has taken on is theirs to know, while the
- clause that started it stays behind the screen with the trigger and flag events.
- `narrative` is the quest's authored offer beat, `None` when unauthored — content
- data in a structured field, not engine-baked English, appended verbatim by the
- default formatter after the templated line. The same beat is appended to the
- journal, so this event and its entry are one report of one moment.
+ """A quest came into play.
+
+ Emitted by [`ActivateQuest`][osrlib.crawl.commands.ActivateQuest]. A quest the
+ adventure marked as standing from the start needs no activation and no event: it
+ is active from the first command.
+
+ It's player visibility, because a job the party has taken on is theirs to know,
+ while the clause that set it off stays behind the screen with the trigger and
+ flag events.
"""
allowed_codes: ClassVar[frozenset[str]] = frozenset({"session.quest.activated"})
+ """The only message code this event uses."""
event_type: Literal["quest_activated"] = "quest_activated"
+ """The wire discriminator, `quest_activated`."""
code: str = "session.quest.activated"
+ """The message code, always `session.quest.activated`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: the party is being given the job."""
quest_id: str
+ """Which quest, as the id the adventure gave it."""
name: str
+ """The quest's display name, so a renderer needs no copy of the adventure to show it."""
narrative: str | None = None
+ """The offer beat the author wrote, or `None` when there's none. The same line is appended to
+ the journal, so this event and that entry report one moment once."""
class ObjectiveRevealedEvent(Event):
- """A hidden objective surfaced: the party can see what it is being asked for.
-
- `name` is the objective's display label — its authored name, or its id when the
- document authors none — and `quest_name` the owning quest's name, both resolved
- at emission so a renderer holds no document to look them up in. Both default
- empty only because an event logged before the fields existed still parses; the
- engine always fills them. `narrative` is the objective's authored offer beat,
- `None` when unauthored, and the journal carries the same line.
+ """A hidden objective surfaced: the party can now be told what it's being asked for.
+
+ Emitted by [`RevealObjective`][osrlib.crawl.commands.RevealObjective]. An
+ objective the adventure didn't mark hidden is visible from the start and is
+ never revealed.
"""
allowed_codes: ClassVar[frozenset[str]] = frozenset({"session.quest.objective_revealed"})
+ """The only message code this event uses."""
event_type: Literal["objective_revealed"] = "objective_revealed"
+ """The wire discriminator, `objective_revealed`."""
code: str = "session.quest.objective_revealed"
+ """The message code, always `session.quest.objective_revealed`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: the party is being told what to do."""
quest_id: str
+ """The quest the objective belongs to."""
quest_name: str = ""
+ """The quest's display name, resolved when the event is made so a renderer holds no adventure
+ to look it up in. It defaults empty only so an event written by an older version still parses.
+ The engine always fills it."""
objective_id: str
+ """Which objective, as the id the adventure gave it."""
name: str = ""
+ """The objective's display label: the name its author wrote, or its id when the adventure
+ wrote none. It defaults empty for the same parsing reason as `quest_name`."""
narrative: str | None = None
+ """The offer beat the author wrote for this objective, or `None`. The journal contains the same
+ line."""
class ObjectiveCompletedEvent(Event):
- """One objective of a quest is done — including one nobody had announced yet.
-
- `name` is the objective's display label — its authored name, or its id when the
- document authors none — and `quest_name` the owning quest's name, both resolved
- at emission so a renderer holds no document to look them up in. Both default
- empty only because an event logged before the fields existed still parses; the
- engine always fills them. `narrative` is the objective's authored progress beat,
- `None` when unauthored, and the journal carries the same line.
+ """One objective of a quest is done.
+
+ Emitted by [`CompleteObjective`][osrlib.crawl.commands.CompleteObjective].
+ Completing an objective also reveals it, so an objective the party finished
+ before anyone announced it arrives here first and needs no separate reveal.
"""
allowed_codes: ClassVar[frozenset[str]] = frozenset({"session.quest.objective_completed"})
+ """The only message code this event uses."""
event_type: Literal["objective_completed"] = "objective_completed"
+ """The wire discriminator, `objective_completed`."""
code: str = "session.quest.objective_completed"
+ """The message code, always `session.quest.objective_completed`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: progress belongs to the party."""
quest_id: str
+ """The quest the objective belongs to."""
quest_name: str = ""
+ """The quest's display name, resolved when the event is made. It defaults empty only so an
+ event written by an older version still parses. The engine always fills it."""
objective_id: str
+ """Which objective was completed."""
name: str = ""
+ """The objective's display label: the name its author wrote, or its id when the adventure
+ wrote none."""
narrative: str | None = None
+ """The progress beat the author wrote, or `None`. The journal contains the same line."""
class QuestCompletedEvent(Event):
- """A quest is finished, however the ruling was reached.
+ """A quest is finished.
+
+ Emitted by [`CompleteQuest`][osrlib.crawl.commands.CompleteQuest]. Whether the
+ quest is done is the referee's ruling: the engine checks that the quest is
+ active, not that every objective was completed.
- `narrative` is the quest's authored completion beat, `None` when unauthored, and
- the journal carries the same line. Rewards, when the quest pays any, land as
- their own commands and their own events after this one.
+ Rewards a quest pays out arrive after this event, as the commands the game issues
+ for them and their own events. When the quest is the one that concludes the
+ adventure, an
+ [`AdventureCompletedEvent`][osrlib.crawl.events.AdventureCompletedEvent] follows
+ in the same result.
"""
allowed_codes: ClassVar[frozenset[str]] = frozenset({"session.quest.completed"})
+ """The only message code this event uses."""
event_type: Literal["quest_completed"] = "quest_completed"
+ """The wire discriminator, `quest_completed`."""
code: str = "session.quest.completed"
+ """The message code, always `session.quest.completed`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: finishing the job is the party's news."""
quest_id: str
+ """Which quest was completed."""
name: str
+ """The quest's display name."""
narrative: str | None = None
+ """The completion beat the author wrote, or `None`. The journal contains the same line."""
class AdventureCompletedEvent(Event):
- """The adventure is over in triumph: the session is in `victory`.
-
- Follows the [`QuestCompletedEvent`][osrlib.crawl.events.QuestCompletedEvent] of
- the quest that concludes the adventure, and carries the same completion beat.
- `name` is that quest's authored display name, defaulting empty only because an
- event logged before the field existed still parses; the engine always fills it.
- The transition happens once and only from a session still in play — a party that
- finishes the job after it has already fallen completes the quest and gets no
- ending event.
+ """The adventure is over and the party won.
+
+ Emitted by [`CompleteQuest`][osrlib.crawl.commands.CompleteQuest] when the quest
+ that concludes the adventure completes, right after that quest's own
+ [`QuestCompletedEvent`][osrlib.crawl.events.QuestCompletedEvent], with the same
+ beat. The session mode becomes `victory`, which is final: play commands
+ are refused and nothing leaves it, so a front end can treat this as its closing
+ screen.
+
+ A session that has already ended doesn't get an ending twice: a party that
+ finishes the job after it has already fallen completes the quest and stays in
+ `game_over`.
"""
allowed_codes: ClassVar[frozenset[str]] = frozenset({"session.adventure.completed"})
+ """The only message code this event uses."""
event_type: Literal["adventure_completed"] = "adventure_completed"
+ """The wire discriminator, `adventure_completed`."""
code: str = "session.adventure.completed"
+ """The message code, always `session.adventure.completed`."""
visibility: Visibility = Visibility.PLAYER
+ """Player visibility: it's the party's ending."""
quest_id: str
+ """The quest that concluded the adventure."""
name: str = ""
+ """That quest's display name. It defaults empty only so an event written by an older version
+ still parses. The engine always fills it."""
narrative: str | None = None
+ """The quest's completion beat, the same line its
+ [`QuestCompletedEvent`][osrlib.crawl.events.QuestCompletedEvent] carried, or `None`."""
CRAWL_EVENT_CLASSES: tuple[type[Event], ...] = (
@@ -1044,16 +1887,43 @@ class AdventureCompletedEvent(Event):
QuestCompletedEvent,
AdventureCompletedEvent,
)
-"""Every crawl event class, in declaration order."""
+"""The event classes a session's own framework emits, in declaration order.
+
+Walk it to build a table of the crawl events, or to generate client types from their JSON
+Schemas. For the whole surface, including the rules resolutions underneath, use
+[`ALL_EVENT_CLASSES`][osrlib.crawl.events.ALL_EVENT_CLASSES].
+"""
ALL_EVENT_CLASSES: tuple[type[Event], ...] = (*KERNEL_EVENT_CLASSES, *CRAWL_EVENT_CLASSES)
-"""Every event class the library emits — kernel then crawl, in declaration order."""
+"""Every event class the library can emit: the kernel ones first, then the crawl ones.
+
+This is the registry to walk when you are generating something from the whole event surface, such
+as client types, a documentation table, or a schema bundle. Each class carries its wire name in
+`model_fields["event_type"].default` and its code set in `allowed_codes`.
+"""
AnyEvent = Annotated[
Union[*ALL_EVENT_CLASSES],
Field(discriminator="event_type"),
]
-"""Any library event, discriminated by `event_type`."""
+"""Any event the library can emit, as a union pydantic discriminates on `event_type`.
+
+Use it to type a value that holds one event of no particular class, and hand it to a
+[`TypeAdapter`][pydantic.type_adapter.TypeAdapter] to get a tagged-union JSON Schema for a client
+in another language. To parse one record, call
+[`parse_any_event`][osrlib.crawl.events.parse_any_event] instead: it skips an event type this
+version has no class for rather than raising on it.
+
+```python
+from pydantic import TypeAdapter
+
+from osrlib.crawl.events import AnyEvent
+
+schema = TypeAdapter(AnyEvent).json_schema()
+print(schema["discriminator"]["propertyName"])
+# event_type
+```
+"""
@cache
@@ -1067,21 +1937,42 @@ def _known_event_types() -> frozenset[str]:
def parse_any_event(data: Mapping[str, object]) -> Event | None:
- """Parse one serialized event, kernel or crawl, skipping unknown event types.
+ """Rebuild one serialized event, kernel or crawl, skipping types this version doesn't know.
+
+ Call it on records that came out of an event's `model_dump` or out of a save's event log, such
+ as a log you are replaying, a stream you received over a network, or a file you are analyzing.
+ A session restored by [`load_game`][osrlib.persistence.load_game] uses it for the event log it
+ reads, keeping the raw record for anything it could not parse.
- The session log's parser: an `event_type` this library doesn't know returns
- `None` instead of raising, so a newer producer's log loads under an older
- consumer (the session preserves the raw record).
+ An `event_type` this version has no class for returns `None` rather than raising, so a log
+ written by a newer engine still loads under an older one. Unknown fields on a known type are ignored
+ for the same reason. What you get back is an instance of the matching class, which you can
+ hand to [`format_message`][osrlib.messages.format_message] like any other event.
Args:
- data: A mapping previously produced by an event's `model_dump`.
+ data: One event as a mapping, from `model_dump` (in either Python or JSON mode) or from
+ parsed JSON.
Returns:
- The event, or `None` when its `event_type` is unknown.
+ The event, or `None` when its `event_type` belongs to no class in
+ [`ALL_EVENT_CLASSES`][osrlib.crawl.events.ALL_EVENT_CLASSES].
Raises:
- ContentValidationError: If the event type is known but the payload is
- malformed.
+ ContentValidationError: If the event type is known but the payload doesn't fit it, like
+ a record missing a required field. The message carries pydantic's own report.
+
+ Examples:
+ ```python
+ from osrlib.crawl.events import PartyMovedEvent, parse_any_event
+
+ event = PartyMovedEvent(code="exploration.party.moved", x=1, y=0, facing="east")
+ record = event.model_dump()
+ print(parse_any_event(record) == event)
+ # True
+
+ print(parse_any_event({"event_type": "teleported", "code": "exploration.party.teleported"}))
+ # None
+ ```
"""
from osrlib.errors import ContentValidationError
diff --git a/src/osrlib/crawl/session.py b/src/osrlib/crawl/session.py
index 10d0b14..51da661 100644
--- a/src/osrlib/crawl/session.py
+++ b/src/osrlib/crawl/session.py
@@ -1,34 +1,89 @@
-"""`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`][osrlib.crawl.session.GameSession.new] (or restore one with
-`load_game`), feed player intent to
-[`GameSession.execute`][osrlib.crawl.session.GameSession.execute] as typed
-commands from [`osrlib.crawl.commands`][osrlib.crawl.commands], and render the
-[`Event`][osrlib.core.events.Event]s that come back. The session owns what the
-kernel leaves to its caller: the [`RngStreams`][osrlib.core.rng.RngStreams]
-(master seed), the [`IdAllocator`][osrlib.core.monsters.IdAllocator], the
-[`EffectsLedger`][osrlib.core.effects.EffectsLedger], the
-[`GameClock`][osrlib.core.clock.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`][osrlib.crawl.session.GameSession.view]
-projects the state at player or referee visibility — render from views and
-events, never from raw session internals.
+"""The running game: `GameSession`, the one object a front end drives.
+
+Build a session from a [`Party`][osrlib.crawl.party.Party] and an
+[`Adventure`][osrlib.crawl.adventure.Adventure] with
+[`GameSession.new`][osrlib.crawl.session.GameSession.new], or restore one with
+[`load_game`][osrlib.persistence.load_game]. From there the loop is the same every
+time: build a command from [`osrlib.crawl.commands`][osrlib.crawl.commands], pass it
+to [`GameSession.execute`][osrlib.crawl.session.GameSession.execute], and render the
+[`CommandResult`][osrlib.crawl.commands.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`][osrlib.messages.format_message] or with a renderer of your own.
+Draw your screens from [`GameSession.view`][osrlib.crawl.session.GameSession.view]
+rather than from the session's own attributes, and save the game with
+[`save_game`][osrlib.persistence.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`][osrlib.crawl.commands.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`][osrlib.crawl.session.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:
+
+```python
+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
+```
"""
-# Command handlers live in osrlib.crawl.exploration, osrlib.crawl.encounter, and
-# osrlib.crawl.battle; each is one function (session, command) -> (rejections,
-# events) whose discipline is validation first — no draw, no mutation, no time
-# before the last rejection check. The session-owned referee and town commands
-# are handled at the bottom of this module.
+# The play commands are handled in osrlib.crawl.exploration, osrlib.crawl.encounter,
+# and osrlib.crawl.battle; each handler is one function (session, command) ->
+# (rejections, events) that validates before it does anything: no draw, no mutation,
+# no time until the last rejection check has passed. The session's own referee and
+# town handlers are at the bottom of this module.
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any, Literal, Protocol
@@ -129,130 +184,223 @@
]
WANDERING_STREAM = "wandering"
-"""Stream key for wandering-monster draws: the check die, the d20, counts, variant picks."""
+"""The name of the stream the wandering-monster procedure draws from.
+
+Pass it to [`RngStreams.get`][osrlib.core.rng.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.
+"""
ENCOUNTER_STREAM = "encounter"
-"""Stream key for encounter-procedure draws: surprise, distance, reaction, distraction."""
+"""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`][osrlib.crawl.session.WANDERING_STREAM] for how stream names are used.
+"""
EXPLORATION_STREAM = "exploration"
-"""Stream key for exploration draws: forcing, listening, searching, traps, tinder, skills."""
+"""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`][osrlib.crawl.session.WANDERING_STREAM] for how stream names are
+used.
+"""
MONSTER_ACTION_STREAM = "monster_action"
-"""Stream key for the action policy's draws — a policy change never shifts combat draws."""
+"""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.
+"""
ADJUDICATION_STREAM = "adjudication"
-"""Stream key for the referee's ad-hoc adjudication rolls — kept off the mechanical
-streams so a freeform roll never shifts a keyed mechanic's draw sequence."""
+"""The name of the stream a referee's own dice roll draws from.
+
+[`RollDice`][osrlib.crawl.commands.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.
+"""
LIGHT_EFFECT_KINDS = frozenset({"light", "continual_light"})
-"""The light-family effect kinds: torch/lantern attachments and the light spells."""
+"""The effect kinds that count as the party carrying light: a torch or lantern, and the light
+spells.
+
+[`GameSession.party_light`][osrlib.crawl.session.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.
+"""
DARKNESS_EFFECT_KINDS = frozenset({"darkness", "continual_darkness"})
-"""The darkness-family effect kinds — the printed radii swallow a marching party."""
+"""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.
+"""
class DeathRecord(BaseModel):
- """When and how a character died — the honest inputs for revival windows.
+ """When and how one character died, kept for the spells that care.
- `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.
+ 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.
"""
model_config = ConfigDict(frozen=True)
round: int
+ """Where the clock stood at the death, in rounds since the session began. Both revival windows
+ are measured from here."""
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."""
class JournalEntry(BaseModel):
- """One journal beat: the authored text and the clock position it landed at.
+ """One beat of the adventure's story, with the moment it landed.
+
+ The journal is the party's own record, and
+ [`AddJournalEntry`][osrlib.crawl.commands.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.
- 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.
+ 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.
"""
model_config = ConfigDict(frozen=True)
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: int = Field(ge=0)
+ """Where the clock stood when the entry was appended, in rounds since the session began."""
class ObjectiveState(BaseModel):
- """One objective's live state: whether the party can see it, and whether it is done.
+ """One objective's live state: whether the party can see it, and whether it's 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.
+ The session seeds one per authored objective and keeps them in
+ [`QuestState.objectives`][osrlib.crawl.session.QuestState]. 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.
"""
model_config = ConfigDict(validate_assignment=True)
revealed: bool
+ """Whether the party may be shown this objective. It starts true unless the adventure marked
+ the objective hidden, and [`RevealObjective`][osrlib.crawl.commands.RevealObjective] turns it
+ on."""
complete: bool
+ """Whether the objective is done. [`CompleteObjective`][osrlib.crawl.commands.CompleteObjective]
+ turns it on, and turns `revealed` on with it."""
class QuestState(BaseModel):
- """One quest's live state: its status, and the state of each of its objectives.
+ """One quest's live state: where it stands, and where each of its objectives stands.
- `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`][osrlib.crawl.commands.ActivateQuest].
+ 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.
- `objectives` is keyed by objective id in the order
- [`QuestSpec.objectives`][osrlib.crawl.quests.QuestSpec] authored them, so every
- walk over the block is deterministic.
+ 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`][osrlib.crawl.commands.ActivateQuest].
"""
model_config = ConfigDict(validate_assignment=True)
status: Literal["inactive", "active", "completed"]
+ """Where the quest stands. It runs `inactive` to `active` to `completed` and never goes
+ back."""
objectives: dict[str, ObjectiveState]
+ """The state of each objective, keyed by objective id, in the order
+ [`QuestSpec.objectives`][osrlib.crawl.quests.QuestSpec] authored them, so a walk over them is
+ the same every run."""
class DefeatedMonsterRecord(BaseModel):
- """One defeated monster — the XP award's input."""
+ """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`][osrlib.crawl.session.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`][osrlib.crawl.events.MonsterDefeatedEvent] reports.
+ """
model_config = ConfigDict(frozen=True)
monster_id: str
+ """The session id of the creature that was defeated."""
template_id: str
+ """What it was: a monster catalog id, or `"npc:"` for an NPC adventurer."""
outcome: str
+ """How it went out: `"slain"`, `"routed"`, or `"surrendered"`. All three count as defeated for
+ the award."""
xp: int
+ """What it's worth in experience."""
class DeprivationState(BaseModel):
- """One member's food and water deprivation counters (worse track applies)."""
+ """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](https://mmacy.github.io/osrlib-python/adaptations/),
+ the page that lists where osrlib settles an ambiguous rule or supplies a default.
+ """
model_config = ConfigDict(validate_assignment=True)
food_days: int = 0
+ """Consecutive days this member has gone without food."""
water_days: int = 0
+ """Consecutive days this member has gone without water."""
@property
def worst(self) -> int:
- """The worse track — the two deprivation tracks don't stack."""
+ """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:
+ The larger of `food_days` and `water_days`.
+ """
return max(self.food_days, self.water_days)
def _member_id(member: Character) -> str:
- """A member's session id — assigned at session entry; absence is programmer misuse."""
+ """Return a member's session id, which every member in a session has."""
if member.id is None:
raise ValueError(f"{member.name} has no session-assigned id")
return member.id
def _xp_award_events(member: Character, result: XpAwardResult) -> list[Event]:
- """`XpAwardedEvent` plus, when the award crossed a threshold, the level event.
+ """Build the award event and, when the award crossed a threshold, the level event.
- Every `apply_xp` call site reports through here, so the ordering — the level
- event immediately after the same member's award event — is structural rather
- than repeated at each surface.
+ Every `apply_xp` call site reports through here, so the ordering, the level event immediately
+ after the same member's award event, holds everywhere without each surface repeating it.
"""
events: list[Event] = [
XpAwardedEvent(
@@ -278,51 +426,118 @@ def _xp_award_events(member: Character, result: XpAwardResult) -> list[Event]:
class Listener(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.
+ """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`][osrlib.crawl.session.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 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.
+ 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`][osrlib.persistence.load_game].
+
+ Examples:
+ ```python
+ 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: 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."""
def handle(self, events: Sequence[Event], state: dict) -> tuple[list[Event], dict]:
- """React to one command's events."""
+ """React to one command's events.
+
+ Args:
+ events: The command's events so far, in order, including the ones listeners registered
+ before this one authored. Treat it as read-only.
+ state: 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.
+
+ Returns:
+ The events this listener authored itself, which the session appends to the result and
+ the log, and the state to keep. Return an empty list when the listener reacted by
+ executing commands: their events are already logged.
+ """
...
class GameSession:
- """A running game: the single entry point for command execution and views.
-
- The loop: [`execute`][osrlib.crawl.session.GameSession.execute] one command at
- a time and render its [`CommandResult`][osrlib.crawl.commands.CommandResult];
- read state through [`view`][osrlib.crawl.session.GameSession.view] (player or
- referee visibility) rather than session attributes; extend the game with
- [`register_listener`][osrlib.crawl.session.GameSession.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`][osrlib.crawl.commands.MarkTriggerFired],
- [`AddJournalEntry`][osrlib.crawl.commands.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.
+ """A game in progress: the one object you execute commands against and read state from.
+
+ Make one with [`GameSession.new`][osrlib.crawl.session.GameSession.new], or get
+ one back from [`load_game`][osrlib.persistence.load_game] or
+ [`replay_game`][osrlib.persistence.replay_game]. Then run the loop: build a
+ command, hand it to [`execute`][osrlib.crawl.session.GameSession.execute], render
+ the result, and draw from
+ [`view`][osrlib.crawl.session.GameSession.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`][osrlib.crawl.session.GameSession.register_listener]
+ and session flags.
+
+ Everything that happened is on `event_log` and every accepted command on
+ `command_log`, so [`save_game`][osrlib.persistence.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.
"""
def __init__(
@@ -334,15 +549,32 @@ def __init__(
streams: RngStreams,
master_seed: int,
) -> None:
- """Internal constructor — use [`GameSession.new`][osrlib.crawl.session.GameSession.new] or `load_game`.
+ """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`][osrlib.crawl.session.GameSession.new] to start a game, or
+ [`load_game`][osrlib.persistence.load_game] to restore one. Both come through here.
+
+ Args:
+ party: The party, in marching order, with ids already assigned.
+ adventure: The adventure content.
+ ruleset: The ruleset in play.
+ streams: The seeded random streams.
+ master_seed: The seed those streams came from, kept so a save can rebuild them.
Raises:
- ContentValidationError: If the adventure bundles monster or item ids
- that collide with the shipped catalogs or each other — the typed
- backstop for `load_game`, which trusts saved content otherwise.
+ 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.
"""
self.party = party
+ """The party, in marching order. Order decides who is in the front rank in a fight, and
+ [`ReorderParty`][osrlib.crawl.commands.ReorderParty] is the only command that changes
+ it."""
self.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."""
catalog, colliding = _effective_monsters(adventure, load_monsters())
if colliding:
raise ContentValidationError(
@@ -356,23 +588,56 @@ def __init__(
)
self._equipment_catalog = equipment
self.ruleset = ruleset
+ """The ruleset in play: the options that decide the readings osrlib leaves open, like
+ when experience is awarded. See
+ [the adaptations register](https://mmacy.github.io/osrlib-python/adaptations/), the page
+ that lists where osrlib settles an ambiguous rule or supplies a default."""
self.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."""
self.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."""
self.allocator = IdAllocator()
+ """The source of session ids. Characters, monsters, effects, and generated caches are
+ numbered from here as `-NNNN`, in order, never as random ids, so two runs of the
+ same commands name things identically."""
self.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."""
self.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."""
self.mode = SessionMode.TOWN
+ """Which [`SessionMode`][osrlib.crawl.commands.SessionMode] the session is in, and so which
+ commands it will accept. A new session starts in `town`."""
self.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."""
self.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."""
self.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."""
self.flags: dict[str, str | int | bool] = {}
+ """The session flag store: the game's own memory, written by
+ [`SetFlag`][osrlib.crawl.commands.SetFlag] and read by an adventure's gates and triggers.
+ Keys and meanings are yours to choose."""
self.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."""
self.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."""
# One state block per authored quest, in document order, seeded here so that
- # every path which builds a session — new, load, replay — starts from the
- # same block. A quest with no activation clause is a standing charge, active
- # from round 0 because there is no command channel before the first command;
- # the rest wait to be activated. Objectives start visible unless hidden.
+ # every path that builds a session, new or load or replay, starts from the
+ # same block. A quest with no activation clause is active from round 0
+ # because there is no command channel before the first command; the rest wait
+ # to be activated. Objectives start visible unless the author hid them.
self.quests: dict[str, QuestState] = {
quest.id: QuestState(
status="active" if quest.activation is None else "inactive",
@@ -383,49 +648,138 @@ def __init__(
)
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`][osrlib.crawl.session.QuestState]."""
self.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."""
self.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."""
self.command_log: list[Command] = []
+ """Every accepted command, in order. Refused commands are absent, because they changed
+ nothing. [`replay_game`][osrlib.persistence.replay_game] re-executes this list from the
+ master seed to rebuild the session."""
self.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."""
self.death_records: dict[str, DeathRecord] = {}
+ """When and how each dead party member died, keyed by character id. See
+ [`DeathRecord`][osrlib.crawl.session.DeathRecord]."""
self.defeated_monsters: list[DefeatedMonsterRecord] = []
+ """The creatures defeated since the last award, which is what the experience award adds
+ up. See [`DefeatedMonsterRecord`][osrlib.crawl.session.DefeatedMonsterRecord]."""
self.deprivation: dict[str, DeprivationState] = {}
+ """Each member's food and water counts, keyed by character id. See
+ [`DeprivationState`][osrlib.crawl.session.DeprivationState]."""
self.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."""
# Exploration bookkeeping (all serialized into saves).
self.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."""
self.turns_since_rest = 0
+ """Turns since the party last rested, which is what the fatigue cadence counts. A rest
+ resets it."""
self.wandering_counter = 0
+ """Turns since the last wandering check. Reaching the level's interval fires the check and
+ resets this."""
self.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."""
self.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."""
self.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."""
self.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."""
self.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."""
self.encounter: EncounterState | None = None
+ """The encounter under way, or `None`. It contains the groups, their distances, the
+ stance, and any chase in progress."""
self.battle: BattleState | None = None
+ """The battle under way, or `None`. It contains the round number and the per-battle
+ trackers."""
self._provisions_day = 0
- # Runtime extension points, re-registered by the game like listeners —
- # never serialized (policies are code).
+ # Runtime extension points a game re-registers like listeners. Policies are
+ # code, so they are never serialized.
self.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."""
@classmethod
def new(cls, party: Party, adventure: Adventure, *, seed: int, ruleset: Ruleset | None = None) -> GameSession:
- """Create a new session, validating the adventure and assigning member ids.
+ """Start a new game: validate the adventure, assign character ids, and open in town.
- 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).
+ This is where a front end begins. Build characters with
+ [`create_character`][osrlib.core.character.create_character], put them in a
+ [`Party`][osrlib.crawl.party.Party] in marching order, load or build an
+ [`Adventure`][osrlib.crawl.adventure.Adventure], and call this. The session comes back in
+ `town`, at round 0, ready for the first
+ [`execute`][osrlib.crawl.session.GameSession.execute]. To continue an existing game, use
+ [`load_game`][osrlib.persistence.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.
Args:
- party: The party, in marching order.
- adventure: The frozen adventure content.
- seed: The master seed.
- ruleset: The ruleset in play; defaults to a stock `Ruleset()`.
+ 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.
+ adventure: The adventure content to play.
+ seed: The master seed every random draw in the session comes from.
+ ruleset: The ruleset options in play. Defaults to a stock
+ [`Ruleset`][osrlib.core.ruleset.Ruleset].
Returns:
- The session, in town, at round 0.
+ The session, in town, at round 0, with an empty command log.
Raises:
- ContentValidationError: If the adventure has dangling references.
+ 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:
+ ```python
+ 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
+ ```
"""
validate_adventure(adventure, load_monsters(), load_equipment())
session = cls(
@@ -442,56 +796,85 @@ def new(cls, party: Party, adventure: Adventure, *, seed: int, ruleset: Ruleset
@property
def metadata(self) -> dict[str, object]:
- """The front-end handshake: the schema and engine versions."""
+ """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`][osrlib.persistence.replay_game] refuses a log that a different engine
+ version recorded.
+
+ Returns:
+ A dict with `schema_version`, the serialized-format version that saves, commands, and
+ events share, and `engine_version`, the installed library's version.
+ """
return {"schema_version": SCHEMA_VERSION, "engine_version": engine_version()}
@property
def effective_monsters(self) -> MonsterCatalog:
- """The session's monster catalog: the shipped catalog plus the adventure's bundled templates.
+ """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`][osrlib.data.load_monsters] and missing
+ the adventure's own.
- 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`][osrlib.data.load_monsters]'s cached object).
+ Returns:
+ The catalog. For an adventure that bundles nothing, it's the shipped catalog itself.
"""
return self._monster_catalog
@property
def effective_equipment(self) -> 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`][osrlib.data.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.
+ """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`][osrlib.crawl.commands.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:
+ The catalog. For an adventure that bundles nothing, it's the shipped catalog itself.
"""
return self._equipment_catalog
# ------------------------------------------------------------------ dispatch
def execute(self, command: Command) -> CommandResult:
- """Execute one command: the pure validation pre-phase, then apply and log.
+ """Execute one command and return everything it caused.
+
+ This is the loop a front end runs: build a command from
+ [`osrlib.crawl.commands`][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'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`][osrlib.crawl.events.GameOverEvent] closing its result —
- whatever killed the party, and from whichever mode. A session already in a
- terminal mode is left alone.
+ 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`][osrlib.crawl.events.GameOverEvent] closing its result. A session that has
+ already ended is left where it is.
- 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.
+ 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.
Args:
command: The command to execute.
Returns:
- The result envelope; rejected commands carry rejections and no events.
+ The result envelope. A refused command contains rejections and no events. An accepted
+ one contains events and no rejections.
+
+ Raises:
+ ValueError: If the command class has no handler, which means it was defined outside
+ osrlib rather than built from
+ [`osrlib.crawl.commands`][osrlib.crawl.commands].
Examples:
```python
@@ -506,26 +889,26 @@ def execute(self, command: Command) -> CommandResult:
from osrlib.crawl.session import GameSession
rules = Ruleset()
- rng = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM)
+ stream = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM)
hero = create_character(
name="Hild",
class_id="fighter",
alignment=Alignment.LAWFUL,
ruleset=rules,
- stream=rng,
- )
+ stream=stream,
+ ).character
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)
+ 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"))
- assert result.accepted and result.events
+ print(result.accepted, [event.code for event in result.events])
+ # True ['exploration.location.entered']
again = session.execute(EnterDungeon(dungeon_id="crypt")) # already inside
- assert not again.accepted
- assert again.rejections[0].code == "session.command.wrong_mode"
+ print(again.accepted, again.rejections[0].code)
+ # False session.command.wrong_mode
```
"""
if self.mode not in type(command).allowed_modes:
@@ -549,12 +932,12 @@ def execute(self, command: Command) -> CommandResult:
events.extend(self._end_on_party_wipe())
self.event_log.extend(events)
# Two lists with two jobs. `accumulated` is what the listeners are dispatched
- # over: this command's own events plus what earlier listeners *authored*. A
+ # over: this command's own events plus what earlier listeners authored. A
# listener's nested commands ran the whole listener loop themselves, so every
# listener has already seen those events at the nested level; putting them in
# here would deliver them to later listeners a second time. `envelope` is what
- # the caller gets back, and it does take them — a front end reads the result
- # once and wants the whole chain.
+ # the caller gets back, and it does take them, because a front end reads the
+ # result once and wants the whole chain.
accumulated = list(events)
envelope = list(events)
self._persist_sight()
@@ -562,9 +945,9 @@ def execute(self, command: Command) -> CommandResult:
mark = len(self.event_log)
emitted, state = listener.handle(tuple(accumulated), self.listener_state.get(listener.key, {}))
self.listener_state[listener.key] = state
- # Everything the listener's own commands logged while it ran — their
+ # Everything the listener's own commands logged while it ran, their
# events and any deeper listener reactions, each already in the log
- # exactly once, in log order — then the events it authored itself.
+ # exactly once and in log order, then the events it authored itself.
# (The log holds serialized entries only for a session restored from a
# save; nothing executing appends one.)
envelope.extend(entry for entry in self.event_log[mark:] if isinstance(entry, Event))
@@ -574,21 +957,20 @@ def execute(self, command: Command) -> CommandResult:
return CommandResult(accepted=True, events=tuple(envelope))
def _persist_sight(self) -> None:
- """Fold the party's current light reveal into the seen map memory.
-
- Runs after every accepted command — the one hook that covers every
- reveal-changing action uniformly (entering, moving, stairs, doors,
- lighting, placement, discovery, battle-flight relocation) — and calls
- [`mark_seen`][osrlib.crawl.dungeon.DungeonState.mark_seen] with what
- `_light_reveal` shows from the party's cell. Rejected commands change no
- state, so they never reach it.
-
- It runs *before* the listeners, so that the map a live session remembers is
- the map a replay rebuilds. A listener that relocates the party — an authored
- teleport — executes its own command, which folds its own destination in
- turn; folding this command's reveal afterwards instead would fold the
- destination's view over the move the party actually made, while a replay,
- running the same commands with no listeners, folds both in order.
+ """Fold what the party's light shows right now into the map it remembers.
+
+ Runs after every accepted command, the one place that covers every action which can change
+ what the party can see (entering, moving, stairs, doors, lighting, placement, discovery,
+ relocation after a fight), and calls
+ [`mark_seen`][osrlib.crawl.dungeon.DungeonState.mark_seen] with the cells `_light_reveal`
+ shows from where the party stands. A refused command changes nothing, so it never gets
+ here.
+
+ It runs before the listeners so that the map a live session remembers is the map a replay
+ rebuilds. A listener that moves the party, an authored teleport for instance, executes its own
+ command, which folds in that destination. Folding this command's view afterwards would
+ record the destination over the move the party actually made, while a replay, running the
+ same commands with no listeners, folds both in order.
"""
from osrlib.crawl.exploration import _light_reveal
@@ -599,10 +981,20 @@ def _persist_sight(self) -> None:
self.dungeon_state.mark_seen(dungeon_id, int(level_text), cells)
def register_listener(self, listener: Listener) -> None:
- """Register a listener; it runs after each command in registration order.
+ """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`][osrlib.crawl.session.Listener] for what one looks like and what it may do.
+
+ Register them again after [`load_game`][osrlib.persistence.load_game] or
+ [`replay_game`][osrlib.persistence.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.
Args:
- listener: The listener; its state snapshots into saves under its key.
+ 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.
"""
self.listeners.append(listener)
self.listener_state.setdefault(listener.key, {})
@@ -610,24 +1002,39 @@ def register_listener(self, listener: Listener) -> None:
# ------------------------------------------------------------------ registry
def registry(self) -> dict[str, Any]:
- """Live entities by id: party members (marching order), then monsters, then NPCs."""
+ """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:
+ A fresh dict from entity id to the live object: [`Character`][osrlib.core.character.Character]
+ for members and NPCs, [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] for
+ monsters. Editing the dict doesn't change the session. Editing the objects in it
+ does.
+ """
entities: dict[str, Any] = {_member_id(member): member for member in self.party.members}
entities.update(self.monsters)
entities.update(self.npcs)
return entities
def combatant(self, combatant_id: str) -> object | None:
- """Return the monster or NPC with `combatant_id`, or `None`.
+ """Return the monster or NPC adventurer with this id, or `None`.
- The encounter side's lookup: an
- [`EncounterGroup`][osrlib.crawl.encounter.EncounterGroup]'s combatant ids
- span monsters and NPC adventurers, and this resolves both.
+ An [`EncounterGroup`][osrlib.crawl.encounter.EncounterGroup] holds ids that can be either,
+ and this resolves both without you having to know which. For a party member, call
+ [`member`][osrlib.crawl.session.GameSession.member]. For everything at once, call
+ [`registry`][osrlib.crawl.session.GameSession.registry].
Args:
- combatant_id: The combatant's entity id.
+ combatant_id: The entity id, as it appears on an encounter group or an event.
Returns:
- The live instance, or `None` when the id is unknown.
+ The live [`MonsterInstance`][osrlib.core.monsters.MonsterInstance] or
+ [`Character`][osrlib.core.character.Character], or `None` when no live entity has that
+ id.
"""
found = self.monsters.get(combatant_id)
if found is not None:
@@ -635,22 +1042,47 @@ def combatant(self, combatant_id: str) -> object | None:
return self.npcs.get(combatant_id)
def member(self, character_id: str) -> Character:
- """Return the party member with `character_id` (see [`Party.member`][osrlib.crawl.party.Party.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`][osrlib.crawl.party.Party.member] with the session's
+ own party filled in.
+
+ Args:
+ character_id: The member's session id, like `"character-0001"`.
+
+ Returns:
+ The member, living or dead.
+
+ Raises:
+ ValueError: If no member of the party has that id.
+ """
return self.party.member(character_id)
def spawn(self, template_id: str, count: int, *, alignment: Alignment | None = None) -> list[MonsterInstance]:
- """Spawn `count` instances into the registry, ids from the session allocator.
+ """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`][osrlib.crawl.commands.SpawnMonsters], which spawns and opens the
+ encounter in one logged command.
Args:
template_id: Any id in the session's
[`effective_monsters`][osrlib.crawl.session.GameSession.effective_monsters]
- catalog — shipped (see [the monster id index][monsters-index]) or
- bundled by the adventure.
+ catalog, shipped (see [the monster id index][monsters-index]) or bundled by the
+ adventure.
count: How many to spawn.
- alignment: An alignment override from keyed content.
+ alignment: An alignment to give them instead of the template's, for keyed content
+ whose author wants, say, lawful goblins.
Returns:
- The spawned instances, in spawn order.
+ The new instances, in spawn order.
+
+ Raises:
+ ValueError: If the catalog has no such template id.
"""
template = self.effective_monsters.get(template_id)
spawned = []
@@ -668,17 +1100,28 @@ def spawn(self, template_id: str, count: int, *, alignment: Alignment | None = N
# ------------------------------------------------------------------ time
def advance_rounds(self, n: int) -> list[Event]:
- """Advance the clock `n` rounds through the ledger, translating light expiries.
+ """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`][osrlib.crawl.commands.AdvanceTime], which comes through here and
+ is logged.
- 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.
+ 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`][osrlib.crawl.events.LightEvent] beside it, naming what went out.
+
+ For whole turns with the exploration cadences (rest, wandering), call
+ [`advance_turns`][osrlib.crawl.session.GameSession.advance_turns] instead. Rounds alone
+ run no cadence.
Args:
- n: How many rounds.
+ n: How many rounds to advance.
Returns:
- The ledger's events plus the player-facing translations.
+ The ledger's own events plus the light translations and any provisions events, in the
+ order they happened.
"""
member_ids = {member.id for member in self.party.members}
light_sources = {
@@ -716,30 +1159,33 @@ def advance_rounds(self, n: int) -> list[Event]:
def advance_turns(
self, 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.
+ """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.
- 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.
+ 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 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.
+ 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.
Args:
turns: How many turns to advance.
- resting: True during a `Rest` (the cadence doesn't count, and the
- wandering chance takes the resting −1).
- field: Whether the wandering cadence runs; defaults to "exploring in a
- dungeon" (town time and travel are abstract, no wandering there).
+ resting: True while the party is resting, which keeps the rest cadence from counting
+ and lowers the wandering chance by one.
+ field: 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.
Returns:
- The events, and True when a wandering encounter interrupted the span.
+ The events, and True when a wandering encounter interrupted the span before it ran
+ out.
"""
from osrlib.crawl import exploration
@@ -749,13 +1195,13 @@ def advance_turns(
in_field = field if field is not None else self.mode is SessionMode.EXPLORING
if in_field and not self.party.living_members():
# The cadences belong to the living: a span that kills the last
- # member — a rest that starves the party out — stops at the turn
- # it happened. Out of the field there is nothing to stop, and a
- # revival window measured in elapsed time has to keep elapsing.
+ # member, a rest that starves the party out, stops at the turn it
+ # happened. Out of the field there is nothing to stop, and a revival
+ # window measured in elapsed time has to keep elapsing.
break
if in_field and not resting:
# The rest cadence is a dungeon rule ("must rest for one turn every
- # hour in the dungeon") — town time and overland travel don't accrue.
+ # hour in the dungeon"); town time and overland travel don't accrue.
self.turns_since_rest += 1
events.extend(exploration.check_fatigue(self))
if in_field:
@@ -771,15 +1217,19 @@ def advance_turns(
# ------------------------------------------------------------------ light queries
def party_light(self) -> tuple[bool, bool]:
- """Return `(lit, infravision_allowed)` for the party as a whole.
+ """Return whether the party has light, and whether infravision works.
- 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.
+ 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:
- The pair of party-level light facts.
+ A pair: whether the party is lit, and whether infravision is allowed.
"""
living_ids = [member.id for member in self.party.living_members()]
darkness = [
@@ -798,11 +1248,17 @@ def party_light(self) -> tuple[bool, bool]:
return lit, True
def bright_light(self) -> bool:
- """Whether the party carries daylight-bright light (*continual light*'s data).
+ """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](https://mmacy.github.io/osrlib-python/adaptations/), the page
+ that lists where osrlib settles an ambiguous rule or supplies a default.
- 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.
+ Returns:
+ True when a living member carries a light effect whose brightness is daylight.
"""
living_ids = {member.id for member in self.party.living_members()}
return any(
@@ -813,7 +1269,18 @@ def bright_light(self) -> bool:
)
def member_has_infravision(self, member: Character) -> bool:
- """Whether one member sees in the dark: the class tag or a spell effect."""
+ """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.
+
+ Args:
+ member: The member to test.
+
+ Returns:
+ True when that member has infravision.
+ """
if any(ability.tag == "infravision" for ability in member.definition.abilities):
return True
return any(
@@ -823,13 +1290,18 @@ def member_has_infravision(self, member: Character) -> bool:
# ------------------------------------------------------------------ the XP award
def party_valuation_cp(self) -> int:
- """The party's treasure valuation in copper pieces — the award's exact unit.
+ """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.
- 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.
+ Returns:
+ The coins, in copper, plus every valuable's listed value, converted to copper.
"""
total = 0
for member in self.party.members:
@@ -838,22 +1310,34 @@ def party_valuation_cp(self) -> int:
return total
def snapshot_treasure(self) -> None:
- """Record the departure valuation — `EnterDungeon`'s bookkeeping."""
+ """Record what the party is worth as it leaves town, for the return award.
+
+ [`EnterDungeon`][osrlib.crawl.commands.EnterDungeon] calls it, so a front end doesn't have
+ to. Call it yourself only when your game starts a delve some other way.
+ """
self.treasure_snapshot_cp = self.party_valuation_cp()
def award_adventure_xp(self) -> list[Event]:
- """The end-of-adventure award: defeated monsters plus the valuation delta.
-
- The treasure XP is the delta between the party's valuation now and the
- departure snapshot — floored to gp once from the cp total, never negative
- (clamped at zero: a party that lost money learned nothing monetarily).
- The total divides evenly among living members (floor division,
- remainder dropped — RAW divides evenly and B/X arithmetic is integer) and
- applies through `apply_xp` directly (a command whose handler executed
- further commands would double-log; `AwardXP` remains the referee and game
- surface). Dead members' recovered treasure counts toward the pool; dead
- members receive no share. A TPK never awards — no one returned. The
- defeated-monsters ledger clears and the next departure snapshots anew.
+ """Pay the end-of-adventure experience award and return its events.
+
+ [`TravelToTown`][osrlib.crawl.commands.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:
+ The [`AdventureXpAwardEvent`][osrlib.crawl.events.AdventureXpAwardEvent] and then each
+ survivor's own award and level events, or nothing at all when there's no award to
+ make.
"""
from osrlib.crawl.events import AdventureXpAwardEvent
@@ -886,10 +1370,19 @@ def award_adventure_xp(self) -> list[Event]:
return events
def award_immediate_xp(self, amount: int) -> list[Event]:
- """The `immediate` timing's division: apply one award pool now.
+ """Divide one pool of experience among the survivors now, and return its events.
- Same division and events as the return award: evenly among living
- members, floor division, remainder dropped.
+ 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`][osrlib.crawl.commands.AwardXP] instead, which is logged and replayed.
+
+ Args:
+ amount: The pool to divide. Zero or less awards nothing.
+
+ Returns:
+ Each survivor's award event and, where one levelled, the level event, or nothing when
+ there's nobody alive or the share rounds to zero.
"""
survivors = self.party.living_members()
if not survivors or amount <= 0:
@@ -908,19 +1401,18 @@ def award_immediate_xp(self, amount: int) -> list[Event]:
def _record_deaths(self, events: Sequence[Event]) -> bool:
"""Record party deaths with the clock round and the cause just resolved.
- The cause is `poison` when the killing resolution was a poison save (a
- failed death-category save immediately preceding the death) or a
- poison-delay expiry; else the nearest preceding cause-bearing event's
- kind. Only the poison/non-poison distinction is consumed (by *neutralize
- poison*).
+ The cause is `poison` when the killing resolution was a poison save (a failed
+ death-category save immediately before the death) or a poison effect running its course,
+ and otherwise the kind of the nearest preceding cause-bearing event. Only the poison and
+ non-poison distinction is consumed, by *neutralize poison*.
Args:
events: The just-executed command's events, in order.
Returns:
- True when a party member died in them — the edge the party-wipe check
- triggers on, identified by this same walk. Monsters and NPC
- adventurers carry non-member ids and never count.
+ True when a party member died in them, which is the edge the party-wipe check triggers
+ on, identified by this same walk. Monsters and NPC adventurers have ids that aren't
+ members' and never count.
"""
member_ids = {member.id for member in self.party.members}
cause = "unknown"
@@ -941,17 +1433,17 @@ def _record_deaths(self, events: Sequence[Event]) -> bool:
def _end_on_party_wipe(self) -> list[Event]:
"""End the session when the death just recorded left nobody standing.
- The one entrance to `game_over`, whatever killed the party: a lost battle,
- a trap, a fall, starvation, a poison that finished the last member under a
- referee's clock. Any open encounter or battle clears — a concluded session
- holds no live play state — and the ending is reported as one
+ The one entrance to `game_over`, whatever killed the party: a lost battle, a trap, a fall,
+ starvation, a poison that finished the last member while the referee moved the clock. Any
+ open encounter or battle clears, because a session that has ended holds no live play state,
+ and the ending is reported as one
[`GameOverEvent`][osrlib.crawl.events.GameOverEvent].
- The trigger is the death, not the state: this runs only for a command whose
- own events killed a member, so ferrying an already-fallen party to town
- (the revival flow's first step) never re-enters game-over. A terminal mode
- is left alone, so a party that dies after the adventure concluded stays in
- `victory` and a second death among the fallen ends nothing twice.
+ The trigger is the death rather than the state: this runs only for a command whose own
+ events killed a member, so carrying an already-fallen party to town, which is the first
+ step of a revival, never re-enters game over. A session already in a terminal mode is left
+ alone, so a party that dies after the adventure concluded stays in `victory` and a second
+ death among the fallen ends nothing twice.
Returns:
The ending event, or nothing when a member still lives.
@@ -966,18 +1458,28 @@ def _end_on_party_wipe(self) -> list[Event]:
# ------------------------------------------------------------------ views
def view(self, visibility: Visibility) -> PlayerView | RefereeView:
- """Return the projection for a visibility level.
+ """Return a projection of the session at one 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.
+ 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.
+
+ 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.
Args:
- visibility: `PLAYER` for the safe whitelist, `REFEREE` for everything
- but RNG internals.
+ visibility: `PLAYER` for the safe whitelist, `REFEREE` for everything but the random
+ streams' internals.
Returns:
- The frozen view.
+ A [`PlayerView`][osrlib.crawl.views.PlayerView] or a
+ [`RefereeView`][osrlib.crawl.views.RefereeView], to match the level asked for.
Examples:
```python
@@ -993,29 +1495,30 @@ def view(self, visibility: Visibility) -> PlayerView | RefereeView:
from osrlib.crawl.session import GameSession
rules = Ruleset()
- rng = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM)
+ stream = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM)
hero = create_character(
name="Hild",
class_id="fighter",
alignment=Alignment.LAWFUL,
ruleset=rules,
- stream=rng,
- )
+ stream=stream,
+ ).character
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)
+ 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)
- 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
+ print(player.mode, player.party[0].name)
+ # exploring Hild
+ # The referee sees the session flags; the player whitelist has no such field.
+ print("flags" in referee.state, "flags" in player.model_dump())
+ # True False
+ # Neither view carries the master seed.
+ print("master_seed" in referee.state)
+ # False
```
"""
from osrlib.crawl.views import build_player_view, build_referee_view
@@ -1093,7 +1596,7 @@ def _handle_record_note(session: GameSession, command: RecordNote) -> tuple[list
#
# Pure bookkeeping, all four: no draw, no clock, no interaction with the wipe check.
# Ids resolve against the adventure's quest specs and the state block seeded from
-# them, and every guard is a rejection — so the accepted log holds a state-consistent
+# them, and every guard is a rejection, so the accepted log holds a state-consistent
# sequence and a replay never meets a refusal.
@@ -1122,7 +1625,7 @@ def _unknown_quest(quest_id: str) -> tuple[list[Rejection], list[Event]]:
def _unknown_objective(quest_id: str, objective_id: str) -> tuple[list[Rejection], list[Event]]:
- """The same answer one level down: the quest is real, this objective of it is not."""
+ """The same answer one level down: the quest is real, this objective of it isn't."""
return [
Rejection(code="session.command.unknown_objective", params={"quest": quest_id, "objective": objective_id})
], []
@@ -1139,8 +1642,8 @@ def _append_quest_beat(session: GameSession, text: str) -> None:
The same [`JournalEntry`][osrlib.crawl.session.JournalEntry] construction
[`AddJournalEntry`][osrlib.crawl.commands.AddJournalEntry] makes, and no
[`JournalEntryAddedEvent`][osrlib.crawl.events.JournalEntryAddedEvent] behind it:
- the quest's own lifecycle event *is* this beat's event, and emitting both would
- show the table one line twice. An unauthored beat appends nothing.
+ the quest's own lifecycle event is this beat's event, and emitting both would show
+ the table one line twice. A beat the author didn't write appends nothing.
"""
if text:
session.journal.append(JournalEntry(text=text, rounds=session.clock.rounds))
@@ -1239,8 +1742,8 @@ def _handle_complete_quest(session: GameSession, command: CompleteQuest) -> tupl
events: list[Event] = [QuestCompletedEvent(quest_id=spec.id, name=spec.name, narrative=beat or None)]
if spec.concludes_adventure and not session.mode.terminal:
# The one entrance to victory. A concluded session holds no live play state,
- # the same rule a party wipe applies; a session that has already ended
- # advances the quest and transitions nothing.
+ # the same rule a party wipe applies; a session that has already ended advances
+ # the quest and changes no mode.
session.encounter = None
session.battle = None
session.mode = SessionMode.VICTORY
@@ -1344,7 +1847,7 @@ def _handle_set_door_state(session: GameSession, command: SetDoorState) -> tuple
if edge.kind is not EdgeKind.DOOR:
return [Rejection(code="session.command.no_door", params={"x": command.x, "y": command.y})], []
if command.open is command.wedged is command.discovered is command.unlocked is None:
- # A write with nothing to write is legal and does nothing at all — it must
+ # A write with nothing to write is legal and does nothing at all: it must
# not leave an overlay entry behind for a door nobody has touched.
return [], []
ref = edge_ref(command.dungeon_id, command.level_number, (command.x, command.y), command.direction)
@@ -1401,8 +1904,8 @@ def _handle_advance_time(session: GameSession, command: AdvanceTime) -> tuple[li
events = session.advance_rounds(command.n)
else:
turns = command.n * (1 if command.unit is TimeUnit.TURN else 144)
- # Referee time passes with full bookkeeping but no wandering cadence —
- # the referee controls encounters (pinned).
+ # Referee time passes with full bookkeeping but no wandering cadence,
+ # because the referee decides what walks in.
events, _ = session.advance_turns(turns, field=False)
events.append(TimeAdvancedEvent(n=command.n, unit=command.unit.value, rounds_total=session.clock.rounds))
return [], events
@@ -1412,7 +1915,7 @@ def _handle_roll_dice(session: GameSession, command: RollDice) -> tuple[list[Rej
from osrlib.core.dice import roll
# The command's field validator already guaranteed the expression parses, so the
- # draw happens unconditionally here — validation is the pure pre-phase, the roll is
+ # draw happens unconditionally here: validation is the pure pre-phase, the roll is
# the only side effect, and it lands on its own stream to leave keyed draws untouched.
result = roll(command.expression, session.streams.get(ADJUDICATION_STREAM))
return [], [DiceRolledEvent(expression=command.expression, total=result.total, rolls=result.rolls)]
@@ -1443,7 +1946,7 @@ def _handle_roll_dice(session: GameSession, command: RollDice) -> tuple[list[Rej
def _handlers() -> Mapping[type[Command], Any]:
- """The command-type → handler map, assembled lazily to avoid import cycles."""
+ """The command-type to handler map, assembled lazily to avoid import cycles."""
global _HANDLERS_CACHE
if _HANDLERS_CACHE is None:
from osrlib.crawl import battle, encounter, exploration