Skip to content

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 id.

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)

name instance-attribute

name: str

The adventure's title, for your front end to show.

description class-attribute instance-attribute

description: str = ''

Prose describing the adventure, for your front end.

hooks class-attribute instance-attribute

hooks: tuple[str, ...] = ()

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

quests: tuple[QuestSpec, ...] = ()

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

quest(quest_id: str) -> QuestSpec

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

name instance-attribute

name: str

The town's name, for your front end to show: "Threshold".

description class-attribute instance-attribute

description: str = ''

Prose your front end shows while the party is in town.

services class-attribute instance-attribute

services: tuple[str, ...] = ()

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

travel_turns: dict[str, int] = {}

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 load_monsters. The check composes it with the adventure's bundled templates itself, and every monster reference resolves against that union, so pass the shipped catalog rather than one you have already merged.

required
equipment EquipmentCatalog

The base equipment catalog, usually load_equipment. Composed with the adventure's bundled items the same way.

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'