feat(backend): Foundry VTT adapter for content packs (#553) #559

Merged
claude-bot merged 4 commits from feat/553-foundry-adapter into main 2026-09-08 07:28:36 +00:00
Contributor

Phase 1 of #553, lane 2 of the content-packs spec: the Foundry VTT document adapter, on top of the foundation merged in #557.

What it reads. A Foundry document export — a bare JSON array, a single document, a .db/.json per-document folder zipped up, or a system compendium zip — for the pf2e and dnd5e systems, with a generic fallback that keeps name, body and licence for any other Foundry system. Headline mappings live in foundry_maps/{pf2e,dnd5e,generic,common}.py; the pf2e map reads the ORC/OGL publication block from all four places Foundry keeps it (system.publication, system.details.publication, system.source, system.details.source), since actors and items disagree.

Conventions taken from the foundation. Registered via register(foundry.adapter); Foundry enrichers (@UUID[...]{Label}, @Check[...]) are stripped before the shared sanitize_markdown, which would otherwise escape them; adapter warnings ride on meta.extra["warnings"].

A detection bug found on the way. questboard.sniff claimed any .json that opened with [, and it is asked first, so a Foundry array imported as empty other records with no error anywhere. The bare-array branch now looks at the first element (whole or truncated — sniff only sees 512 bytes) and yields to the other adapters when it is plainly not a native record. Open5e's per-page arrays (lane 4) depend on this.

Tests: 134 in the adapter, detection and import suites; 187 across every content-pack test on the branch. All fixtures are invented (the "Bramblewisp Stalker" and friends), nothing from a published book.

🤖 Generated with Claude Code

Phase 1 of #553, lane 2 of the [content-packs spec](docs/design/content-packs-spec.md): the Foundry VTT document adapter, on top of the foundation merged in #557. **What it reads.** A Foundry document export — a bare JSON array, a single document, a `.db`/`.json` per-document folder zipped up, or a system compendium zip — for the pf2e and dnd5e systems, with a generic fallback that keeps name, body and licence for any other Foundry system. Headline mappings live in `foundry_maps/{pf2e,dnd5e,generic,common}.py`; the pf2e map reads the ORC/OGL publication block from all four places Foundry keeps it (`system.publication`, `system.details.publication`, `system.source`, `system.details.source`), since actors and items disagree. **Conventions taken from the foundation.** Registered via `register(foundry.adapter)`; Foundry enrichers (`@UUID[...]{Label}`, `@Check[...]`) are stripped *before* the shared `sanitize_markdown`, which would otherwise escape them; adapter warnings ride on `meta.extra["warnings"]`. **A detection bug found on the way.** `questboard.sniff` claimed any `.json` that opened with `[`, and it is asked first, so a Foundry array imported as empty `other` records with no error anywhere. The bare-array branch now looks at the first element (whole or truncated — `sniff` only sees 512 bytes) and yields to the other adapters when it is plainly not a native record. Open5e's per-page arrays (lane 4) depend on this. Tests: 134 in the adapter, detection and import suites; 187 across every content-pack test on the branch. All fixtures are invented (the "Bramblewisp Stalker" and friends), nothing from a published book. 🤖 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>
claude-bot scheduled this pull request to auto merge when all checks succeed 2026-09-08 07:12:18 +00:00
claude-bot deleted branch feat/553-foundry-adapter 2026-09-08 07:28:37 +00:00
Sign in to join this conversation.
No description provided.