osrlib.crawl.adventure
The adventure: the root document a session plays.
An Adventure is everything a game needs to run except the party
and the dice. You build the geometry in osrlib.crawl.dungeon, wrap the
levels in dungeons, add a TownSpec for the party to come home
to, and assemble the two here. Then you hand the result to
GameSession.new beside a
Party, and the session runs it.
The adventure is frozen. The session reads it and never writes back: everything play changes goes
into DungeonState instead. That split is what lets a save file
carry the overlay alone, and it is why loading a save against the same adventure gives you the same
game. Prose lives in these models rather than in events, because events carry ids and your front end
resolves them against the adventure it already has.
Besides its dungeons, an adventure contains its own content and behavior. monsters and items bundle
templates that resolve beside the shipped catalogs for the sessions that run this adventure.
triggers (TriggerSpec) are what fire when something happens,
quests (QuestSpec) are what the party is trying to accomplish, and
gates (GateSpec) sit on the doors and transitions of the geometry.
validate_adventure is what tells you the document
hangs together before anybody plays it. It follows every id in the tree to the thing it names and
raises ContentValidationError listing everything that
dangles at once. GameSession.new runs it for you, so a session can never start on broken content.
Call it yourself while you author and you find out sooner.
The long form, with a complete program you can run, is the guide Building an adventure.
Adventure
Bases: BaseModel
An adventure: one or more dungeons, the base town, and everything they need.
This is the root of the content tree and the document a session plays. Build the levels first,
wrap them in DungeonSpecs, write a
TownSpec, and assemble them here. Then call
GameSession.new with a
Party and this adventure: it validates the whole tree, assigns the
party's members their entity ids, composes the shipped catalogs with whatever this adventure
bundles, seeds a state block for each quest, and hands you a session standing in town. The
adventure itself is frozen and stays as you wrote it for the life of the session.
Everything nested here is a pydantic model with a keyword constructor, so an adventure is a tree of calls you can write in Python, generate from your own file format, or round-trip through JSON. Nothing reads files for you.
Attributes:
| Name | Type | Description |
|---|---|---|
name |
str
|
The adventure's title. |
description |
str
|
Prose for your front end. |
hooks |
tuple[str, ...]
|
Why a party might take this on. |
town |
TownSpec
|
The base town. |
dungeons |
tuple[DungeonSpec, ...]
|
The dungeons, at least one. |
monsters |
tuple[MonsterTemplate, ...]
|
Monster templates this adventure brings with it. |
items |
tuple[ItemTemplate, ...]
|
Item templates this adventure brings with it. |
triggers |
tuple[TriggerSpec, ...]
|
What fires when something happens. |
quests |
tuple[QuestSpec, ...]
|
What the party is trying to accomplish. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If two dungeons carry the same |
Examples:
from osrlib.crawl.adventure import Adventure, TownSpec, validate_adventure
from osrlib.crawl.dungeon import DungeonSpec, Edge, EdgeKind, LevelSpec
from osrlib.data import load_equipment, load_monsters
# Two cells joined west to east, entered at the west end.
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", travel_turns={"crypt": 1}),
dungeons=(crypt,),
)
validate_adventure(adventure, load_monsters(), load_equipment())
print(adventure.dungeon("crypt").level(1).entrance)
# (0, 0)
description
class-attribute
instance-attribute
description: str = ''
Prose describing the adventure, for your front end.
hooks
class-attribute
instance-attribute
The reasons a party might take this on, as free-form strings: the rumours in the tavern, the patron's offer. Nothing in the engine reads them. They are here so an adventure document contains its own pitch.
town
instance-attribute
town: TownSpec
The base town. Exactly one, and the session starts there. See
TownSpec.
dungeons
class-attribute
instance-attribute
dungeons: tuple[DungeonSpec, ...] = Field(min_length=1)
The dungeons, at least one, with unique ids. Some level of each needs an entrance, since
that is where the party arrives from town.
monsters
class-attribute
instance-attribute
monsters: tuple[MonsterTemplate, ...] = ()
MonsterTemplates this adventure brings with it,
beyond the shipped catalog. They join that catalog for the sessions that run this adventure,
everywhere the engine resolves a template id: keyed encounters,
SpawnMonsters, inline wandering tables, listen checks.
A bundled id may not collide with a shipped one or with another bundled one. A collision is a validation error rather than an override, because one id has to name one monster for the session to be able to say what it spawned. Writing a monster template is covered in the guide Authoring custom classes, spells, monsters, and items.
items
class-attribute
instance-attribute
items: tuple[ItemTemplate, ...] = ()
ItemTemplates this adventure brings with it: weapons,
armour, gear, and ammunition. They join the shipped equipment catalog for the sessions that run
this adventure, everywhere the engine resolves an authored item id, which is treasure caches,
GrantItem, and drop-pile recovery. Once an instance exists it
carries its template with it, so a bundled item gives, equips, and drops like any other.
A bundled id may not collide with the equipment catalog, the magic-item catalog, or another bundled item: one item id names one thing per session. The town shop is the one place these do not reach, because it stocks the shipped equipment lists.
triggers
class-attribute
instance-attribute
triggers: tuple[TriggerSpec, ...] = ()
The adventure's TriggerSpecs: what happens when
something happens. The tuple's order is the firing order, so triggers matching one event fire in
the order you wrote them.
Triggers do nothing on their own. A game plays them by registering an
Interpreter on its session, and an adventure with no
triggers plays the same whether or not one is registered. See the guide
Gates, triggers, and quests.
quests
class-attribute
instance-attribute
The adventure's QuestSpecs: what the party is trying to
accomplish. The session seeds one state block per quest when it is constructed, in this order,
and every walk over them follows it.
Quest ids and trigger ids live in separate state blocks, so they are separate namespaces and a quest may share an id with a trigger.
dungeon
dungeon(dungeon_id: str) -> DungeonSpec
Return the dungeon with dungeon_id.
Use this to turn a dungeon id out of a command, an event, or a party location back into the
dungeon it names, rather than searching dungeons yourself. From there,
DungeonSpec.level gets you the level.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
dungeon_id
|
str
|
The dungeon id. |
required |
Returns:
| Type | Description |
|---|---|
DungeonSpec
|
The dungeon spec. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If no dungeon has that id. The message names the id. |
quest
Return the quest with quest_id.
This is what the quest lifecycle commands resolve against, which is what makes their id domain closed: an id this cannot answer names no quest of this adventure, and the command is refused. Call it to show a quest's objectives and rewards beside the state the session keeps for it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
quest_id
|
str
|
The quest id. |
required |
Returns:
| Type | Description |
|---|---|
QuestSpec
|
The quest spec. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If no quest has that id. The message names the id. |
TownSpec
Bases: BaseModel
The base town: where the party is safe, buys gear, and comes home to.
Every adventure has exactly one town, and a session starts there. It is a marker rather than a place you can walk around: there is no grid, no rooms, and nothing to explore. What it does is anchor the rules that need somewhere safe. The party rests a full day here, buys and sells through the equipment catalog, pays a temple for healing, and, under the default XP timing, earns the treasure it carried out only once it has come back.
Attributes:
| Name | Type | Description |
|---|---|---|
name |
str
|
The town's name. |
description |
str
|
Prose for your front end. |
services |
tuple[str, ...]
|
The services the town offers. |
travel_turns |
dict[str, int]
|
The travel cost from town to each dungeon. |
Examples:
from osrlib.crawl.adventure import TownSpec
threshold = TownSpec(name="Threshold", services=("temple", "smith"), travel_turns={"crypt": 1})
print(threshold.travel_turns["crypt"])
# 1
description
class-attribute
instance-attribute
description: str = ''
Prose your front end shows while the party is in town.
services
class-attribute
instance-attribute
The services the town offers, as free-form strings like ("temple", "smith"). Nothing in the
engine reads them: the shop and the temple commands work in town regardless. They reach your
front end on the player view's town_services, which is what a town screen lists.
travel_turns
class-attribute
instance-attribute
How long it takes to get from town to each dungeon's entrance, in exploration turns, keyed by
dungeon id. EnterDungeon and
TravelToTown each advance the clock by this much, so a
far-off dungeon costs light and rations to reach and to leave. A dungeon with no entry here
travels free. Every id named here has to be a dungeon of this adventure, and
validate_adventure refuses one that is not.
validate_adventure
validate_adventure(adventure: Adventure, monsters: MonsterCatalog, equipment: EquipmentCatalog) -> None
Check that every id in an adventure names something that exists.
Call this while you author, as soon as you have an adventure to check. It follows every reference
in the tree and raises once, listing everything wrong, so you fix the whole document in one pass
instead of finding the next broken id on the next run.
GameSession.new runs it too, which is why a session can
never start on content that would fail partway through a delve. It changes nothing and returns
nothing, so a clean adventure comes back unchanged.
It checks the following, in the order the message lists them. First the bundled ids: monster ids against the shipped catalog and each other, then item ids against the equipment catalog, the shipped magic-item catalog, and each other. Then the town's travel entries naming real dungeons, and every dungeon having an entrance on some level.
Then, for each level: feature ids unique across the level and none of them the reserved id
"pile", the entrance on the grid, area ids unique, no area id colliding with a feature id (an
area trap and a feature trap are referenced the same way, so a collision would leave the found,
sprung, and removed records unable to tell the two apart), area cells on the grid, an
open-trigger room trap having a door on its area's boundary (an enter-trigger trap needs
none, and a door that starts open still counts),
keyed encounter template ids resolving and any fixed alignment being one the template allows,
feature cells on the grid with their cache item ids and magic item ids resolving, every
level-scope feature having a cell, inline wandering-table monster ids resolving, the item ids
named by has_item gates on doors and transitions resolving, and transitions standing on the grid
and landing on real cells of real levels.
Then, for each trigger: its id unique, the area, level, dungeon, item, and monster its pattern names resolving, and for each consequence the item it grants, the monster it spawns, a door actually standing at the cell a door-state consequence names, and a placement landing on the grid. Then, for each quest: its id unique, and the same checks over every clause it has (its activation, each objective's completion, each hidden objective's reveal) and every reward it pays.
One rule is about authoring rather than about a dangling id. A consequence that addresses a character has to do so through a party selector, because character ids are allocated when a session starts: a document naming one is naming something that cannot exist at the time it is read.
Magic items are the one catalog you do not pass. Adventures bundle no magic items, so validation
loads the shipped one itself with load_magic_items.
Quest ids and trigger ids are separate namespaces, so nothing here compares them and a quest may share an id with a trigger.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
adventure
|
Adventure
|
The adventure to check. |
required |
monsters
|
MonsterCatalog
|
The base monster catalog, usually |
required |
equipment
|
EquipmentCatalog
|
The base equipment catalog, usually
|
required |
Raises:
| Type | Description |
|---|---|
ContentValidationError
|
If anything is wrong. The message lists every problem found, one per line, each naming the dungeon, level, and object it sits on. |
Examples:
from osrlib.crawl.adventure import Adventure, TownSpec, validate_adventure
from osrlib.crawl.dungeon import AreaSpec, DungeonSpec, KeyedEncounter, KeyedMonster, LevelSpec
from osrlib.data import load_equipment, load_monsters
from osrlib.errors import ContentValidationError
hall = AreaSpec(
id="hall",
cells=((0, 0),),
encounter=KeyedEncounter(monsters=(KeyedMonster(template_id="grue", count_fixed=1),)),
)
level = LevelSpec(number=1, width=1, height=1, entrance=(0, 0), areas=(hall,))
broken = Adventure(
name="A First Delve",
town=TownSpec(name="Threshold"),
dungeons=(DungeonSpec(id="crypt", levels=(level,)),),
)
try:
validate_adventure(broken, load_monsters(), load_equipment())
except ContentValidationError as error:
print(error)
# adventure validation failed:
# crypt level 1: area 'hall' references unknown monster 'grue'