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