Skip to content

v0.69.0 — Character Import

Import takes a filled-in character sheet — a PDF, a spreadsheet — and produces a Character. It was written when a character was a name, six ability scores and a list of attacks, and it has not moved since. Everything that makes a character a character now (the declared origin, class levels and subclasses, the module trait catalogues, HP/AC/Speed attributes, spells with levels and preparation, equipment) either did not exist or was never plumbed through it.

Three of the nine findings mean parts of the feature do not work at all: the PDF path throws on a pdf.js worker version handshake and blames the file; every character imported from the two most obvious entry points is filed as System 8 regardless of the campaign; and the whole-character path reads less out of a PDF than the token path does, because it concatenates text items and ignores the AcroForm fields where a fillable sheet keeps its answers already labelled.

Spec: 16-character-import — read § What is actually there today first, then § The matching problem, in numbers for why "match against the SRD" cannot mean "match or fail". Tasks are requirements.md, one commit each, in order.

Scope is Phases 0, 1 and 2 — repair, emit the current format, and match against the module catalogue. Phase 3 (review and confidence UI), Phase 4 (better extraction) and Phase 5 (beyond one sheet) stay in the spec. That is the spec's 22 tasks plus two the decisions below created: 1.10 exists because D4 was answered "declare it", and 1.11 because D5 was answered "equipment yes".

Phase 0 is where a cut-short release should stop: five defects, no design questions, and everything downstream is wasted effort until they land.

Decisions, settled 2026-08-25

§10 had seven open. All seven are answered; the four that changed the work are D4, D5, D6 and the scope above.

  • D1 — a low match rate is the honest answer, and gets said out loud. The 2014 SRD bundles one background and one feat, so a typical 5e sheet imports mostly custom rows. Phase 2's summary reports the real number rather than hiding it; pointing the matcher at a fuller user-supplied catalogue is a content problem and is not in this release.
  • D2 — close stays conservative. Single qualifying candidate, small normalised edit distance, always flagged. Principle 4 is the reason: binding "Alert" to "Alter" rewrites the character's rules, and a wrong match is worse than no match.
  • D3 — matching runs client-side, confirmed as §3.1 argues. Modules load in the browser and their CS-5 providers reach content by fetching the module's own routes; matching on the server would have the API server calling itself over the network, per trait, per import.
  • D4 — declare metadata on the power. CharacterPower gets metadata?: Record<string, unknown> on the type and on CharacterPowerSchema, which is what CharacterFeat has had all along. dnd5e's TraitPicker already reads power.metadata?.level first and falls back to power.level; today the schema strips the key it reads, so the first read can never hit. level keeps its documented meaning ("the level at which this power was gained") and needs no migration. Task 1.10.
  • D5 — equipment yes, currency no. Extracted gear is POSTed to /api/characters/:id/equipment after the character exists, with per-item failures reported rather than swallowed. Currency is the ledger and has its own semantics; it stays out. Task 1.11.
  • D6 — current HP only. attributes[].current from the sheet's current HP, with absent and zero kept distinct. Spent slots, used hit dice and death saves are a moment rather than a character, and metadata.resources can carry them later if anyone misses them.
  • D7 — no change to who pays. Import uses the existing resolveLLMConfig ladder and the user's own key exactly as the other AI surfaces do. Task 0.5 gives the call a size bound, which is the part of the question this release can answer on its own; 12-early-saas-maturity owns the rest.

