Compile adventure
Turn a written module into a bundle: an on-disk Adventure spec plus a prose sidecar and a manifest that the play skill loads through session_new. Interpretation — reading a map, splitting boxed text from DM notes, choosing the nearest SRD template for a non-SRD creature — is your judgment. Everything where correctness is checkable rides the deterministic bundletool helpers, so the fidelity-critical parts never depend on model arithmetic.
Read references/bundle-format.md before writing any files — it pins the exact JSON shapes of adventure.json, prose.json, and manifest.json, and it is the contract [load_bundle][osrlib_referee_mcp.bundle.load_bundle] and validate_bundle enforce. The osrlib content model those shapes come from (AreaSpec, LevelSpec, KeyedEncounter, edge_key, …) is authoritative; read its source (osrlib.crawl.dungeon, osrlib.crawl.adventure) when a field is unclear.
This is SRD-only content: every keyed template_id and feature item_id must resolve against the stock OSE SRD catalog. Non-SRD creatures and items are reskinned to their nearest SRD template; content whose mechanics themselves are the encounter goes to the authorial escape hatch. Both are logged in the manifest.
The licensing gate — do this first
Compile a module only if it is openly licensed (OGL/CC) or original-authored. Verify the license; never assume it. This decides where the bundle lives:
- Open or original → the plugin's own
${CLAUDE_PLUGIN_ROOT}/adventures/<bundle_id>/directory, which is the repo checkout when you launch with--plugin-dir .. It commits with the repo. - Anything else (a commercial module, verbatim prose you can't relicense) → the user's game directory,
~/osr-games/bundles/<bundle_id>/(or whereverOSRLIB_REFEREE_GAME_ROOTpoints). Never commit it.
If the module's license is unclear, stop and ask the user. Do not compile-and-commit on assumption.
Stage 0 — get the module's text
Every stage below assumes you have the module as text. If the user hands you Markdown, use it. Otherwise check the shelf-of-holding index (~/repos/shelf-of-holding), which already stores one Markdown text per page for every PDF under ~/rpgbook, converted by the best converter that covered that page. Reading it costs a fraction of reading the PDF.
Find the book, then pull the pages you need:
uv run --project ${CLAUDE_PLUGIN_ROOT}/server python -m osrlib_referee_mcp.bundletool shelf-find "isle of dread"
uv run --project ${CLAUDE_PLUGIN_ROOT}/server python -m osrlib_referee_mcp.bundletool shelf-text bf860050 --pages 4-16 --out <scratch>/x01.md
shelf-find prints a sha prefix, the text coverage, and the path of every match. Pass the sha prefix to shelf-text: a title fragment often matches several cuts of one product, and the OSE, 5e, and Shadowdark editions of a module are three separate books with three separate keys. shelf-text writes Markdown with an HTML comment before each page naming the PDF page and the converter behind it, and it reports on stderr any requested page the index has no text for. Read the file you wrote rather than paging the PDF.
Four things to get right:
--pagestakes PDF page indexes, not the page numbers printed on the page. Front matter offsets them: X1's printed page 24 is PDF page 25. Establish the offset once against a known page before you trust a range.- Maps do not survive OCR. A map page comes back as its labels in reading order with the geometry gone, so stage 1's grid never comes from this text. Read map pages from the PDF itself —
Readthe file withpages: "12"— and lay out the grid from the image. - Some pages read badly and look fine. A book whose text layer is junk, or a table MinerU merged across a page break, yields plausible-looking wrong text. When an area's numbers or a table's rows look off, check that page against the PDF.
- The index is optional. With no index on the machine, or a module that is not on the shelf, both commands say so and exit 1. Compile from whatever text the user supplies.
The shelf is mostly commercial product. A module being easy to read here says nothing about where its bundle may live — the licensing gate above decides that, unchanged.
These commands only ever read the index, and never through the shelf CLI, whose scan, triage, convert, and gold subcommands write. The store is live, and a bulk conversion run may be writing to it. When one is running, point SHELF_DB at one of shelf-of-holding's dated snapshots instead:
export SHELF_DB=~/.shelf-snapshots/shelf-20260907T1625.db
The compile stages
Work one dungeon level at a time. For each:
Geometry → grid + edges. Lay the module's map onto the 10′ cell grid (
xeast,ysouth from the northwest corner). Walls are the default — you declare only the passages: every open edge and every door is an explicit entry inedges, keyed by the canonical edge key. Compute every edge key with the helper, never by hand:uv run --project ${CLAUDE_PLUGIN_ROOT}/server python -m osrlib_referee_mcp.bundletool edge-key 3 2 eastA non-grid or organic map is approximated to the grid — record each such approximation in
manifest.approximations.geometry. Set the levelentranceto the cell town-travel andEnterDungeonland on.Prose → sidecar. Split each area's authored text into
read_aloud(the module's boxed/read-aloud text, delivered verbatim in play) andreferee_notes(DM tactics, development, secrets), keyed by theAreaSpec.idyou assigned in step 1. The reserved key"town"carries the town's scene. Prose sets the scene; it never renames a reskinned creature — by the play constitution the referee describes a monster by appearance, not type-name, so an evocative area description carries the beat without ever speaking either the source name or the SRD name.Keyed content → specs, with the reskin stage. Encounters become
KeyedEncounter/KeyedMonster; trapsTrapSpec/TrapEffect; treasureFeatureSpec/AreaTreasureSpec.- A creature already in the SRD → its stock
template_id. - A non-SRD creature → reskin it: pick the nearest SRD template by mechanical profile (HD, AC, attacks/damage, movement, morale, special qualities), emit that stock
template_id, and record{source_name, template_id, area_id}inmanifest.approximations.reskins. The creature then plays as its SRD self; the area prose sets the scene without naming it. - A creature no SRD template meaningfully approximates (its unique mechanic is the fight), a true custom magic item, a bespoke trap/puzzle/trick, or NPC roleplay with no command → do not fake it. Flag it for the authorial escape hatch (the play skill runs it live via
SpawnMonstersof an SRD stand-in, aRollDice-adjudicated effect,SetFlag, etc.) and record it inmanifest.approximations.escape_hatch.
- A creature already in the SRD → its stock
Write the three files into the bundle directory, exactly as references/bundle-format.md specifies. There is no
catalog.json— an SRD-only bundle injects nothing.
The review gates — a bundle is not done until both pass
<bundle_dir> below is the directory the licensing gate sent the bundle to. Pass the path, not a bare id: validate and render-map both take a directory, and the session's working directory is the player's game directory rather than the checkout.
validate_bundle— the deterministic gate. It reconstructs theAdventure, resolves every keyed/feature/wandering id against the stock catalog, and checks edge-key integrity:uv run --project ${CLAUDE_PLUGIN_ROOT}/server python -m osrlib_referee_mcp.bundletool validate <bundle_dir>Fix every error it reports (an unresolved
template_idusually means a reskin named a template that doesn't exist; a "boundary edge" or "not a canonical key" means an edge-key mistake). Do not proceed until it printsOK.Visual map-diff — the human gate. Render the compiled map and compare it, cell for cell, against the source module's map:
uv run --project ${CLAUDE_PLUGIN_ROOT}/server python -m osrlib_referee_mcp.bundletool render-map <bundle_dir>Present the render to the user alongside the source map. Geometry is the likeliest place fidelity slips; this gate catches a transposed passage or a missing door that
validate_bundlecannot.
The manifest is the audit log — be honest
manifest.approximations is the record of everything the compile approximated. It is not optional bookkeeping; it is how a reviewer sees the fidelity budget instead of discovering it in play. Every reskin, every geometry approximation, every escape-hatch beat gets an entry. The reskin's cost — an evocatively-named creature loses its name and plays as a plain SRD nearest-likeness (a "barrow wight" statted as a skeleton has no energy drain) — is stated in the log, never smoothed over. When you are unsure a reskin is close enough, say so in the entry and let the user decide at the map-diff gate whether it should be an escape-hatch beat instead.
Handing off
When both gates pass, tell the user the bundle_id, where the bundle lives, and a one-line summary of the approximation log (how many reskins, geometry notes, escape-hatch beats). They start it with the play skill: session_new("<bundle_id>").