feat(backend): 5etools and Dungeon Master's Vault (.orcbrew) adapters for content packs (#553) #560

Merged
claude-bot merged 2 commits from feat/553-fivetools-orcbrew-adapters into main 2026-09-08 07:37:39 +00:00
Contributor

Phase 1 of #553, lane 3 of the content-packs spec. Stacked on #559 (the Foundry adapter); merge that first and this PR's diff shrinks to its own two commits.

What it reads. 5etools homebrew JSON (_meta + per-kind arrays: monster, spell, item, and the rest) and Dungeon Master's Vault .orcbrew (EDN). Both map to the shared kinds, render 5etools {@tag ...} markup and orcbrew's nested maps to Markdown through the shared sanitiser, and record no licence the file does not declare. edn-format==0.8.0 is the one new dependency, import-guarded so a missing package becomes an AdapterError rather than a crash.

Pipeline change. Adapter warnings now reach the importer: import_service.normalise drains the record iterator and then merges meta.extra["warnings"] into the outcome through the existing _warn, so the cap and count behave like every other warning. Three tests cover it, including an end-to-end .orcbrew whose armour class is "as tough as a door" and imports anyway with a warning.

Bug fixed on the way. .orcbrew files saved on Windows (CRLF) were rejected as "not EDN" because edn_format's lexer refuses \r. read() normalises line endings first; regression test included.

Also. The formats-endpoint test is now an invariant over the registry rather than a list of names, so later adapters need not touch it; test_reference_detect.py gains a case per adapter so all four are pinned in one place; docs (CONTENT-PACKS.md, API.md, changelog) describe the Foundry reader too, since #559 carried no docs.

Tests: 204 across the adapter, detection, import and search suites; 53 more in export, task, URL-fetch, migration and admin. Fixtures are invented end to end (Hollow Fen, Thornhollow Reaches), nothing from 5etools' data or a real .orcbrew.

🤖 Generated with Claude Code

Phase 1 of #553, lane 3 of the [content-packs spec](docs/design/content-packs-spec.md). Stacked on #559 (the Foundry adapter); merge that first and this PR's diff shrinks to its own two commits. **What it reads.** 5etools homebrew JSON (`_meta` + per-kind arrays: monster, spell, item, and the rest) and Dungeon Master's Vault `.orcbrew` (EDN). Both map to the shared kinds, render 5etools `{@tag ...}` markup and orcbrew's nested maps to Markdown through the shared sanitiser, and record no licence the file does not declare. `edn-format==0.8.0` is the one new dependency, import-guarded so a missing package becomes an `AdapterError` rather than a crash. **Pipeline change.** Adapter warnings now reach the importer: `import_service.normalise` drains the record iterator and then merges `meta.extra["warnings"]` into the outcome through the existing `_warn`, so the cap and count behave like every other warning. Three tests cover it, including an end-to-end `.orcbrew` whose armour class is `"as tough as a door"` and imports anyway with a warning. **Bug fixed on the way.** `.orcbrew` files saved on Windows (CRLF) were rejected as "not EDN" because `edn_format`'s lexer refuses `\r`. `read()` normalises line endings first; regression test included. **Also.** The formats-endpoint test is now an invariant over the registry rather than a list of names, so later adapters need not touch it; `test_reference_detect.py` gains a case per adapter so all four are pinned in one place; docs (`CONTENT-PACKS.md`, `API.md`, changelog) describe the Foundry reader too, since #559 carried no docs. Tests: 204 across the adapter, detection, import and search suites; 53 more in export, task, URL-fetch, migration and admin. Fixtures are invented end to end (Hollow Fen, Thornhollow Reaches), nothing from 5etools' data or a real `.orcbrew`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Lane 2 of the content-packs work: read what a Foundry user actually has on
disk — a folder or a zip of document JSON, one document per file, or a single
JSON array of the same documents — and turn it into NormalizedRecords.

`adapters/base.py` is the spec's §2 contract verbatim (lane 1 writes the same
bytes, so the two merge cleanly). `adapters/foundry.py` owns the container,
the identity and the prose; `adapters/foundry_maps/{pf2e,dnd5e}.py` own the
meaning of the `system` block, and `generic.py` catches every other system as
a text record with the raw block kept in `extra`.