Phase 0 — Repair

  • [x] 0.1 — Fix the pdf.js worker in CharacterImportFromSheetModal. Drop the unpkg.com/pdfjs-dist@4.0.379 workerSrc for the worker the app already bundles, as apps/web/src/utils/pdfParser.ts does. The version handshake throws on mismatch, so this is the whole PDF path failing with a message that blames the file — and a third-party CDN fetch from a page that has no business making one. Guard: no workerSrc in the repo points at an external host. (Done. import pdfjsWorkerSrc from 'pdfjs-dist/build/pdf.worker.min.mjs?url', which the consumer's bundler resolves — pnpm --filter @purple-cat/web build emits exactly one pdf.worker.min-*.mjs, shared with pdfParser.ts's existing use, so the version now matches the API by construction rather than by someone remembering to bump a URL. The ?url form needed an ambient declaration: ui-components publishes ./src/index.ts, so every consumer bundles this source, but its own tsc build has no vite/client to resolve from node_modules. It lives at packages/ui-components/vite-env.d.ts rather than under src/ because .gitignore line 101 ignores **/src/**/*.d.ts — committing it there would have left the build passing locally and failing in CI. The guard (workerSrcHosts.guard.test.ts) is scoped to workerSrc on purpose. Plenty of external URLs in this repo are legitimate — doc links, the hosting manifest, SSRF fixtures. What is never legitimate is executing a worker fetched from one, and the version-pin bug and the supply-chain question have the same fix.)
  • [x] 0.2 — Make the target system come from the caller, never a default. systemId becomes a required prop, passed at all four call sites; ImportCharacterPage prefers the route's campaign over the response echo; off-campaign import falls back to custom, never to a named game. Contract test: no literal system id in ui-components. (Done, and there were six call sites rather than the spec's four — MapList and CharacterPage both grew one since it was written, and both inherited the same default. The default was not the whole bug, and fixing only it would not have held. The parse route echoes the systemId it was handed, setParsedData stored the response verbatim, and every consumer read data.systemId || <their own context> — putting a value that originates in the modal, makes a round trip and earns no authority ahead of the campaign the user was importing into. So handleApply now stamps the prop over the reply (onImport({ ...parsedData, systemId })), which makes the echo structurally incapable of winning even for a consumer that reads the obvious field.* Where each site's system comes from is a question with a real answer, not a fallback: ImportCharacterPage and MapList take the route's campaign; CharacterList is the whole-account list with no campaign to inherit, so it names CUSTOM_SYSTEM_ID; CharacterDetail's New Character modal takes the character it opened beside. The two overwrite paths (CharacterDetail, CharacterPage) now write character.systemId flatly — an import onto an existing character replaces its build, never the system its sheet, traits and validation are already written in. CUSTOM_SYSTEM_ID is new in shared, because the fallback needed a name: it is the only system id the platform is allowed to know under CC-1, and spelling it out at each use would have read like the leak it replaces. Guards: the CC-1 ratchet drops five sites (modal 1 → 0, CharacterList 2 → 0, MapList 2 → 0, CharacterDetail 5 → 4). Two contract tests carry what a type cannot — that every surface opening the modal passes a system and none falls back to a named game, and that the modal declares the prop required and stamps it onto what it hands back. All four assertions were checked against HEAD~1 first: three of the four consumers trip the named-game rule, and all four trip the passes-a-system rule.)
  • [x] 0.3 — One document reader. parsePDF (with its AcroForm widget extraction) and the workbook-to-rows conversion move into a shared module both surfaces import, and the modal's private copy goes. The full-character path gains form-field extraction for free, which is the largest accuracy win available in Phase 0. (Done. packages/ui-components/src/utils/documentReader.ts — isPdfFile, readPdfText, readWorkbook, readWorksheetRows — replaces the two extractors, the two inline XLSX.read calls and the two sheet_to_json calls. Both pdfjs-dist and xlsx stay dynamic, so a page that never opens an import modal pays for neither. The fork had a direction, and that is what made it worth a task. The surface that produced a whole Character read strictly less out of a PDF than the one that produced a token: pdfParser.ts pulled AcroForm widgets — a fillable sheet's own labelled answers — and the modal concatenated text items and stopped. The same fork is why one of them got a bundled worker and the other kept the dead CDN URL of task 0.1. One reader means one defect, and one fix.* It is reached by subpath (@purple-cat/ui-components/documentReader) rather than through the barrel: characterSheetParser.ts is a util, and pulling every React component in this package into its graph to get a spreadsheet helper is how the "shared" version of this ends up worse than the fork was. Eight behavioural tests over a mocked pdf.js pin what actually comes out — form fields read, numbers and button values kept (AC: 18, Inspiration: Off), unfilled widgets dropped rather than one blank line per field on a 200-field sheet, non-widget annotations ignored, Content and Form Data labelled separately because they are different kinds of evidence, and an empty text layer answered honestly rather than thrown. Plus a repo guard: exactly one module may call getDocument( — checked against HEAD~1, where three call sites in two files would have failed it.)
  • [x] 0.4 — Retire the keyword heuristics. findValue(['AC', …]) matches "Background" and "Attacks"; the action heuristic invents roll('1d20') snippets. Both fire precisely when the user has no AI configured — the user least able to tell fabricated data from parsed data. The AI-unavailable path says so instead. (Done. pdfParser.ts is deleted outright — parsePDF left for the shared reader in 0.3 and the guessing was all that remained — and characterSheetParser.ts is now a request and a thrown error. ParsedSheetData.source goes with them: with one source it recorded nothing, and it was the field the UI branched on to badge fabricated data "Basic Import". The failing path now surfaces the server's own message, which matters because the common failure is not a failure — it is the route's 503, "No AI provider configured. Set an API key in Settings or ask your server admin to configure one." That is the one thing the user can act on, and a fixed "Failed to parse the PDF content." was hiding it behind a sentence that blamed the file. One of these guards was vacuous when first written, and the catch is worth recording. The check for fabricated actions stripped comments before scanning — the house pattern, since the prose above these files names the shapes being forbidden. But the invented action's body lived inside a template literal that opens with //, so comment-stripping deleted the exact marker being searched for: the assertion passed over an untouched offender. That is v0.68.0's finding 4 in miniature, in a release that is not even about tests. It now scans raw and carries a self-check asserting the pattern still matches the old template verbatim — and both deleted call sites were confirmed against HEAD~1.)*
  • [x] 0.5 — Make the model call survivable. Explicit max_tokens on both parse routes, and a JSON failure that says which failure it was (truncated / not JSON / provider error) rather than Failed to parse LLM response as JSON. Fix or remove claude from PROVIDER_CONFIG on these routes while here: the handlers send an OpenAI body with Authorization: Bearer and read choices[0], which the Anthropic Messages API answers with a 401. (Done — claude fixed rather than removed, because PROVIDER_CONFIG is also what the model-listing endpoint reads, and that one already speaks Anthropic correctly. The three defects turned out to be one defect: two copies of a hand-rolled call. They are now one llmJsonCompletion in services/llmChat.ts, which picks the dialect from the provider — x-api-key + anthropic-version, the system prompt as a top-level field rather than a message, content[] on the way back, and max_tokens required. The truncation message is the part that matters most. Failed to parse LLM response as JSON named one cause for at least three events, and it was not the likely one: the usual failure is a reply that was JSON and got cut off, because a level-12 prepared caster's sheet is sixty spells. Both routes were sending no max_tokens at all, so the ceiling was whatever the provider's default happened to be. stop_reason / finish_reason now decides the message, and the response carries a machine-readable cause (truncated / invalid-json / provider) so a surface can eventually offer the right next step without matching on prose. 502 rather than 500 for all three: the upstream misbehaved, the request that reached us was fine.* Markdown-fence unwrapping is kept and widened to bare ```, because it is not a workaround — Anthropic has no response_format to ask JSON with, so a fenced reply is a normal reply there. What this release does not fix, counted rather than left silent: three other routes (generate-monster, generate-encounter, optimize-character) still hand-roll the OpenAI body and still answer everything with the old message, so claude is broken on those exactly as it was here. Out of scope — 0.5 is the import path, and those have their own response shapes — so a ratchet counts the remaining occurrences instead. It can only go down.)*

Phase 1 — Emit the current format

  • [x] 1.1 — Declare the import shape once, in shared. One zod schema for the extraction result with ParsedCharacterData derived from it, parsed at the route boundary; the hand-written sanitized = {…} literal goes. Two lossy layers is one too many. Added to the parity guard's list of pairs. (Done. packages/shared/src/services/characterImportSchema.ts; ParsedCharacterData is z.infer of it and ui-components re-exports rather than re-declaring, so the modal, the route and the tests cannot disagree about what an import produces. The sanitizer was not merely redundant, it was inventing. attribute: skill.attribute || 'Intelligence' and attribute: attack.attribute || 'Strength' wrote D&D ability names into every system's characters, and for any skill that is not an Intelligence skill the result was confidently wrong rather than visibly incomplete. components: … : [{ name: 'base', value: 10 }] gave a character a 10 in an ability the sheet never showed. 'Unknown Feat' made a row nobody could use look like one somebody had imported. All four are gone; where a required string was not on the sheet, the schema writes ''.* Three failures, three answers, none of them silent and none of them fatal. A row with no name is dropped whole — these collections are name-keyed, so a nameless row cannot be addressed, rendered or matched. A field whose value the shape cannot hold is dropped via .catch() and reported; losing sixty correctly-read spells to one level that came back as "three" serves nobody. A field the shape does not declare is dropped and reported too. parseImportedCharacter returns droppedFields as dotted paths (powers[0].components, feats[1]) and the route logs them — .strict() would have killed a whole import over a volunteered "backstory", and plain stripping is what got us here.* systemId is stamped by the route and the model's is discarded, which closes the same echo path 0.2 closed on the client. The parity guard gains a different pass, because z.infer leaves nothing to compare. One declaration cannot drift from itself — so the pairs added are import shape ↔ storage schema: every key the extraction asks the model for must be a key characters.ts declares, or the value is fetched, parsed, validated, sent, and then silently stripped on the way into the row. That was true of damageDice and its neighbours for three releases. Not the reverse direction — storage holds plenty an import cannot know, and Phase 1 closes that gap one task at a time.)*

  • [x] 1.2 — Drive the whole prompt envelope from the module. AiParseSchema grows attacks, powers and proficiencies; buildCharacterParsePrompt stops hardcoding damage: light|medium|heavy, hitPower, critPower and power rank for every system, and ai.ts stops padding to six D&D ability names. Minor MODULE_API_VERSION bump — a module that declares nothing keeps today's envelope. (Done. MODULE_API_VERSION 1.26.0 → 1.27.0: attackFields, powerFields, proficiencyGroups, all optional. R3.3 made the attribute list module-supplied and stopped, and what it left behind was the more damaging half. The attack and power blocks were System 8's vocabulary asked of every system: a 5e sheet's 1d8 slashing was requested as a damage weight, damageDice / damageType / range — on CharacterAttack since v0.50.0 — were never requested at all, and powers were asked for a rank, which is System 8's tier and not a thing a 5e sheet has. The prompt's own examples also named "Dexterity" and "Strength" as the skill and attack abilities for every system; those are now "<one of the attributes above>".* The fields a module names are the platform's own storage keys, not game vocabulary — that is the line that keeps CC-1 intact. The platform owns the record shape and knows how to ask for each field (and now carries a guideline line per field, because a key in a JSON skeleton with no explanation is a key the model guesses at); the module says which of them its sheets carry. ai.ts's six-name padding now pads from attributeNamesFor(schema) — the same list the prompt asked for, which is the only list that can be right. A System 8 import was arriving with rows for abilities that system does not use, and a system with a seventh ability never got it. Additive in the strict sense, and asserted as such: a module that declares nothing gets exactly today's envelope, which is why the defaults are System 8's vocabulary — that is where the hardcoding came from. The modules do not yet declare the new fields, so 5e import gains nothing until they do; that is task 1.7's other half and lands in vtt-modules before this release merges.)*

  • [x] 1.3 — Extract the origin. Species / subspecies / background into Character.origin, with origins[] kept in step for the legacy display. (Done. The prompt asks for the structured origin and fills only the parts the sheet shows — "a sheet with no subrace has no subspecies key at all", because "no background yet" must not read as "a background with no name". origins[] is derived from it rather than extracted alongside: ask the model for the same fact twice and you get two answers, and a character whose origin.species is "Wood Elf" while origins says "Elf" renders two different ways on two different surfaces. An unmatched origin is a complete CharacterOriginChoice, not a degraded one — slug, abilityBonuses and skillProficiencies are what a match adds in Phase 2. The prompt is told so explicitly: "an unrecognised name is kept as written, never replaced with a similar one". The bigger half of this task is the mapping it replaced. Five surfaces open the import modal and each had its own data.x.length > 0 ? data.x : base.x chain. They had already drifted — some carried origins, some did not, and CharacterDetail kept an inline copy of the blank template's six attributes spelled Base where the shared template spells it base. Phase 1 adds eight more fields to this shape; eight fields across five copies is forty chances to leave one out, silently, because a field nobody copied is a field nobody sees missing. characterFieldsFromImport in shared is now the only mapping, returning a partial — only what the extraction produced — which is what lets one function serve both the create path ({ ...template, ...fields }) and the overwrite path ({ ...character, ...fields }). A guard asserts all five call it and none reads data.<collection> directly; against HEAD~1 that regex finds 58 sites across the five files.* A trap worth recording: packages/server-core compiles with strict: false and resolves shared from source, not from its built declarations. zod decides key optionality with undefined extends T[k], which is TRUE for everything when strictNullChecks is off — so the same schema infers { name: string } in shared and { name?: string } in server-core, and the row stops being assignable to the interface it models. Annotating the schemas z.ZodType<CharacterSkill, …> does not help: that annotation is checked in server-core's context too and fails there for exactly the reason it was added. One named asStored cast carries it, documented at the site; the real fix is strictNullChecks in server-core, which is 83 errors of separate work.* One stale assertion re-pointed rather than loosened: CharacterPage.contract.test.ts pinned the literal expression data.powers.length > 0 ? data.powers : character.powers. The contract it protects — an empty parse erases nothing — is unchanged and is what it asserts now.)

  • [x] 1.4 — Extract class levels. classLevels[] with className, level and subclass, with classes[] and the character level derived from it so the three cannot contradict each other. (Done. classes[] is a list of names — enough to display a class, not enough to say Fighter 6 / Wizard 2, and with nowhere at all to put a martial archetype. An imported multiclass character arrived as a level-8 character with two class names and no subclass. Deriving the other two is the point, not a convenience. All three describe the same fact, and a model asked for all three answers each independently: a Fighter 6 / Wizard 2 whose level came back as 6 — the biggest number on the sheet — is a character whose proficiency bonus is wrong forever, because the server derives that from level and stores no column for it. So classLevels is what the prompt asks for, and classes and level are computed from it. The prompt says as much, so a disagreement costs nothing.* classLevels is name-keyed on className rather than name, so the nameless-row rule from 1.1 covers it — and dropping one changes the total, which is exactly why the total is derived from the surviving rows rather than taken from the model. The subclass reuses the imported origin-choice shape, because CharacterClassLevel reuses CharacterOriginChoice: they really are the same thing, a catalogue pick that may instead be written in. The parity pass gains three pairs — origin choice, origin, class level.)

  • [x] 1.5 — Extract HP, AC and Speed as attributes. Single override component each, current set from the sheet's current HP. current: undefined and current: 0 are different facts (D6). (Done. They go in the attributes list rather than a block of their own, because that is what they are — both shipped modules declare HP, AC and Speed through the same CS-1.2 numericAttributes channel an ability score goes through. AiParseSchema gains numericAttributes (still 1.27.0, still unreleased) so a module can name its own and say which are ranges; GENERIC_NUMERIC_ATTRIBUTES is the fallback, held to the same rule as GENERIC_ABILITY_NAMES. Omitting one is a meaningful answer, and the prompt says so. All three are override-mode: a stored attribute WINS over the module's derivation. So writing an AC the sheet never showed does not fill a gap, it pins a number and stops the rules working it out — worse than the blank it replaces. ai.ts's padding stays abilities-only for exactly this reason.* The component name is spelled out in the example (max for a range, base otherwise) because that is the platform's storage convention and the model has no way to guess it. And current is never defaulted: absent means the sheet recorded no current, zero means at zero, which is dying — an importer that collapses the two imports every undamaged character as unconscious. Two wording assertions re-pointed rather than loosened: the guideline now says "Extract all N abilities", since the same list carries the numerics too and the count only ever meant the abilities.)

  • [x] 1.6 — Extract proficiencies properly. skills[].group for tool, weapon and armour proficiencies and skills[].tier where the module declares a ladder, with the two booleans kept in step for consumers that read them. (Done. Without group, tool and armour proficiencies could not be imported at all — they are on the same ladder and read the same way, and the group is the only thing separating them from a skill. AiParseSchema gains proficiencyTiers beside 1.2's proficiencyGroups. Keeping three fields in step needed an owner, and there wasn't one. The booleans are 5e's vocabulary and cannot express a four-rung ladder; tier is the generic form and authoritative since v0.50.0 — but stored characters carry the booleans with no tier, and consumers read both. A module projecting from these keeps them in step for its own consumers, which left the importer with the same obligation and nobody to inherit it from. reconcileProficiencies is that owner: pure, module-free, and it invents nothing — with no declared ladder there is no index to compute and the booleans are carried as sent.* The tier↔boolean rule is not new: skillSection.ts already reads trained as tier > 0 and toggleSkillTier already moves between 0 and 1. What the reconciler adds is the refusal to guess at the other end — a two-rung ladder has no expertise rung, so a sheet that marked expertise on such a system was marking proficiency, and claiming otherwise would assert a rule the module never declared. An out-of-range tier is clamped rather than trusted, because an index the ladder does not have renders with no label at all. A group the module never declared is dropped and reported: the row would land in a list that does not exist and render nowhere, while absent is a real answer — the module's first group — so dropping the key leaves a working row rather than losing one. It runs in the route rather than the client mapper because it needs the module's ladder, and the parse schema is a server-side declaration.)

  • [x] 1.7 — Extract 5e-shaped attacks. damageDice, damageType, damageAttribute, range, critRange, additionalDamage — declared since v0.50.0, never requested. (Done, platform side. 1.2 taught the prompt to ask for them when a module declares them; this is the shape that can hold the answer. All six are soft and never defaulted — a damage type nobody wrote is not an empty damage type, and a system that does not use these must not gain empty strings for them. critRange stays a string beside the integer crit, because "19-20" is a range and crit is an expansion count; the v0.50.0 field exists precisely because they are different facts. Still inert until the modules declare attackFields, which is the other half of this task and lands in vtt-modules below.)*

  • [x] 1.8 — Extract the prose and the leftovers. Backstory, features and traits and personality into notes; ability-score method into metadata.abilityScoreMethod. Anything the model could not classify goes into notes under a heading rather than being dropped — a sheet's contents are the user's. (Done. The prompt is told outright: "Do not discard it and do not summarise it". unclassified is a heading→text map that composeNotes appends as markdown sections under whatever prose the sheet already had — markdown because notes is markdown, so the heading has to survive as one. metadata is merged, not assigned: it is a bag several features share, and an import that replaced it would take the resource pools with it. The method itself is not validated against a platform list — which methods apply is a module's to declare via AbilityScoreSchema.generationMethods, so a value this side does not recognise is the module's business, not a validation failure. The parity pass caught this task in the act, which is the first time one of these guards has earned its place inside its own release: abilityScoreMethod and unclassified are asked of the model and have no column, because they land in metadata and notes under other names. That is legitimate and it is exactly the shape of the defect the pass hunts, so the two are listed as extraction-only with the destination named — "it's on purpose" has to say what the purpose is — and a second assertion drops an entry that later gains a column.)*

  • [x] 1.10 — Declare metadata on CharacterPower (D4). Type, CharacterPowerSchema and the parity guard. Lands before the spells are matched because 2.6 has nowhere to write a spell's level until it does. (Done. The fifth instance of the recurring strip defect, and the first with a reader already waiting for it. dnd5e's TraitPicker reads a spell's level as power.metadata?.level before falling back to power.level — knownSpellRecords and recordLevel both do — because the catalogue it renders beside a character's spells keeps level and school in ContentRecord.metadata and the two lists have to sort the same way. The key it reads first was undeclared on both the type and the schema, so zod stripped it on every write and the fallback did all the work, which looked like it working. CharacterFeat has declared the same field all along for the same kind of module-owned data. This is the asymmetry, not a new idea. Why not bless power.level as the spell level instead — D4's other option: because level already means the level at which the power was gained, which is what the level-scaffolded slot rendering reads. A third-level wizard's Fireball is a level-3 spell gained at level 5 and both are true, so the two numbers coexist. That option was also a migration over every stored character; this one is two lines and no migration.)*

  • [x] 1.11 — Import equipment (D5). Extracted gear POSTed to /api/characters/:id/equipment after the character exists, with per-item failures surfaced. Currency stays out. (Done. There is no equipment field on a character to spread into the create, so gear is a second write — and a second write needs a partial-failure story. It reports rather than rolls back. A character that arrived with nine of its ten items is a better outcome than one that did not arrive: the character is the expensive part — a model call and a page of extraction — and a missing item is a line the user retypes in seconds. Rolling back would throw away the whole import to protect the cheap half of it. Every rejection comes back named, because the silent version of this is the dangerous one: the character looks complete, since everything else landed.* Sequential rather than parallel, because sortOrder follows insertion and the inventory should come out in the order the sheet listed it rather than in whatever order a burst of requests settled. Only the fields the sheet actually had are sent — the route defaults quantity to 1 and equipped to false, and sending them anyway would be claiming the document said so. The overwrite paths do not write it, and say so. Adding to an existing character's inventory would duplicate every item on a second import, with no diff and no undo. But silently discarding gear the user handed over in their own document is not the alternative — notifyEquipmentSkipped tells them what was left alone and why.* The extraction shape is deliberately thin: a line reading "Longsword" is evidence of a name and, on a good sheet, a quantity. The equipment record also holds artwork, buffs, slots, parent items and market values, and asking for those is asking the model to invent them. A small thing the report caught: equipment was the example of an undeclared key in 1.1's tests, and declaring it broke that test — which is the mechanism working. A key that keeps turning up in droppedFields is a key the shape is behind on. currency is the example now, and stays one: D5 kept the ledger's semantics out of the importer.)

  • [x] 1.12 — Send each prompt only the guidance written for it. (Added while writing the module declarations 1.2 and 1.7 depend on — it is the platform half of a defect the modules cannot work around.) AiParseSchema.guidelines reached both parse routes, and the two produce different things: a whole Character, versus a token's flat metadata bag plus executable actions. So dnd5e's "provide a code snippet that rolls to hit and applies damage" was being appended to the character prompt, which has no code snippets to give, and its "record hp, ac, speed, level, class and race in metadata (lowercase keys)" described a shape that prompt does not have. Instructions for one output, delivered to a model building the other — and invisible, because a model handed an impossible instruction just produces something slightly worse. characterGuidelines and tokenGuidelines join guidelines, which keeps its meaning for facts about the game ("System 8 has no skills list") that are true whichever route is asking. Still 1.27.0, still unreleased, and a module declaring only guidelines behaves exactly as before.

  • [x] 1.9 — Test against real sheets. Fixture-driven tests over checked-in extracted text — an official fillable 5e sheet, a D&D Beyond export, a multiclass character, a homebrew spreadsheet, a System 5 sheet — asserting the produced object against the shape an authored character of the same build carries. Last in the phase because it is what locks the format the other ten tasks agreed on. (Done. Each fixture carries three things: the extracted text in the exact form readPdfText emits it (page headings, Form Data sections, |-joined spreadsheet rows), a plausible model reply for that text, and a note saying which past failure it exists to pin. Nothing in Phase 1 reads the text — it is there because it is the evidence for the reply, and because task 4.1 will read these directly. A fixture whose reply drifts from the text it claims to come from stops being evidence of anything, so an assertion checks the two still agree. The tests run the route's own sequence — parseImportedCharacter → reconcileProficiencies → characterFieldsFromImport — because format bugs live between those, not inside them: a field that passes its own unit test and is then dropped by the mapping. The cross-fixture block is principle 1 made mechanical. Per-fixture assertions say what each sheet should yield; the invariants say what every sheet must — only keys a Character declares, no nameless row anywhere, origins equal to originNames(origin), level equal to the classLevels sum, no current on a non-range attribute, no tier without a declared ladder, no group outside the module's list. A sixth fixture inherits all of it.* And it immediately found one. AiParsePowerField has listed prepared since 1.2, so the prompt was asking for it — and ImportedPowerSchema had nowhere to put it, so every answer was dropped on the way through. A whole round trip spent on a value nobody would ever see, which is precisely what the parity pass exists to prevent and precisely what a unit test per field cannot see. That is the argument for having both.* One guard widened rather than baselined: the CC-1 leakage ratchet flagged the fixtures for naming dnd5e-2014. A fixture of a 5e sheet that cannot say so is not a fixture of anything, and packages/shared/src/testing/ holds nothing but test support — so that exact directory joins *.test.ts in the exemption, scoped to the path rather than to a testing segment anywhere, so it cannot be claimed by moving production code into a folder with a convenient name.)

The module half, in vtt-modules

Tasks 1.2, 1.5, 1.6, 1.7 and 1.12 all end at the same place: the platform can now be told what a system's sheets are made of, and until a module says so it keeps getting the generic envelope. That declaration is vtt-modules commit 35a525b — dnd5e 0.13.0 → 0.14.0 and system8 0.9.0 → 0.10.0.

dnd5e declares the dice-and-type attack model, spell level / school / class / prepared, HP-AC-Speed with HP as the range, the ['none', 'proficient', 'expertise'] ladder and the ['skill', 'tool', 'weapon', 'armor'] lists. The ladder is copied from its own sheet schema on purpose: a tier that means different things either side of an import is worse than no tier.

system8 declares the damage-weight set — damage / hitPower / critPower, which is exactly what the platform used to hardcode for everyone, because it came from here — plus powerFields: ['rank'] and the same three numeric attributes. It declares no ladder and no groups, deliberately: System 8 has no skills, and an empty ladder would be worse than none, since the platform would then ask for a tier index that means nothing on a skills array meant to come back empty.

Both modules' tests assert against the platform's real prompt builder rather than against the declaration, because what matters is what the model is asked.

Ordering. Neither module compiles against platform main — the 1.27.0 fields do not exist there yet — so the usual "publish the modules first" rule inverts for this release: v0.69.0 merges first, then the module tags and packages, then modules.hosted.json (still pinned at dnd5e 0.11.0 / system8 0.7.0, both already behind two unpublished versions). Bumping the manifest before publishing would point production at packages that do not exist.

Phase 2 — Match against the module catalogue

  • [x] 2.1 — The normaliser. A pure normalizeTraitName() in shared: case, diacritics, punctuation, articles, trailing parentheticals. Every later task depends on it and it depends on nothing. (Done, plus editDistance and closeThreshold, which 2.3 needs and which belong beside it. Apostrophes are deleted; other punctuation becomes a space. Writing that rule the obvious way — all punctuation to a space — broke on the first realistic name: an apostrophe sits inside a word, so Bigby’s Hand normalised to bigby s hand, a stray one-letter token, and a catalogue's Bigby's Hand stopped matching a sheet's Bigbys Hand. Which is precisely the pair the normaliser exists to reconcile. Both quote characters are handled, because a sheet and a catalogue rarely agree on which.* The threshold scales with length, and that is D2's answer made concrete. "Alert" and "Alter" are one edit apart and are two different feats; "Warrior of the Open Hand" and "Warrior of Open Hand" are four apart and are one subclass. A constant cannot be right for both, so a name of six characters or fewer gets zero tolerance and the allowance grows from there. A test asserts the pair that matters: Greater Restoration and Lesser Restoration stay further apart than the threshold ever reaches.* It deliberately does not stem or singularise — Potion and Potions stay different here. Near-misses belong in the close tier, surfaced for review, rather than being made exact by a rule nobody can see. The article list is three words for the same reason: dropping "of" invites dropping "greater" next, and those are the words that separate two real records. editDistance takes a cap and stops once no completion could come in under it. The largest bundled catalogue is 960 records, and that is the difference between a comparison and a pause.)
  • [x] 2.2 — The catalogue index. One listSystemTraits fetch per content type per import, indexed by normalised name and slug, cached for the life of the import. Whole, not per-name: the module routes serve from memory and the largest bundled catalogue is 960 records. (Done. Fetching whole is not only cheaper — it is the only way to know a name is absent, and for 5e that is the answer the matcher gives most often, so it has to be able to give it confidently rather than by failing to find something. Indexed by normalised name and normalised slug, because a sheet says "Wood Elf", a D&D Beyond export says "wood-elf" and a spreadsheet says "wood elf" — all three reach one record without the matcher knowing which kind of string it was handed. A collision is stored as a list, not resolved. Fire-Bolt and Fire Bolt in one catalogue normalise to the same key and are two records, with no basis here for preferring either. A plain Map<string, ContentRecord> keeps the first silently, which is a coin toss dressed up as a lookup; the list becomes ambiguous in 2.3 and is never applied. A record reachable by both its own name and its own slug is counted once, by identity — two different records may legitimately share a name.* The cache is per instance, not per module: one import sees one consistent catalogue from its first row to its last, and nothing outlives the import. Concurrent callers share one fetch because the promise is cached rather than the result, so ten collections asking at once make one request. A catalogue that will not load yields an empty index rather than an error: a module may have no content of a type, and custom has no module at all, and neither should stop someone importing their own character. It is not swallowed — every row then comes back custom, which 2.7 reports as a match rate of zero.)
  • [x] 2.3 — The matcher. matchTrait(name, index) returning { tier, record? } — exact / alias / close / ambiguous / custom, with two close candidates meaning no match (D2). Pure, module-free, fully unit tested. (Ambiguity is not a property of the close tier, and §3.2 has it wrong.) Two catalogue records can normalise to one name — Fire-Bolt and Fire Bolt — and when they do, an exact hit is exactly as unresolvable as a close one. Applying the first would be a coin toss wearing the confident tier's badge, which is the single outcome principle 4 rules out. So every tier can end in ambiguous, and the result carries the candidates: the platform knows the answer is one of these three and cannot choose, and Phase 3 will let a user settle in a second what no threshold can. ContentRecord.aliases is new (still 1.27.0). The forcing cases are renames the rules themselves made — 2014's "Race" against 2024's "Species", "Way of the Open Hand" against "Warrior of the Open Hand" — so a sheet printed under one edition is a perfectly good sheet and neither spelling is a typo. This is not fuzzy matching; it is the module saying two names are one record. A named field rather than a metadata key because the platform acts on it, and reported as its own tier so a reviewer can see a rename was applied rather than a name matched. Aliases are indexed after every real name, so an alias that collides with a record's actual name loses to it. The close pass uses optimal string alignment rather than plain Levenshtein, because swapped adjacent letters are the typo people actually make and Levenshtein charges two for them — Fire Sheild would otherwise be as far from Fire Shield as a wholly different word. That does not loosen the guard, because the guard was never the metric: alert/alter turns out not to be an adjacent swap at all (ert against ter is a rotation of three) and stays two apart, and even a name that is one transposition from a real record fails at five letters, where the tolerance is zero. The prefix rule is bounded by proportion, not length: "Fire" is a prefix of "Fireball" and is evidence of nothing, while "Warrior of the Open" is a prefix of "Warrior of the Open Hand" and plainly is something. A prefix qualifies only when the shorter is at least six characters and at least half the longer — and the ambiguity rule catches the rest, since a sheet's bare "Fire" is a prefix of several records and therefore matches none.)
  • [x] 2.4 — Wire origins. Species, subspecies, background and subclass through the matcher; a hit copies slug, abilityBonuses and skillProficiencies onto the choice. Copying rather than referencing is deliberate: a later module release must not silently rewrite a character somebody already built. (Done, and it brings the whole matching pass with it: matchImportedCharacter, the MatchReport every row's tier travels back in, and summariseMatches. 2.5 and 2.6 are collections added to the same walk. The plan comes from the caller, so this file names no content type at all. Which catalogue holds species, or backgrounds, or spells is the module's knowledge — OriginKindSchema.contentType already says, and the sheet's own Background section already reads it. The importer is handed the same answer rather than guessing, which is what keeps CC-1 intact through Phase 2.* A hit writes the catalogue's raw slug (dwarf), never the namespaced projection (dwarf-species) — the projection is a view, and writing it means the detail modal fetches nothing. Grants are deep-copied, with a test that mutating the catalogue record afterwards does not reach the character. The sheet's own wording wins over the catalogue's. A matched name is kept as the user typed it — the two normalise to the same thing or the match would not have happened, and their document is the more faithful record. The catalogue's prose is borrowed only when the sheet had none, so a homebrew note is never overwritten by a match.* Subclasses match per class rather than per character, because that is where a subclass lives: a Fighter 6 / Wizard 2 has a martial archetype and no arcane tradition yet. The report's path says which — classLevels[1].subclass.)
  • [x] 2.5 — Wire feats. Matched feats carry the raw catalogue slug and metadata (abilityPointsGranted where the record has it); unmatched feats keep the sheet's own description, under the key CharacterFeat declares for it. (Done. metadata is what a match actually buys here: for 5e it carries abilityPointsGranted, which is the entire input to the ASI budget rule — an imported Ability Score Improvement that grants nothing means a character who has taken one and spent its two points is told nothing they have taken grants any. Copied, not shared, with a test to prove it. A repeatable record taken more than once needs a distinct slug, and that is not cosmetic. A Fighter takes seven ASIs, so a sheet listing it three times is a correct sheet — and the server treats a supplied slug as authoritative while the sheet's collection sync diffs by slug, so two rows carrying one slug are one row as far as everything downstream is concerned. normalizeCollectionSlugs already mints unique slugs for name-derived ones; this is the same rule applied to matched ones. A duplicate of a non-repeatable record is disambiguated too rather than dropped: "your sheet lists Alert twice" is the module's validation to report (3.3), not the importer's to silently resolve.* The prose goes under description, which is the key CharacterFeat declares — writing brief for both is how catalogue feats arrived with no text at all, and why dnd5e's describeTrait exists. The sheet's own wording wins; the catalogue's fills in only where there was none. The test suite is written against the honest expectation: five feats off a real 5e sheet, one match. That number is the truth about what the 2014 SRD contains, and hiding it would make the matched rows look less trustworthy than they are.)*
  • [x] 2.6 — Wire spells. Matched spells carry the raw slug, the level and the school under metadata (D4), and prepared where the sheet marks it. Power actions are not derived here — derive-power-actions owns that and runs off the stored power. (Done, and this is where D4 actually lands. Tasks 1.2 and 1.5 taught the prompt to ask for "the level shown against the power", and characterFieldsFromImport was writing that straight into CharacterPower.level — which means the level at which the power was gained, and is what the level-scaffolded slot rendering reads. Both are true of a third-level wizard's Fireball, so the field was quietly claiming the wrong one. The number now moves to metadata.level, where dnd5e's recordLevel reads it and where 1.10 declared room for it. The catalogue wins over the sheet for a matched spell — it is authoritative about what level a spell is, where a sheet is a transcription — and the sheet's number fills the gap for a custom spell, which has no catalogue entry to be authoritative. A level-0 cantrip survives, which matters because 0 is both a real level and the falsy one. Prose goes under brief here and description on a feat, which is the asymmetry dnd5e's describeTrait exists to encode. prepared is carried because no list can reconstruct it: "known but not prepared" is exactly the state a spell list alone cannot express. Actions are deliberately not derived. derive-power-actions owns that, runs off the stored power, and stamps ids that must stay stable across re-derivation — a second deriver racing it is the two-places-decide problem the platform has avoided everywhere else.* Caught by 1.9's fixtures again: the D&D Beyond fixture asserted powers[].level and failed the moment the number moved. Re-pointed rather than loosened — the contract (every spell's level survives) is unchanged, and it now also asserts the field that means something else stays empty.)
  • [x] 2.7 — Report the outcome. Each row's tier travels back with the parsed data, so the user is told "38 of 51 entries matched the 5e SRD; 13 were kept as custom" and 2.8 has something to assert on. (Done, and this is the task that makes Phase 2 actually run: until now nothing called the matcher. importedCharacterFields is the one match-then-map path, and all five import surfaces go through it — a surface that mapped without matching would import a character whose traits carry no slugs, which looks fine and behaves like a v0.20 import. The plan is read off the module's own schema, so nothing guesses. originKinds already names a content type per origin slot, subclassKind names one for the subclass, and the trait channels name one per subtype — resolved to feats / powers through traitCollection, the closed list the sheet has used since it was built. A CS-1.3d view subtype is skipped, because "Spells Prepared" is a lens on a collection another subtype owns and matching it would match the same list twice.* The summary tells the truth even when it is unflattering. For 5e the number will look low, and it should — the 2014 bundle holds one background and one feat, so a real sheet imports mostly custom rows. Softening it would make the matched rows look less trustworthy than they are, and a user told "13 kept as custom" knows exactly which part of their sheet to check. Ambiguity is named separately rather than counted as a miss: those are choices the platform declined to make on the user's behalf. Silence when there was nothing to match, since "0 of 0" is noise.* The 1.3 contract guard is re-pointed rather than loosened — it now asserts every surface calls the match-then-map path and that none calls the mapping directly, which is a stronger statement than the one it replaces.)
  • [x] 2.8 — Match-rate tests over the real catalogues. Over the 1.9 fixtures: the 5e sheet's spells match, its background and feats fall to custom, the System 5 sheet matches materially more. These are what catch a module release silently halving the hit rate. (Done, in two halves, and the real-catalogue half is in vtt-modules.) It cannot run here: the platform depends on no module — they are fetched from a package registry at run time — and checking a copy of the SRD in as a fixture would mean the platform shipping game content, which the spec's own scope boundaries rule out. It also belongs there on its merits: the failure it exists to catch is "a module release silently halved the hit rate", which is a vtt-modules event and should fail vtt-modules CI while the change is being made. @purple-cat/shared/testing is a new subpath export so both sides assert about the same fixture documents rather than two approximations of one.* What is left here is the platform's own half: that a broad catalogue and a narrow one produce materially different outcomes over one sheet — the ask's premise, made mechanical — that the difference lands in the summary rather than being absorbed, and that no fixture loses a row to the matching pass whichever catalogue it ran against. And running it against the real SRD found a design bug three earlier tasks had missed. dnd5e answers listTraits('subclass') with nothing unless it is given { parent: <class name> } — a subclass list without a class is a category error, not a shorter list — and subraces are the same with a species slug. The catalogue index was built around fetching a content type whole, so every subclass on every fixture came back custom and looked like a plausible SRD miss. Synthetic fixtures could never have shown it, because they had no such rule. CatalogueCache now keys on the filter too, the plan carries dependsOn, and origins are matched in dependency order so a refinement is asked for with its parent's slug — which is exactly how the sheet's own Background section has always done it.* The numbers that came back are the spec's argument in figures: 11 of 16 entries match, every spell and every species does, and the five misses are one background, one feat and three subclasses — all real entries on real sheets that the SRD does not publish. Pinned exactly rather than as a floor, so a content change has to be acknowledged in a diff.)