Skip to content

osrforge.monsters

Stage 3: monster resolution — extracted names against osrlib's shipped catalog, plus the stat-block pass.

The resolution population is the union of encounter monster names across every stages/areas.*.json cache — the names assembly must map — not survey.monster_names: the survey list is a document-wide superset that includes wandering-table entries and townsfolk, and resolving those would burn LLM budget on names no keyed encounter references and emit flags nobody can act on.

The four resolution tiers, each consulted only when the previous one misses: normalized exact match over derived match forms, the curated alias table, stdlib fuzzy matching, and one LLM pass over the remainder. A fully deterministic resolution makes no model call.

The stat-block pass runs after the tiers, over the names still unresolved plus the LLM- and fuzzy-resolved ones, gated by the custom_monsters knob: one transcription request per name over its planned page set, cached raw in stages/statblocks.json for assembly's deterministic template mapping. The pass transcribes — every rules judgment lives in assembly, where it is testable and correctable.

The stat-block veto then runs over the LLM- and fuzzy-resolved names: a printed block whose Hit Dice count differs from the picked template's by one or more flips the pick to unresolved — the vetoed name flows into the existing custom-emission path, and the discarded pick rides the cache on vetoed_template_id/veto_detail. The veto changes the cache, not just the draft, so every downstream consumer agrees with the record. Near-miss judgment (resolution_suspect) is assembly's job — the comparator caches facts; assembly judges survivors.

MONSTERS_SYSTEM module-attribute

MONSTERS_SYSTEM = "You match monster names extracted from a tabletop adventure module against a fixed monster catalog. The user message lists the extracted names, each with its candidate templates as `id (Printed Name)` pairs.\n\nFor each name, pick the template a referee would treat as the same creature under another name — edition synonyms, spelling variants, singular versus plural. Answer null when no candidate is that creature: never pick a merely similar monster, a different creature of the same theme, or a \"close enough\" substitute. When in doubt, answer null — an unmatched name keeps the module's own printed stat block downstream, while a wrong pick silently replaces the module's creature with a different one.\n"

MONSTER_ALIASES module-attribute

MONSTER_ALIASES: dict[str, str] = {'wolf': 'normal_wolf', 'lizard men': 'lizard_man'}

The curated alias tier: normalized extracted name → catalog template id.

Entry rule: only names observed in a real module run, each entry carrying a source comment. Growing the table after a resolution fixture was recorded changes that fixture's request fingerprint whenever a new entry covers one of its names — re-recording is the remedy (the re-record rule).

STATBLOCK_PAGE_CAP module-attribute

STATBLOCK_PAGE_CAP = 8

The stat-block pass's per-name page budget: encounter pages first, then ascending text hits.

STATBLOCK_SYSTEM module-attribute

STATBLOCK_SYSTEM = 'You transcribe one creature\'s printed stat block from tabletop adventure module pages. The user message names the creature, then interleaves each page\'s extracted text (each headed by a [page N] marker) with that page\'s image — check the images too: tabular stat blocks often scramble in the extracted text.\n\nRules:\n- Transcribe, never convert. Copy each value as the page prints it, in its printed notation. Do not derive missing values, translate between editions or armour-class systems, or fill gaps from your own knowledge of any game. A value the pages don\'t print is null.\n- "found" is whether the pages print a stat block (or an inline stat line) for this creature. When false, leave every other field null or empty.\n- "ac" is the armour-class value exactly as printed; "ac_notation" classifies the printed system: "descending" (classic, lower is better), "ascending" (modern, higher is better), or "dual" (both printed, like "5 [14]").\n- A Hit Dice line as printed goes in "hit_dice" ("3+1", "1-1", "½", "2d8"); a class-and-level designation ("F 3", "3rd-level cleric", "Thief 2") goes in "class_level". Use whichever the block prints; both null means the block states neither.\n- "thac0" is the printed to-hit line, keeping its notation ("17", "19 [+0]", "+2").\n- "attacks" keeps one entry per printed attack line, with counts and damage as printed ("2 claws (1d4 each)", "1 bite (1d6 + poison)").\n- "movement" is the printed movement line ("120\' (40\')", "Fly 180\' (60\')", "30 ft.").\n- "saves" is the printed saving-throw line, whatever its form ("D12 W13 P14 B15 S16 (2)", "save as F2", "Fort +2, Ref +4, Will +1").\n- "special" keeps one entry per printed special-ability line or note.\n- "number_appearing" is the printed number-appearing value ("1d6 (2d6)", "2-8").\n- "source_pages" refer to the [page N] markers in this request, never to page numbers printed on the pages.\n- "confidence" is your self-assessment in [0, 1] of how faithfully you transcribed the block.\n'

build_monsters_request

build_monsters_request(names_with_candidates: Sequence[tuple[str, Sequence[tuple[str, str]]]]) -> ModelRequest

Build the monsters-stage request over names and their candidate lists.

Public and pure, like build_survey_request — the runner and fixture tests must build fingerprint-identical requests without duplicating prompt code. Names are sorted ascending here, so the prompt, the schema's property order, and its required list always agree regardless of the caller's order. The request carries no page images, only text — which is what makes its fixture replay-grade.