Three decisions worth the reader's time:

- **Two passes, bounded memory.** PackMeta must carry the game system and the
  pack's licence, which are facts about the whole pack, while records are
  yielded lazily. So `read` scans once to vote on the system and count
  licences, discarding each document as it goes, then returns a generator that
  walks them again. A zip is read entry by entry and an entry whose declared
  size exceeds the per-file ceiling is refused before it is decompressed.

- **Slugs come from the name, not the file.** A Foundry `_id` is a random
  16-character handle and the file stem only exists for the folder and zip
  shapes. Name-derived slugs are the only rule that gives identical records
  whether a pack arrives as a folder, a zip or one array — asserted by the
  tests, and the property that makes re-import replace rather than duplicate.

- **Stats are mapped faithfully or not at all.** Values are keyed by the real
  `app/game_systems/*/schema_v1.json` field keys and coerced to the schema's
  types; anything unreadable is dropped rather than rounded or guessed. The
  tests validate both creature fixtures against the live active schema, so a
  map that drifts from the schema fails here instead of importing silently
  with `stats = None`.

`_html_to_md` is a local nh3 + markdownify pass so this lane does not depend
on lane 1's file existing; swap it for `app.reference.sanitize` at merge.

Every fixture is invented — creatures, rule text, numbers and book titles are
written for the tests, per the fixtures policy in CLAUDE.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The pf2e system does not keep `publication` in one place. Items carry it at
`system.publication`, which is what the adapter read; actors carry it at
`system.details.publication`, which it did not. Every creature in a Monster
Core-style pack therefore imported with `licence = None` — the one field a
content pack cannot afford to lose, since the export path decides what may be
shared on from exactly that value.

`foundry_maps.pf2e.licence_block` now looks in both places, plus the
pre-remaster `source` spelling of each for older exports, and the generic
fallback map looks in the same places for the same reason: a document from an
unrecognised system may still declare a licence. `_detect_system` counts
`details.publication` as pf2e evidence too, so a bare actor with no other
pf2e-shaped field is still recognised.

Fixture `cairnlight-warden.json` is an invented pf2e creature that declares
ORC only under `details`, with tests for the licence, the system evidence and
the stat mapping.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Joins lane 2 to the foundation now that it exists.

- Registered: `foundry.adapter` is a module-level instance, imported and
  `register()`ed in `adapters/__init__.py` under the Registrations banner.
  `detect` therefore routes a Foundry zip here rather than to the native
  reader asked before it, which is asserted.
- Sanitising: the local nh3 + markdownify pass is gone in favour of
  `app.reference.sanitize.sanitize_markdown` — not `to_markdown`, which is
  markdownify alone with no allowlist in front of it. The Foundry-specific
  enricher pass still runs first, on the raw HTML, because markdownify
  escapes the brackets it matches on. Summaries use `sanitize_summary` for
  the cap but still take only the opening paragraph, so a creature's summary
  is its description rather than the first 200 characters of its strikes.
- Slugs come from `app.reference.slugs.slugify`, as that module asks every
  adapter to do, so the same creature from two formats lands on one slug.
- Warnings: `PackMeta.extra["warnings"]` is a list the record generator holds
  and appends to, for the things this adapter would otherwise swallow — an
  unidentifiable system, a pack that mixes systems, a hint that contradicts
  the documents, a document with no name, a journal page that is not an
  object. Bounded at 50, well under the pipeline's own cap.
- `foundry_maps/__init__.py` added now that the package sits inside regular
  packages rather than being reached as a namespace package.

