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 —
closestays 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
metadataon the power.CharacterPowergetsmetadata?: Record<string, unknown>on the type and onCharacterPowerSchema, which is whatCharacterFeathas had all along. dnd5e'sTraitPickeralready readspower.metadata?.levelfirst and falls back topower.level; today the schema strips the key it reads, so the first read can never hit.levelkeeps 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/equipmentafter 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[].currentfrom 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, andmetadata.resourcescan carry them later if anyone misses them. - D7 — no change to who pays. Import uses the existing
resolveLLMConfigladder 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 theunpkg.com/pdfjs-dist@4.0.379workerSrcfor the worker the app already bundles, asapps/web/src/utils/pdfParser.tsdoes. 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: noworkerSrcin 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 buildemits exactly onepdf.worker.min-*.mjs, shared withpdfParser.ts's existing use, so the version now matches the API by construction rather than by someone remembering to bump a URL. The?urlform needed an ambient declaration:ui-componentspublishes./src/index.ts, so every consumer bundles this source, but its owntscbuild has novite/clientto resolve fromnode_modules. It lives atpackages/ui-components/vite-env.d.tsrather than undersrc/because.gitignoreline 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 toworkerSrcon 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.
systemIdbecomes a required prop, passed at all four call sites;ImportCharacterPageprefers the route's campaign over the response echo; off-campaign import falls back tocustom, never to a named game. Contract test: no literal system id inui-components. (Done, and there were six call sites rather than the spec's four —MapListandCharacterPageboth 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 thesystemIdit was handed,setParsedDatastored the response verbatim, and every consumer readdata.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. SohandleApplynow 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:ImportCharacterPageandMapListtake the route's campaign;CharacterListis the whole-account list with no campaign to inherit, so it namesCUSTOM_SYSTEM_ID;CharacterDetail's New Character modal takes the character it opened beside. The two overwrite paths (CharacterDetail,CharacterPage) now writecharacter.systemIdflatly — an import onto an existing character replaces its build, never the system its sheet, traits and validation are already written in.CUSTOM_SYSTEM_IDis new inshared, 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,CharacterList2 → 0,MapList2 → 0,CharacterDetail5 → 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 againstHEAD~1first: 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 inlineXLSX.readcalls and the twosheet_to_jsoncalls. Bothpdfjs-distandxlsxstay 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 wholeCharacterread strictly less out of a PDF than the one that produced a token:pdfParser.tspulled 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.tsis 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 callgetDocument(— checked againstHEAD~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 inventsroll('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.tsis deleted outright —parsePDFleft for the shared reader in 0.3 and the guessing was all that remained — andcharacterSheetParser.tsis now a request and a thrown error.ParsedSheetData.sourcegoes 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 againstHEAD~1.)* - [x] 0.5 — Make the model call survivable. Explicit
max_tokenson both parse routes, and a JSON failure that says which failure it was (truncated / not JSON / provider error) rather thanFailed to parse LLM response as JSON. Fix or removeclaudefromPROVIDER_CONFIGon these routes while here: the handlers send an OpenAI body withAuthorization: Bearerand readchoices[0], which the Anthropic Messages API answers with a 401. (Done —claudefixed rather than removed, becausePROVIDER_CONFIGis 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 onellmJsonCompletioninservices/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, andmax_tokensrequired. The truncation message is the part that matters most.Failed to parse LLM response as JSONnamed 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 nomax_tokensat all, so the ceiling was whatever the provider's default happened to be.stop_reason/finish_reasonnow decides the message, and the response carries a machine-readablecause(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 noresponse_formatto 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, soclaudeis 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 withParsedCharacterDataderived from it, parsed at the route boundary; the hand-writtensanitized = {…}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;ParsedCharacterDataisz.inferof it andui-componentsre-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'andattribute: 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 onelevelthat came back as"three"serves nobody. A field the shape does not declare is dropped and reported too.parseImportedCharacterreturnsdroppedFieldsas 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.*systemIdis 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, becausez.inferleaves 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 keycharacters.tsdeclares, or the value is fetched, parsed, validated, sent, and then silently stripped on the way into the row. That was true ofdamageDiceand 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.
AiParseSchemagrows attacks, powers and proficiencies;buildCharacterParsePromptstops hardcodingdamage: light|medium|heavy,hitPower,critPowerand powerrankfor every system, andai.tsstops padding to six D&D ability names. MinorMODULE_API_VERSIONbump — a module that declares nothing keeps today's envelope. (Done.MODULE_API_VERSION1.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's1d8 slashingwas requested as a damage weight,damageDice/damageType/range— onCharacterAttacksince v0.50.0 — were never requested at all, and powers were asked for arank, 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 fromattributeNamesFor(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 invtt-modulesbefore this release merges.)* -
[x] 1.3 — Extract the origin. Species / subspecies / background into
Character.origin, withorigins[]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 nosubspecieskey 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 whoseorigin.speciesis "Wood Elf" whileoriginssays "Elf" renders two different ways on two different surfaces. An unmatched origin is a completeCharacterOriginChoice, not a degraded one —slug,abilityBonusesandskillProficienciesare 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 owndata.x.length > 0 ? data.x : base.xchain. They had already drifted — some carriedorigins, some did not, andCharacterDetailkept an inline copy of the blank template's six attributes spelledBasewhere the shared template spells itbase. 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.characterFieldsFromImportinsharedis 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 readsdata.<collection>directly; againstHEAD~1that regex finds 58 sites across the five files.* A trap worth recording:packages/server-corecompiles withstrict: falseand resolvessharedfrom source, not from its built declarations. zod decides key optionality withundefined extends T[k], which is TRUE for everything whenstrictNullChecksis off — so the same schema infers{ name: string }insharedand{ name?: string }inserver-core, and the row stops being assignable to the interface it models. Annotating the schemasz.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 namedasStoredcast carries it, documented at the site; the real fix isstrictNullChecksin server-core, which is 83 errors of separate work.* One stale assertion re-pointed rather than loosened:CharacterPage.contract.test.tspinned the literal expressiondata.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[]withclassName,levelandsubclass, withclasses[]and the characterlevelderived 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 whoselevelcame back as 6 — the biggest number on the sheet — is a character whose proficiency bonus is wrong forever, because the server derives that fromleveland stores no column for it. SoclassLevelsis what the prompt asks for, andclassesandlevelare computed from it. The prompt says as much, so a disagreement costs nothing.*classLevelsis name-keyed onclassNamerather thanname, 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, becauseCharacterClassLevelreusesCharacterOriginChoice: 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,
currentset from the sheet's current HP.current: undefinedandcurrent: 0are different facts (D6). (Done. They go in theattributeslist 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.2numericAttributeschannel an ability score goes through.AiParseSchemagainsnumericAttributes(still 1.27.0, still unreleased) so a module can name its own and say which are ranges;GENERIC_NUMERIC_ATTRIBUTESis the fallback, held to the same rule asGENERIC_ABILITY_NAMES. Omitting one is a meaningful answer, and the prompt says so. All three areoverride-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 (maxfor a range,baseotherwise) because that is the platform's storage convention and the model has no way to guess it. Andcurrentis 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[].groupfor tool, weapon and armour proficiencies andskills[].tierwhere the module declares a ladder, with the two booleans kept in step for consumers that read them. (Done. Withoutgroup, 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.AiParseSchemagainsproficiencyTiersbeside 1.2'sproficiencyGroups. 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;tieris 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.reconcileProficienciesis 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.tsalready readstrainedastier > 0andtoggleSkillTieralready 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. Agroupthe 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 aresoftand 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.critRangestays a string beside the integercrit, because "19-20" is a range andcritis an expansion count; the v0.50.0 field exists precisely because they are different facts. Still inert until the modules declareattackFields, which is the other half of this task and lands invtt-modulesbelow.)* -
[x] 1.8 — Extract the prose and the leftovers. Backstory, features and traits and personality into
notes; ability-score method intometadata.abilityScoreMethod. Anything the model could not classify goes intonotesunder 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".unclassifiedis a heading→text map thatcomposeNotesappends as markdown sections under whatever prose the sheet already had — markdown becausenotesis markdown, so the heading has to survive as one.metadatais 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 viaAbilityScoreSchema.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:abilityScoreMethodandunclassifiedare asked of the model and have no column, because they land inmetadataandnotesunder 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
metadataonCharacterPower(D4). Type,CharacterPowerSchemaand 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'sTraitPickerreads a spell's level aspower.metadata?.levelbefore falling back topower.level—knownSpellRecordsandrecordLevelboth do — because the catalogue it renders beside a character's spells keeps level and school inContentRecord.metadataand 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.CharacterFeathas declared the same field all along for the same kind of module-owned data. This is the asymmetry, not a new idea. Why not blesspower.levelas the spell level instead — D4's other option: becauselevelalready 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/equipmentafter the character exists, with per-item failures surfaced. Currency stays out. (Done. There is noequipmentfield 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, becausesortOrderfollows 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 defaultsquantityto 1 andequippedto 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 —notifyEquipmentSkippedtells 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:equipmentwas 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 indroppedFieldsis a key the shape is behind on.currencyis 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.guidelinesreached both parse routes, and the two produce different things: a wholeCharacter, versus a token's flatmetadatabag plus executableactions. 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.characterGuidelinesandtokenGuidelinesjoinguidelines, 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 onlyguidelinesbehaves 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
readPdfTextemits it (page headings,Form Datasections,|-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 aCharacterdeclares, no nameless row anywhere,originsequal tooriginNames(origin),levelequal to theclassLevelssum, nocurrenton 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.AiParsePowerFieldhas listedpreparedsince 1.2, so the prompt was asking for it — andImportedPowerSchemahad 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 namingdnd5e-2014. A fixture of a 5e sheet that cannot say so is not a fixture of anything, andpackages/shared/src/testing/holds nothing but test support — so that exact directory joins*.test.tsin the exemption, scoped to the path rather than to atestingsegment 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, thenmodules.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()inshared: case, diacritics, punctuation, articles, trailing parentheticals. Every later task depends on it and it depends on nothing. (Done, pluseditDistanceandcloseThreshold, 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, soBigby’s Handnormalised tobigby s hand, a stray one-letter token, and a catalogue'sBigby's Handstopped matching a sheet'sBigbys 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 RestorationandLesser Restorationstay further apart than the threshold ever reaches.* It deliberately does not stem or singularise —PotionandPotionsstay different here. Near-misses belong in theclosetier, 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.editDistancetakes 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
listSystemTraitsfetch 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-BoltandFire Boltin one catalogue normalise to the same key and are two records, with no basis here for preferring either. A plainMap<string, ContentRecord>keeps the first silently, which is a coin toss dressed up as a lookup; the list becomesambiguousin 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, andcustomhas no module at all, and neither should stop someone importing their own character. It is not swallowed — every row then comes backcustom, 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 twoclosecandidates meaning no match (D2). Pure, module-free, fully unit tested. (Ambiguity is not a property of theclosetier, and §3.2 has it wrong.) Two catalogue records can normalise to one name —Fire-BoltandFire 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 inambiguous, 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.aliasesis 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 ametadatakey 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. Theclosepass 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 Sheildwould otherwise be as far fromFire Shieldas a wholly different word. That does not loosen the guard, because the guard was never the metric:alert/alterturns out not to be an adjacent swap at all (ertagainstteris 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,abilityBonusesandskillProficienciesonto 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, theMatchReportevery row's tier travels back in, andsummariseMatches. 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.contentTypealready 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(abilityPointsGrantedwhere the record has it); unmatched feats keep the sheet's owndescription, under the keyCharacterFeatdeclares for it. (Done.metadatais what a match actually buys here: for 5e it carriesabilityPointsGranted, 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.normalizeCollectionSlugsalready 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 underdescription, which is the keyCharacterFeatdeclares — writingbrieffor both is how catalogue feats arrived with no text at all, and why dnd5e'sdescribeTraitexists. 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), andpreparedwhere the sheet marks it. Power actions are not derived here —derive-power-actionsowns 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", andcharacterFieldsFromImportwas writing that straight intoCharacterPower.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 tometadata.level, where dnd5e'srecordLevelreads 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 underbriefhere anddescriptionon a feat, which is the asymmetry dnd5e'sdescribeTraitexists to encode.preparedis 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-actionsowns 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 assertedpowers[].leveland 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.
importedCharacterFieldsis 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.originKindsalready names a content type per origin slot,subclassKindnames one for the subclass, and the trait channels name one per subtype — resolved tofeats/powersthroughtraitCollection, 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 avtt-modulesevent and should failvtt-modulesCI while the change is being made.@purple-cat/shared/testingis 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 answerslistTraits('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 backcustomand looked like a plausible SRD miss. Synthetic fixtures could never have shown it, because they had no such rule.CatalogueCachenow keys on the filter too, the plan carriesdependsOn, 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.)