Parameters:

Name Type Description Default
names_with_candidates Sequence[tuple[str, Sequence[tuple[str, str]]]]

(normalized name, candidates) pairs; each candidate is (template_id, printed_name).

required

Returns:

Type Description
ModelRequest

The request, tagged monsters.

Raises:

Type Description
ValueError

If no names are given — the stage never builds an empty request (programmer misuse).

build_statblock_request

build_statblock_request(name: str, parts: Sequence[TextPart | ImagePart]) -> ModelRequest

Build one name's stat-block transcription request.

Public and pure, like build_monsters_request — the runner and fixture tests must build fingerprint-identical requests without duplicating prompt code. Pages ride as text and images: the one stat-block form the text layer destroys is the tabular one.

Parameters:

Name Type Description Default
name str

The normalized monster name.

required
parts Sequence[TextPart | ImagePart]

The planned pages' interleaved parts (page_request_parts).

required

Returns:

Type Description
ModelRequest

The request, tagged statblock.<slug>.

Raises:

Type Description
ValueError

If no parts are given — a name with no planned pages never builds a request (programmer misuse; the stage writes an absent marker instead).

deterministic_resolutions

deterministic_resolutions(
    names: Sequence[str], catalog: MonsterCatalog, fuzzy_threshold: float
) -> dict[str, MonsterResolution]

Run tiers 1-3 (exact, alias, fuzzy) over normalized names.

Exact matches only when the matched template is unique — the shipped catalog has no match-form collisions (tested), but an ambiguous hit falls through to the later tiers rather than picking arbitrarily. Fuzzy auto-accepts only when the best score clears fuzzy_threshold and the best template is unique at that score; a tie goes to the LLM tier rather than a coin flip.

Parameters:

Name Type Description Default
names Sequence[str]

The normalized names to resolve.

required
catalog MonsterCatalog

The osrlib monster catalog.

required
fuzzy_threshold float

The fuzzy tier's auto-accept floor.

required

Returns:

Type Description
dict[str, MonsterResolution]

Resolutions for exactly the names tiers 1-3 resolved.

encounter_names

encounter_names(levels: Sequence[LevelContent]) -> list[str]

Return the normalized resolution population: every keyed encounter name, deduplicated, sorted.

A name that normalizes to empty is excluded — the frozen stage-cache schema does not forbid an empty monster string, and there is nothing to resolve; assembly skips the same encounters with a flag. Public because it is the population rule: the stage, assembly's stale-cache check, and the extraction runner must all agree on it, or a recorded fixture's request fingerprint could drift from what the stage builds.

Parameters:

Name Type Description Default
levels Sequence[LevelContent]

The content caches to collect names from.

required

Returns:

Type Description
list[str]

The normalized names, deduplicated and ascending.

llm_candidates

llm_candidates(name: str, catalog: MonsterCatalog, top_k: int) -> tuple[tuple[str, str], ...]

Return one name's LLM-tier candidates as (template_id, printed_name) pairs.

Ordered by fuzzy score descending, ties broken by template id ascending, the top-k cut taken after that ordering — candidate identity and order are part of the frozen fixture's request fingerprint, so they must be deterministic.

Parameters:

Name Type Description Default
name str

The normalized extracted name.

required
catalog MonsterCatalog

The osrlib monster catalog.

required
top_k int

How many candidates to offer.

required

Returns:

Type Description
tuple[tuple[str, str], ...]

The top-k candidates.

monsters

monsters(workdir: Workdir, provider: ModelProvider) -> MonsterResolutions

Run stage 3: resolve every keyed encounter name; write stages/monsters.json and stages/statblocks.json.

Tiers 1-3 run first; the provider is called only if names remain — a fully deterministic resolution makes no model call. An empty name population writes an empty cache and completes. The stat-block pass then runs over the union of unresolved, LLM-resolved, and fuzzy-resolved names (under custom_monsters: emitoff is the documented opt-out of the entire custom path, and no veto fires under it): one transcription request per name over its planned page set, with a name whose plan is empty (no encounter pages, no text hits) cached as an explicit absent marker without a model call. After the pass, the deterministic stat_block_veto runs per LLM- and fuzzy-resolved name, flipping contradicted picks to unresolved before the cache writes — the cache is the record. stages/statblocks.json is rewritten on every run — the knob echo plus an entry per pass-population name; under off, the echo and an empty blocks. Both caches are single atomic artifacts; no pre-clearing is needed.

Parameters:

Name Type Description Default
workdir Workdir

A workdir whose content stage is completed.

required
provider ModelProvider

The model provider.

required

Returns:

Type Description
MonsterResolutions

The resolutions, as written to the cache.

Raises:

Type Description
ValueError

If the content stage is not completed, or the survey or any level's area cache is missing (programmer misuse).

ProviderError

On provider transport, auth, or rate-limit exhaustion.

SchemaValidationError

If the provider exhausts its schema budget.

monsters_schema

monsters_schema(names_with_candidates: Sequence[tuple[str, Sequence[tuple[str, str]]]]) -> dict[str, object]

