feat(backend): Foundry VTT adapter for content packs (#553) #559
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/553-foundry-adapter"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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/.jsonper-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 infoundry_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 sharedsanitize_markdown, which would otherwise escape them; adapter warnings ride onmeta.extra["warnings"].A detection bug found on the way.
questboard.sniffclaimed any.jsonthat opened with[, and it is asked first, so a Foundry array imported as emptyotherrecords with no error anywhere. The bare-array branch now looks at the first element (whole or truncated —sniffonly 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
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>`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>