`test_the_formats_endpoint_lists_the_registered_adapters` now expects
questboard *and* foundry, in DETECT_ORDER: registering a format is exactly
what changes that endpoint's answer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
fix(backend): a bare JSON array is only native if it holds native records (#553)
All checks were successful
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m22s
CI / Bot/backend version sync (pull_request) Successful in 24s
CI / Backend lint (ruff) (pull_request) Successful in 44s
CI / Bot tests and audit (pull_request) Successful in 2m30s
CI / Frontend tests, audit, and build (pull_request) Successful in 2m58s
CI / Docker image build (pull_request) Successful in 4m56s
CI / Backend migration, tests, and audit (pull_request) Successful in 10m58s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 14m29s
9deacde6f2
`questboard.sniff` claimed any `.json` whose head started with `[`, and it is
asked first. A Foundry export — an array of documents, one of the shapes the
Foundry adapter exists to read — was therefore claimed by the native reader
and imported as five empty `other` records: no body, no stats, no licence, no
error, nothing in the warnings. Open5e's per-page array will land the same way
when that lane arrives.

The bare-array branch now looks at the array's first element. Three answers,
because the evidence comes in three strengths:

- The element arrived whole: decide on its keys. Ours has `kind` and `name`;
  a Foundry document has `_id`; an Open5e page has a name and no kind.
- The element is truncated — the usual case, since `sniff` is given 512 bytes
  and a stat block is longer than that. Its keys cannot be enumerated, so a
  missing `kind` proves nothing. Only a positive signal decides: `_id` without
  `kind` is someone else's document, and anything else stays with the native
  reader, which is what keeps `[{"name": "Truncated"` answering "not valid
  JSON" rather than "could not tell what format".
- An empty array is nobody's format in particular, so it stays here too and
  the importer is told it contains no records.

`DETECT_ORDER` is unchanged. The Foundry adapter now also judges an extension-
less filename on shape, as the native one already did, so a Foundry array
fetched from a URL with no extension is detected rather than refused.

One existing expectation moved: `[{"level": 3}]` has neither kind nor name, so
nothing detects it and the answer is now "Could not tell what format". The
reader's own "record 1 has no name" is still covered, by a test that declares
the format instead of relying on detection.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Lane 3 of the content packs work: two importers for formats people already
have homebrew in, both producing the normalised record shape from the spec.

`fivetools.py` reads a 5etools homebrew JSON file — a `_meta` block plus one
array per content type. Two parts carry the weight. `render_tags` turns
5etools' inline markup (`{@hit 7}`, `{@damage 2d8+4}`, `{@dc 14}`,
`{@condition grappled}`) into plain text rather than dropping it, because the
numbers in those tags are usually the only place the number appears; unknown
tags keep their text argument, so a homebrew author's invention degrades to
readable prose instead of vanishing. `entries_to_markdown` flattens the
recursive `entries` structure — nests, lists, tables, insets — into Markdown.
Monsters map onto the real dnd5e stat schema (AC, HP, speed, CR, the six
scores, size, creature type); everything the schema has no room for, `senses`
and the rest, is kept in `extra` for export.

`orcbrew.py` reads a Dungeon Master's Vault / OrcPub2 export, which is EDN.
The format has no schema and no version marker, so the only things that fail
the file are "not EDN" and "not a map"; an unknown content type, an entity
that is not a map, or `:armor-class "as tough as a door"` each cost one field
and one warning, never the import. A pack that loads 340 of its 341 monsters
with a warning is a better answer for the person who uploaded it than a
rejection. `:hit-points` prefers `:mean` and computes the dice average only
when the export omits it; `:challenge 1/4` survives as the string "1/4" and
the number 0.25. The pack's identity comes from the modal `:option-pack`
across its entities, because there is no header to read it from.

`base.py` is the spec's section 2 code, byte for byte, so the foundation
lane's copy merges as a no-op. `_text.py` is a stopgap HTML/Markdown cleaner
for these two adapters only; it is replaced by `app.reference.sanitize` when
lane 1 lands. Neither `__init__.py` is created here — the registry lines are
the foundation lane's to add.

Fixtures are invented end to end, per the fixtures policy: invented sources,
authors, creatures, spells, items and numbers, and the EDN was written by
hand. Nothing is taken from a published book, from 5etools' data, or from a
real `.orcbrew`.

