Skip to content

osrforge.contracts.stages

Stage-cache contracts: the stages/survey.json, areas.<dungeon>.<level>.json, and monsters.json wire formats.

Stage caches are cross-stage wire formats — content reads survey's cache, and the monsters and assemble stages read both — so their models live here, in the established home for anything serialized between stages. No stage module ever imports another stage module.

Pinned reading: a stage cache holds the extraction stage's validated-and-normalized output, not the model's literal wire answers. Canonical ids and keys are normalized at the source (the survey stage), with the model's original spellings preserved in source_label/name; a host that wants the literal wire answers wraps its provider in RecordingProvider.

Caches carry schema_version only, not osrforge_version — the producing package version is already recorded once per workdir in run.json, and duplicating it per cache would break golden-file byte-equality on every package version bump. Usage and model identity likewise live only in run.json: usage varies with provider retries, so putting it here would let byte-identical extractions produce differing caches.

AC_NOTATIONS module-attribute

AC_NOTATIONS: tuple[str, ...] = get_args(AcNotation)

The AcNotation wire values, for building extraction-schema enums.

AREA_KINDS module-attribute

AREA_KINDS: tuple[str, ...] = get_args(AreaKind)

The AreaKind wire values, for building extraction-schema enums.

CANONICAL_SLUG_PATTERN module-attribute

CANONICAL_SLUG_PATTERN = re.compile('^[a-z0-9]+(-[a-z0-9]+)*$')

The canonical id/key grammar.