Build the LLM tier's JSON Schema: one required property per name, enum-locked to its candidates.

The proven dynamic-schema pattern from content's key enums: the model cannot invent, misspell, or skip a name, and each answer is one of that name's candidate ids or null.

Parameters:

Name Type Description Default
names_with_candidates Sequence[tuple[str, Sequence[tuple[str, str]]]]

(normalized name, candidates) pairs, already in sorted-name order.

required

Returns:

Type Description
dict[str, object]

The request schema.

normalize_monster_name

normalize_monster_name(text: str) -> str

Normalize an extracted monster name for resolution and cache keying.

Casefold, collapse every internal whitespace run to one space, strip. Not the slug function — override keys keep their spaces ("hobgoblin chieftain").

Parameters:

Name Type Description Default
text str

The name as extracted.

required

Returns:

Type Description
str

The normalized name.

parse_statblock_response

parse_statblock_response(data: object) -> RawStatBlock | None

Parse one schema-valid stat-block response; found: false is the explicit absent marker.

Public for the same reason the request builders are: the extraction runner's recording session must produce a cache byte-identical to the stage's without duplicating parsing code.

Parameters:

Name Type Description Default
data object

The response data, already validated against statblock_schema.

required

Returns:

Type Description
RawStatBlock | None

The raw block, or None — the explicit absent marker.

printed_hd_profile

printed_hd_profile(block: RawStatBlock) -> tuple[int, int] | None

Return the printed block's structural HD (count, modifier), or None when neither form parses.

The veto's (and assembly's suspect judgment's) one reading of a printed block's Hit Dice, pinned: a printed HD line takes precedence over a class-level line when both print (mirroring map_stat_block's own order); a class-level block's level is its count (assembly's mapping), modifier 0; a fractional printed parse normalizes to osrlib's own modeling — ½ → count 1 (the catalog's die-4 shape), smaller fractions (¼, ) → count 0 — so a module printing ½ against a ½-HD template is agreement, never a Δ1 false veto.

Parameters:

Name Type Description Default
block RawStatBlock

The cached raw block.

required

Returns:

Type Description
tuple[int, int] | None

(count, modifier), or None when neither an HD line nor a

tuple[int, int] | None

class-level notation parses.

stat_block_veto

stat_block_veto(name: str, block: RawStatBlock | None, template: MonsterTemplate) -> MonsterResolution | None

Judge one LLM- or fuzzy-resolved pick against its printed block; return the vetoed entry, or None.

The veto fires exactly when (a) the printed block is usable — an AC plus an HD line or class-level notation, the same structural gate as usable_stat_block, composed here from the shared parsers because assembly sits above this stage in the import graph — (b) both the printed HD and the template HD parse structurally (printed_hd_profile against MonsterHitDice.count), and (c) the HD count differs by ≥ 1. AC never vetoes — single-value AC direction rests on a defaulted assumption (complement_derived), and a false veto spends resolution accuracy the corpus shows we don't have to spend; AC only flags, at assembly. The vetoed entry preserves the discarded pick on vetoed_template_id with the both-readings veto_detail.

Parameters:

Name Type Description Default
name str

The normalized extracted name.

required
block RawStatBlock | None

The name's cached raw block, or the absent marker.

required
template MonsterTemplate

The picked catalog template.

required

Returns:

Type Description
MonsterResolution | None

The unresolved replacement carrying the veto record, or None when

MonsterResolution | None

the pick survives.

statblock_page_plan

statblock_page_plan(
    name: str, levels: Sequence[LevelContent], page_texts: Mapping[int, str], cap: int = STATBLOCK_PAGE_CAP
) -> tuple[int, ...]

Plan one unresolved name's page set: encounter pages union text-layer hits, capped.

The union of the name's encounter source_pages (every content-cache area with an encounter normalizing to name) and every page whose extracted text layer contains the name — a deterministic local search (casefolded, whitespace collapsed), no model. The search is what catches the printed-elsewhere pattern (stat blocks in a new-monsters appendix far from the encounter); a scanned module with an empty text layer degrades to encounter pages only. The cap keeps encounter pages first, then ascending text hits.

Parameters:

Name Type Description Default
name str

The normalized monster name.

required
levels Sequence[LevelContent]

The content caches.

required
page_texts Mapping[int, str]

Page number → that page's extracted text layer.

required
cap int

The page budget.

STATBLOCK_PAGE_CAP

Returns:

Type Description
int

The planned pages, encounter pages (ascending) then text hits

...

(ascending), capped.

statblock_schema

statblock_schema() -> dict[str, object]

Build the stat-block pass's JSON Schema: the printed block, system-neutral, plus the found marker.

Returns:

Type Description
dict[str, object]

The request schema.

statblock_tag

statblock_tag(name: str) -> str

Return one name's stat-block request tag: statblock.<slug> within the tag charset.

Slugging is lossy ("orc chief" and "orc-chief" share a tag), which is harmless: fixture filenames append the request fingerprint, so identity never rests on the tag alone.

Parameters:

Name Type Description Default
name str

The normalized monster name.

required

Returns:

Type Description
str

The request tag.