64 tests, covering: sniffing in both directions (neither adapter claims the
other's file, and neither claims a `.qbpack`); every array and content type
mapping to its kind; the monster stat maps validating against the real dnd5e
schema through `validate_stats_values`; the tag renderer; and malformed input
raising `AdapterError` with a message a person can act on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
feat(backend): register the 5etools and .orcbrew adapters with the import pipeline (#553)
All checks were successful
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m16s
CI / Bot/backend version sync (pull_request) Successful in 25s
CI / Backend lint (ruff) (pull_request) Successful in 40s
CI / Bot tests and audit (pull_request) Successful in 2m13s
CI / Frontend tests, audit, and build (pull_request) Successful in 2m28s
CI / Docker image build (pull_request) Successful in 4m33s
CI / Backend migration, tests, and audit (pull_request) Successful in 11m16s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 14m55s
6530eaaacc
Wires lane 3's two readers into the registry and the pipeline, alongside the
native and Foundry readers that landed first.

**Registered.** `fivetools` and `orcbrew` join `questboard` and `foundry` in
`adapters/__init__`, each exposing a module-level `adapter` instance the way
the others do. One import and one `register` per line, in `DETECT_ORDER`, so
the file reads the way detection runs — though the order of the lines decides
nothing, since `detect` and `list_formats` both walk `DETECT_ORDER`. That is
what lets the remaining lane add its line without reasoning about the lines
around it.

The formats-endpoint test is rewritten against the registry instead of against
a list of names, so no further lane has to edit it, and so what it checks is
the wiring rather than today's roster: every registered adapter is served (an
adapter registered under an id `DETECT_ORDER` does not mention would otherwise
vanish from the endpoint in silence), in `DETECT_ORDER` and not import order,
with the name and extensions its adapter declares. The one thing still spelled
out is that the native reader comes first, because `detect` takes the first yes
and whichever adapter leads decides what the rest ever see.

`tests/test_reference_detect.py` gains a case per lane-3 format, so all four
adapters are pinned in the one file that owns the boundary question.

**One sanitiser, not two.** `adapters/_text.py` — the stopgap cleaner lane 3
carried while the foundation was unmerged — is deleted, and both adapters use
`app.reference.sanitize` and `app.reference.slugs`. `sanitize_markdown` rather
than `to_markdown`: the latter is only the markdownify half, so on its own it
would run a converter over unsanitised HTML, which is the hole `nh3.clean`
exists to close, and would mangle the plain prose that most of a 5etools entry
or an `.orcbrew` description actually is. Each adapter keeps three-line
`_markdown`/`_plain`/`_summary` wrappers so a caller never has to think about
coercing a keyword or an int, and so the 200-character cap that comes with
`sanitize_summary` is documented where it bites.

Fragments are cleaned as they are read rather than the assembled body being
cleaned at the end, so what the pipeline receives is already markup-free and
its own sanitising pass is a no-op instead of an HTML converter running over
Markdown the adapter wrote.

**Adapter warnings now reach the importer.** `read()` returns only a meta and
an iterator, so an adapter reports what it could not map by appending to
`meta.extra["warnings"]` — the same list object the record iterator appends to
as it is consumed. `normalise` folds that list into the outcome's warnings
after the walk, de-duplicated and under `MAX_WARNINGS`. Without this the
pipeline could only ever warn about what it saw after the fact: a bad kind, a
duplicate slug. Only the adapter knows that a monster's armour class was the
words "as tough as a door", and a GM who is never told will spend the session
wondering where the AC went.

**Fixed: `.orcbrew` files with CRLF failed as "not EDN".** A carriage return is
legal EDN whitespace but `edn_format`'s lexer rejects it outright, so a file
opened and saved on Windows — which is most of them — was refused for a reason
that had nothing to do with its contents. Line endings are normalised before
the parse, with a regression test. The bug surfaced because git checked the
fixture out with CRLF, which is exactly how a self-hoster would have met it.

Docs: `docs/CONTENT-PACKS.md` gains a section per non-native format — what maps
to what, how each pack is named, where each licence is read from and why
neither 5e homebrew format records one the file does not declare —
`docs/API.md`'s formats example lists all four, and the changelog describes the
Foundry, 5etools and `.orcbrew` readers for the people who will use them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude-bot scheduled this pull request to auto merge when all checks succeed 2026-09-08 07:29:27 +00:00
claude-bot deleted branch feat/553-fivetools-orcbrew-adapters 2026-09-08 07:37:40 +00:00
Sign in to join this conversation.
No description provided.