The alphabet is the point: no / (the AreaAddress grammar forbids it), no . (so areas.<dungeon>.<level>.json filenames parse unambiguously and request tags stay within the tag charset), and lowercase (so a hand-edited overrides.yaml can't alias 4a against 4A).

CONNECTION_VIAS module-attribute

CONNECTION_VIAS: tuple[str, ...] = get_args(ConnectionVia)

The ConnectionVia wire values, for building extraction-schema enums.

DICE_PATTERN module-attribute

DICE_PATTERN = '^([1-9][0-9]{0,2})?d(2|3|4|6|8|10|12|20|100)([+-](0|[1-9][0-9]{0,5}))?$'

osrlib's dice grammar: optional 1-999 count, the allowed die sizes, optional canonical modifier.

The content-batch schema constrains count_dice with this exact pattern, so a schema-valid dice string is an osrlib-parseable dice string — a looser pattern like 2d7 or 1d6+07 would pass the cache and fail KeyedMonster validation at assembly.

DIRECTIONS module-attribute

DIRECTIONS: tuple[str, ...] = get_args(Direction)

The Direction wire values, for building extraction-schema enums.

MAP_DOORS module-attribute

MAP_DOORS: tuple[str, ...] = get_args(MapDoor)

The MapDoor wire values, for building extraction-schema enums.

AcNotation module-attribute

AcNotation = Literal['descending', 'ascending', 'dual']

How a printed armour class counts: classic descending, modern ascending, or both (5 [14]).

AreaKind module-attribute

AreaKind = Literal['room', 'corridor', 'cave', 'landmark', 'other']

A surveyed area's rough kind.

ConnectionVia module-attribute

ConnectionVia = Literal['passage', 'door', 'secret_door', 'stairs', 'trapdoor', 'chute', 'other']

A connection's stated mechanism; passage when the text names none.

secret_door is its own value, not a modifier: it drives both DoorSpec(kind="secret") and the playability lint's secret_only_access warning.

Direction module-attribute

Direction = Literal['north', 'south', 'east', 'west', 'up', 'down', 'unknown']

A connection's compass or vertical direction.

MapDoor module-attribute

MapDoor = Literal['none', 'door', 'secret_door']

A map-proposed pair's door reading; none is an assertion, not an absence.

The truth convention's own posture, inverted from prose: a proposed pair's door reading is complete, so none on a proposed pair disputes a prose-stated door, while prose with no stated door on that pair agrees silently whatever its non-door mechanism (the map's vocabulary doesn't carry mechanisms).

ResolutionMethod module-attribute

ResolutionMethod = Literal['exact', 'alias', 'fuzzy', 'llm', 'unresolved', 'override', 'custom']

How a name resolved: one of the four resolution tiers, not at all, a human override, or emission.

override and custom appear only in memory, when a monster override supersedes a cached resolution or template emission gives an unresolved name the module's own creature during assembly — the monsters.json cache is written by the monsters stage alone and never contains either.

AreaConnection

Bases: BaseModel

One extracted connection to another area.

to_key is a free string — connections may cross batches or levels; the prompt instructs canonical keys from the survey excerpt, and dangling references are assembly's job, surfacing as connection_ambiguous. to_level is the escape hatch for level-shaped targets ("stairs descend to the second level" states a level, not a keyed area); the prompt prefers the keyed target.

The failure posture, pinned: tolerate and flag, never reject. The batch JSON schema stays flat (no conditional coupling — structured-output implementations handle it badly), so a schema-valid response can carry door conditions on a non-door via, or neither target. This model accepts all of it and consumers discard-with-flag: geometry reads door conditions only when via is a door kind, and a connection with neither to_key nor to_level is skipped with connection_ambiguous:no target stated. A pydantic error mid-stage would be a crash, not defense.

to_key class-attribute instance-attribute

to_key: str | None = None

The stated target area key; None when no keyed target was stated.

direction instance-attribute

direction: Direction

The stated compass or vertical direction; unknown when unstated.

via class-attribute instance-attribute

via: ConnectionVia = 'passage'

The stated mechanism; passage when the text names none.

door_stuck class-attribute instance-attribute

door_stuck: bool = False

A stated stuck-door condition; meaningful only on a door via.

door_locked class-attribute instance-attribute

door_locked: bool = False

A stated locked-door condition; meaningful only on a door via.

to_level class-attribute instance-attribute

to_level: int | None = None

A stated target level, for level-shaped targets with no keyed area.

AreaContent

Bases: BaseModel

One keyed area's extracted content.

key instance-attribute

key: str

The canonical area key, matching the survey index.

description instance-attribute

description: str

The area's extracted keyed description.

encounters instance-attribute

encounters: tuple[AreaEncounter, ...]

The area's extracted encounters.

trap class-attribute instance-attribute

trap: str | None = None

The stated trap, verbatim; None when the area states none.

treasure instance-attribute

treasure: tuple[str, ...]

The stated treasure strings, verbatim — parsed later by assembly's treasure grammar (parse_treasure).

features instance-attribute

features: tuple[str, ...]

Notable stated features, one entry per feature.

connections instance-attribute

connections: tuple[AreaConnection, ...]

The stated connections out of this area.

source_pages instance-attribute

source_pages: tuple[int, ...]

The 1-based source pages this content was extracted from.

confidence class-attribute instance-attribute

confidence: float = Field(ge=0.0, le=1.0)

The model's self-assessed extraction confidence for this area.

AreaEncounter

Bases: BaseModel

One extracted encounter: a monster name plus what the module said about count.

The three count fields are independent optionals; the cache stores what the model said. Assembly's encounter builder owns the mapping onto osrlib's exactly-one-of rule (prefer dice when both are set; flag when neither is) and discards in memory any count_dice osrlib's dice parser still rejects, flagging the area — defense in depth behind the extraction schema's DICE_PATTERN. The mapping lives in assembly, not the monsters stage: counts are per-encounter facts, and the monsters cache is keyed per-name.

monster instance-attribute

monster: str

The monster name as extracted.

count_fixed class-attribute instance-attribute

count_fixed: int | None = Field(default=None, ge=1)

A stated fixed count ("3 orcs").

count_dice class-attribute instance-attribute

count_dice: str | None = None

A stated dice count ("1d6 goblins"), within DICE_PATTERN.

count_note class-attribute instance-attribute

count_note: str | None = None

A stated non-numeric count ("one per character"), verbatim.

LevelContent

Bases: BaseModel

The stages/areas.<dungeon>.<level>.json cache: one level's extracted areas.

schema_version class-attribute instance-attribute

schema_version: int = SCHEMA_VERSION

The stage-cache schema version this cache was written under.

dungeon_id instance-attribute

dungeon_id: str

The canonical dungeon id, matching the survey index.

level_number class-attribute instance-attribute

level_number: int = Field(ge=1)

The 1-based level number, matching the survey index.

areas instance-attribute

areas: tuple[AreaContent, ...]

The level's extracted areas, in survey order.

MapEdgeProposal

Bases: BaseModel

One map-proposed same-level adjacency between two printed keys.

a and b cache as answered, raw — endpoint resolution (exact-then-slug against the survey's keys) happens at reconcile time in deterministic code, so a reconciliation-policy fix never strands a recorded fixture.

a instance-attribute

a: str

One endpoint's printed key, as the model answered it.

b instance-attribute

b: str

The other endpoint's printed key, as the model answered it.

door class-attribute instance-attribute

door: MapDoor = 'none'

The pair's door reading — complete for a proposed pair (see MapDoor).

MapLevelReading

Bases: BaseModel

One level's map reading: the proposals, or the recorded reason there are none.

dungeon_id instance-attribute

dungeon_id: str

The canonical dungeon id, matching the survey index.

level_number class-attribute instance-attribute

level_number: int = Field(ge=1)

The 1-based level number, matching the survey index.

map_pages instance-attribute

map_pages: tuple[int, ...]

The pages actually sent — provenance, not the survey's map_pages verbatim: blanked renders are excluded, and an unread level sent none.

status instance-attribute

status: Literal['read', 'unread']

Whether this level's map was read. An unread level produces no adoptions and no disagreements at reconcile time — the prose-only path, bit for bit.

unread_reason class-attribute instance-attribute

unread_reason: str | None = None

Why the level went unread (no_map_pages, pages_blanked, no_keyed_areas, empty_reading); None exactly when status is read.

proposals class-attribute instance-attribute

proposals: tuple[MapEdgeProposal, ...] = ()

The proposed adjacencies, as answered.

entrance_key class-attribute instance-attribute

entrance_key: str | None = None

The map-labeled way in, as answered; None when the map labels none.

MapReading

Bases: BaseModel

The stages/mapread.json cache: every level's map reading.

Carries the map_reading knob echo (the StatBlocks.custom_monsters precedent) so assembly reads the cache, never the knob; under off the stage writes the echo with zero levels.

schema_version class-attribute instance-attribute

schema_version: int = SCHEMA_VERSION

The stage-cache schema version this cache was written under.

map_reading instance-attribute

map_reading: Literal['read', 'off']

The knob the stage ran under, echoed.

levels class-attribute instance-attribute

levels: tuple[MapLevelReading, ...] = ()

One reading per surveyed level, in survey order; empty under off.

MonsterResolution

Bases: BaseModel

One extracted name's resolution against the osrlib monster catalog.

template_id class-attribute instance-attribute

template_id: str | None = None

The matched catalog template id; None exactly when unresolved.

method instance-attribute

Which tier produced the match (or unresolved).

vetoed_template_id class-attribute instance-attribute

vetoed_template_id: str | None = None

The LLM- or fuzzy-tier pick the stat-block veto discarded — additive and defaulted, so caches recorded before the veto existed still load. The cache invariant template_id is None ⟺ method == "unresolved" holds; the veto record rides beside it.

veto_detail class-attribute instance-attribute

veto_detail: str | None = None

The human-readable both-readings record of the veto (orc chief → orc, printed HD 2 vs 1).

MonsterResolutions

Bases: BaseModel

The stages/monsters.json cache: every keyed encounter name's resolution.

Keys are normalized names (casefolded, internal whitespace collapsed, stripped — normalize_monster_name), sorted ascending for byte stability. Normalization is the point: modules spell the same monster "Zombies" and "zombies", one resolution must serve both, and override matching (overrides.yaml monsters: keys) normalizes the same way.

schema_version class-attribute instance-attribute

schema_version: int = SCHEMA_VERSION

The stage-cache schema version this cache was written under.

resolutions instance-attribute

resolutions: dict[str, MonsterResolution]

Normalized name → its resolution, keys sorted ascending.

RawStatBlock

Bases: BaseModel

One creature's printed stat block, transcribed system-neutrally — never converted.

Every field is the page's text or number as printed; the stat-block pass transcribes and classifies notation, nothing more — every rules judgment (AC complements, THAC0/saves/XP derivation, movement rates) lives in assembly's deterministic mapping, where it is testable and correctable. A value the pages don't print is None.

ac class-attribute instance-attribute

ac: str | None = None

The armour-class value exactly as printed ("5", "5 [14]").

ac_notation class-attribute instance-attribute

ac_notation: AcNotation | None = None

Which system the printed ac counts in.

thac0 class-attribute instance-attribute

thac0: str | None = None

The printed to-hit line, keeping its notation ("17", "19 [+0]", "+2").

hit_dice class-attribute instance-attribute

hit_dice: str | None = None

The Hit Dice line as printed ("3+1", "1-1", "½", "2d8").

class_level class-attribute instance-attribute

class_level: str | None = None

A printed class-and-level designation ("F 3", "3rd-level cleric") — the leveled-NPC shape that prints no HD line; hit_dice and this are the two printed forms of the same fact.

hp class-attribute instance-attribute

hp: int | None = Field(default=None, ge=1)

The printed hit points.

attacks class-attribute instance-attribute

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

One entry per printed attack line, counts and damage as printed ("2 claws (1d4 each)").

movement class-attribute instance-attribute

movement: str | None = None

The printed movement line ("120' (40')", "Fly 180' (60')").

saves class-attribute instance-attribute

saves: str | None = None

The printed saving-throw line, whatever its form ("D12 W13 P14 B15 S16 (2)", "save as F2").

morale class-attribute instance-attribute

morale: int | None = Field(default=None, ge=2, le=12)

The printed morale score.

alignment class-attribute instance-attribute

alignment: str | None = None

The printed alignment, verbatim.

xp class-attribute instance-attribute

xp: int | None = Field(default=None, ge=0)

The printed XP award.

number_appearing class-attribute instance-attribute

number_appearing: str | None = None

The printed number-appearing value ("1d6 (2d6)", "2-8").

special class-attribute instance-attribute

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

One entry per printed special-ability line or note.

confidence class-attribute instance-attribute

confidence: float = Field(default=1.0, ge=0.0, le=1.0)

The transcription self-assessment. Defaults to 1.0 because an override-supplied block is the human's word; the model pass always sets its own.

source_pages class-attribute instance-attribute

source_pages: tuple[int, ...] = ()

The request's page numbers the block was read from.

StatBlocks

Bases: BaseModel

The stages/statblocks.json cache: raw printed stat blocks for the unresolved names.

Assembly is driven purely by this cache's contents — it never reads the custom_monsters knob itself, only the echo stored here.

schema_version class-attribute instance-attribute

schema_version: int = SCHEMA_VERSION

The stage-cache schema version this cache was written under.

custom_monsters instance-attribute

custom_monsters: Literal['emit', 'off']

The knob the stage ran under, echoed.

blocks class-attribute instance-attribute

blocks: dict[str, RawStatBlock | None] = {}

Normalized name → its raw block, keys sorted ascending. Under emit, an entry for every name in the stat-block population — the union of unresolved, LLM-resolved, and fuzzy-resolved — a block, or an explicit null absent marker (the pass ran and found nothing). Under off, empty. Caches written before the population widened carry unresolved names only; assembly treats a missing LLM/fuzzy entry as no evidence.

SurveyArea

Bases: BaseModel

One keyed area in the survey index.

key instance-attribute

key: str

The canonical area key, unique within its level.

name instance-attribute

name: str

The area's printed name, untouched.

source_label class-attribute instance-attribute

source_label: str | None = None

The model's original key spelling, preserved wherever the canonical key differs from a non-empty printed spelling; None when they agree or the model's spelling was empty.

kind instance-attribute

kind: AreaKind

The area's rough kind.

source_pages instance-attribute

source_pages: tuple[int, ...]

The 1-based source pages the area appears on.

SurveyDungeon

Bases: BaseModel

One dungeon in the survey index.

id is the canonical slug derived from name; name is the model's printed name, untouched. There is no source_label here — the id derives from name, which is preserved on the same model, so a label could never carry information name doesn't.

id instance-attribute

id: str

The canonical dungeon id, slugged from name.

name instance-attribute

name: str

The dungeon's printed name, untouched.

levels class-attribute instance-attribute

levels: tuple[SurveyLevel, ...] = Field(min_length=1)

The dungeon's levels — at least one.

SurveyIndex

Bases: BaseModel

The stages/survey.json cache: the index that plans everything downstream.

schema_version class-attribute instance-attribute

schema_version: int = SCHEMA_VERSION

The stage-cache schema version this cache was written under.

title instance-attribute

title: str

The module's printed title; empty when unstated (assembly defaults and flags it).

description class-attribute instance-attribute

description: str = ''

The module's own pitch — an excerpt of its printed introduction or back-cover text, never invented, empty when the module has none — defaulted so survey caches recorded before the field existed still load and assemble.

hooks instance-attribute

hooks: tuple[str, ...]

The module's stated adventure hooks.

town instance-attribute

town: TownInfo

The town or home base.

dungeons instance-attribute

dungeons: tuple[SurveyDungeon, ...]

The surveyed dungeons, in document order.

monster_names instance-attribute

monster_names: tuple[str, ...]

The document-wide monster-name superset — wandering tables and townsfolk included. The narrower resolution population is encounter_names.

census_disputes class-attribute instance-attribute

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

Where the survey census disagreed with the survey — one stable human-readable entry per disagreement (census names 'crypt-of-horrors'; survey does not). Additive and defaulted (the TownInfo.services precedent), so caches recorded before the census existed still load; assembly turns each entry into a module-scope survey_disputed flag.

SurveyLevel

Bases: BaseModel

One dungeon level in the survey index.

number class-attribute instance-attribute

number: int = Field(ge=1)

The 1-based level number, unique within its dungeon.

map_pages instance-attribute

map_pages: tuple[int, ...]

The pages showing this level's map — load-bearing for the content stage's direction extraction: the map pages ride along on every content batch so the model can answer direction when the prose is silent.

areas instance-attribute

areas: tuple[SurveyArea, ...]

The level's keyed areas, in survey order.

TownInfo

Bases: BaseModel

The town or home base — never a dungeon.

name instance-attribute

name: str

The printed town name; empty when the town is genuinely unnamed (osrlib's required TownSpec.name gets a default-plus-flag at assembly).

description instance-attribute

description: str

The module's own description of the town, as extracted.

services class-attribute instance-attribute

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

The named establishments and services the module states — defaulted so survey caches recorded before the field existed still load and assemble.