From 5c18edc4e141c61caaee11cb2e32a0da37bb40c5 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Sun, 13 Sep 2026 22:31:31 -0700 Subject: [PATCH 1/2] Document the session and the crawl event catalog for the API reference GameSession is the entry point of the library for anyone running a game, and its docstrings now carry the whole path a front end takes: build a session with new, execute a typed command, read the result, render the events, read state through a view, and save. Every public method says what it does, what you need first, what to call next, and what to use instead, with runnable examples on new, execute, view, and the listener protocol. Every crawl event class says when it fires, which command emits it, what each of its message codes means, and who may see it, and every field of every event has an attribute docstring, so the generated event pages and the API reference describe an event without the reader opening the source. Docstrings and comments only: no signature, default, field, or behavior changed. Claude-Session: https://claude.ai/code/session_01GL26QnA6dCrvUc3WmhzFSa --- src/osrlib/crawl/events.py | 1319 +++++++++++++++++++++++++++++------ src/osrlib/crawl/session.py | 1103 +++++++++++++++++++++-------- 2 files changed, 1907 insertions(+), 515 deletions(-) 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..f171791 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,136 @@ 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, like failing to + force a door. Noise raises the next check's chance by one and then clears.""" 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 +794,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 +887,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 +930,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 +943,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 +955,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 +979,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 +1000,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 +1040,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 +1098,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 +1157,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 +1193,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 +1215,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 +1246,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 +1267,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 +1288,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 +1308,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 +1368,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 +1399,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 +1431,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 +1456,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 +1493,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 +1594,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 +1623,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 +1640,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 +1740,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 +1845,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 +1902,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 +1913,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 +1944,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 From e4fb20134979b12870a2eb1371305ec6c40dd9f9 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Sun, 13 Sep 2026 22:38:29 -0700 Subject: [PATCH 2/2] Say what actually sets the noise flag on a forced door The noise_since_check docstring said failing to force a door sets the flag, but _handle_force_door sets it before the roll, so any attempt past the gate sets it whether the door opens or not. A failed attempt is what alerts the area beyond, which is the part that denies the party surprise there. Claude-Session: https://claude.ai/code/session_01GL26QnA6dCrvUc3WmhzFSa --- src/osrlib/crawl/session.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/osrlib/crawl/session.py b/src/osrlib/crawl/session.py index f171791..51da661 100644 --- a/src/osrlib/crawl/session.py +++ b/src/osrlib/crawl/session.py @@ -688,8 +688,10 @@ def __init__( """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, like failing to - force a door. Noise raises the next check's chance by one and then clears.""" + """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."""