Skip to content

Current Production Version - v0.52.0 - 5e validation: ability scores, skills, feats and ASIs

The 5e half of Phase 2. System 8's shipped in v0.49.0, and phase-2-validation.md still owns the philosophy (validation never blocks, it only flags), the CS-3.3 rule-catalog contract, the three preference scopes and the panel itself — all reused unchanged. This release adds rules, and the ability-score generation UI those rules describe; it adds no new validation chrome.

Scoped to what a character sheet can check without knowing a character's species or spells: ability scores, skills, feats and ASIs. Spells, spell slots, rest and the Background/species section are v0.53.0; AI-assisted actions are v0.54.0. Both are listed under Upcoming below so nothing is lost.

Racial and background ability bonuses are ignored in this release — the racial component is neither expected nor checked. It becomes real in v0.53.0, when there is a Background section to choose a species and apply its bonuses automatically.

Items marked (DECIDE) are open questions. Each carries the default that will be implemented if nothing is said, so none of them blocks: the note records what was assumed and where to look if the assumption was wrong.

2d — Platform contract (packages/shared, packages/server-core)

  • [x] 2d.1 — Ability-score generation state. metadata.abilityScoreMethod stores a bare enum and abilityScoreRerollCount is read by readRerollCount but written by nothing — the tally has no producer because there is no roller. Add the platform-owned generation record (the six values as generated, which ability each is assigned to, the formula that produced them, and the tally), read/written by one pure module beside apps/web/src/utils/abilityScoreMethod.ts. Values persist so a re-opened modal shows what was rolled rather than silently re-rolling, and assignment is modelled as a permutation — a drop onto an occupied ability swaps, so no value is ever stranded where the UI cannot reach it. (One record covers rolling AND the standard array: both are "six values, assign each to an ability", differing only in provenance. Point buy has no values to assign and does not use it. Parsed defensively rather than zod-validated at the route — Character.metadata is one open Record<string, unknown> shared by currency holdings, HP mode and everything else, so there is no per-key route schema to hang this off; a malformed blob degrades to null instead.)
  • [x] 2d.2 — Roll formulas are module data, not a platform enum. AbilityScoreMethod is a closed platform union, so rolled can name the method but not the formula; 4d6-drop-lowest, 3d6 straight, 4d6-reroll-1s and 2d6+6 cannot join it without a platform release apiece. Add AbilityScoreSchema.rollFormulas — an ordered { id, label, formula, description? } list the module declares and the roller evaluates through the existing dice engine. (MODULE_API_VERSION 1.11.0 → 1.12.0, additive. Declaration order is load-bearing — the first entry is the default — and declaring rolled with no formulas means no roller rather than a defaulted 4d6: a system that offers rolling has an opinion about how, and guessing one puts dice in a player's hand their table never agreed to.)
  • [x] 2d.3 — Class-aware slot schedules. TraitSlotSchedule is { startingSlots, slotLevels } — static and level-only, which is exactly right for System 8. 5e's feat schedule depends on the class mix: Fighters gain ASIs at 6 and 14, Rogues at 10, on top of the common 4/8/12/16/19. Add an optional module-supplied slotsAtLevel(character): number alongside it; the declarative form stays for System 8 and nothing about its sheet changes. (No longer needs to be consumable, which the earlier plan required — the virtual-feat ASI model in 2e.5 means slots are filled rather than hidden, so the count depends only on the character's classes and levels. Built as TraitSlotSchedule.resolveGrantLevels(character) returning the grant levels, not a count: an empty slot is labelled with the level that opened it, and the count is the array's length. MODULE_API_VERSION 1.12.0 → 1.13.0, additive — the declarative pair is still read and is still the fallback, including when a module throws or returns a non-array, so a module bug degrades the schedule rather than blanking the section. traitSlots/slotCapacity now take the character; TraitSection's level prop becomes character.)
  • [x] 2d.4 — A feat may carry module data. CharacterFeat gains an opaque metadata — the same channel ProjectedTrait has had since CS-1.3 — and 5e records abilityPointsGranted in it. This is the whole data dependency of 2e.5's model: a half-feat is a feat granting 1, and the ASI itself is a feat granting 2. Without somewhere on the record for "Resilient gives +1" to live there is no model, because the 5.1 SRD ships exactly one feat (Grappler) — every half-feat a table uses arrives as a custom feat the player authored. (Opaque metadata rather than a named abilityPointsGranted field: "a feat grants N ability points" is 5e's sentence and System 8 has no equivalent, so the platform stores it without knowing what it means, and the module's own custom-feat editor — module code since v0.51.0 — is what writes it. Found while doing this: CharacterFeatSchema declared six of the ten fields CharacterFeat has, and zod strips what it does not declare, so rank, buffs and enabled were dropped on every write. rank is the one that mattered — v0.49.0's minimum-feat-rank reads it and it could never reach the record, so the rule had nothing to check. Third instance of this defect in this file after portraitCrop and the skill tier; a background task is filed to sweep the remaining sub-entity schemas.)
  • [x] 2d.5 — A character-type gate for validation. Nothing on Character distinguishes a PC from a monster, and a 5e monster has no class levels, no ASI budget and no standard array — so the moment 2e lands, every monster a GM inserts lights up with errors they can do nothing about. This is the phase-2 doc's own open question ("should the default be per character type rather than per campaign?") arriving with a forcing case. Add metadata.characterType ('pc' | 'npc', defaulting to pc) and let the reporting mode default off for npc. (Answered both: characterType says what the character IS and supplies the default; metadata.validationMode (flag / report-only / off) is the per-character override the doc already promised, absent meaning "inherit" exactly as a rule preference does. off means nothing runs and no validation UI renders — the doc's own definition — so the hook reports itself unsupported rather than showing an affirmative zero state for a character nobody checked. New Monster marks its character npc from the faction it already chose; every other creation path takes the pc default, since a monster mislabelled as a PC merely over-reports while a PC mislabelled as a monster stops validating and nobody notices.)
  • [x] 2d.6 — Turning validation back on for an NPC. 2d.5's off renders no validation UI, so the control cannot live in the panel — a panel that is not rendered cannot offer to render itself. A reporting-mode selector in the sheet's Options section, showing the inherited default and letting it be overridden per character. (Two Build-mode selects: character type, and the reporting mode with inherit as its own named option — "Default for a monster or NPC (Off)" — rather than an empty value, so a player can see which default they are inheriting and put it back deliberately. Play mode shows the row only when it is worth reading (the mode is not flag, or an override is set): "Validation: Flag" on every PC is noise, while a sheet that is silently not being checked is exactly the case that must be visible. The description says validation never blocks an edit, since error is the severity most likely to be misread as a gate.)

2e — 5e rules and the validation catalog (module: vtt-modules/modules/dnd5e, branch version/dnd5e-0.5.0)

  • [x] 2e.1 — One validateHp, not two. 2014 and 2024 carry divergent copies, preserved verbatim with a header comment deferring the reconciliation "to the release that gives 5e a CS-3 validation catalog". This is that release. One implementation in core/: resolve the class through classLevels (2024's behaviour), keep 2014's missing/negative-HP checks and its graded average-mode discrepancy, and sum HP per class level so a multiclass character stops being measured against its first class's hit die × total level. (vtt-modules commit 4898198 on the new branch version/dnd5e-0.5.0; the module's version is bumped to 0.5.0 there, 0.4.0 being spoken for by the unpublished AI-authoring draft. 500 module tests, up from 474. Severities follow the phase doc — rolled HP below the dice floor is impossible and errors; above the ceiling only warns, since Tough, a Hill Dwarf's trait and an Amulet of Health all add hit points no die rolled. Every issue already carries a ruleId.)

  • [x] 2e.2 — validate() and the catalog skeleton. dnd5e exposes validateHp and nothing else — no validate, no validationRules — so v0.49.0's panel renders empty for every 5e character today. Add both, declared in sheet order (identity and classes → ability scores → attributes → skills → feats), every issue carrying ruleId and anchored to a stable field per §5 of the phase doc. Edition-parameterised the way the rest of the module is (buildValidate(edition)), not forked. (vtt-modules commit 0dc1e00. validate.ts is composition only — each rule group gets its own file and its own task — and validationRules.ts carries a banner for all five categories whether or not they hold rules yet, so later groups insert into the panel's running order rather than appending to it. Two guards catch the mechanical failure modes 2e.3–2e.6 could each introduce: an issue whose ruleId nobody declared can be shown but never muted, and a rule declared but never emitted offers a switch that controls nothing. Both read the sources for ruleId: literals rather than running them, so they hold for untested paths. Also fixed: getConModifier threw on a character with no attributes array, and validate runs on every change to every character including half-imported ones. 514 module tests.)

  • [x] 2e.3 — Classes and levels. class-levels-match-character-level (error, the same rule S8 has, stating the delta in the message: "7 of 10 levels assigned"). multiclass-ability-prerequisite — 13 in the abilities both classes require — as info, since a table's house rules are invisible from here and this is advice, not a finding. (vtt-modules c97d635. Adds class-missing (info — every character starts with none). The multiclass table carries both allOf and anyOf, since Fighter reads as Strength OR Dexterity and Monk as Dexterity AND Wisdom, and collapsing them would misreport exactly the classes hardest to qualify for. 533 module tests.)

  • [x] 2e.4 — Ability scores, by generation method. The method is what makes a set of scores right or wrong, so the rules read metadata.abilityScoreMethod and check what that method claims: standard-array → the six base scores are a permutation of 15 14 13 12 10 8; point-buy → every score in 8–15 and the cost totals exactly 27; rolled → the base scores still match the values that were rolled and assigned (2d.1's record), which is the only check rolling admits; manual → nothing is checked at all, which is the whole point of offering it. Plus ability-score-missing and ability-max-20 (base + racial + ASI ≤ 20; magic items are their own component and exempt). custom-array is retired from the declared methods — it is not an official method and has no array to validate against, so it was only ever manual under a second name. The racial component is recognised but not validated this release. (vtt-modules a41d92e. New readAbilityComponents reads components by EXACT name — the loose readers serve imported monsters and would count "base attack bonus" as a base score, quietly ruining every array check. ability-max-20 and unrecognized-ability-component are two sides of one fact: an unrecognised component is exempt from the cap because it is where a magic item lands. custom-array retired from supportedMethods too; applyTemplateStats still handles the stored value. Declares 2d.2's four roll formulas. 564 module tests.)

  • [x] 2e.5 — ASIs are virtual feats. The model, and it is the whole of it: every ASI opportunity is a feat slot, and what fills it grants ability points — a plain feat grants 0, a half-feat 1, and "Ability Score Improvement" (a virtual feat the player slots like any other) grants 2. Expected points is then just the sum of what is slotted, and actual points is the sum of the asi components across the six abilities. Two numbers, one comparison, and half-feats stop being a special case — which is what the earlier ceil(points / 2) + feats formulation could never express. Rules: - asi-points-overspent (error) — actual > expected. A character who has slotted nothing and pressed + once trips this immediately, since their expected total is 0. - asi-points-unspent (info) — actual < expected; says how many are left to place. - no-extra-feat-slots (warning) — more entries slotted than opportunities earned. - no-empty-feat-slots (info) — an opportunity not yet filled. Normal mid-build.

    Opportunities are ASI levels reached, keyed off `classLevels` **per class** rather than total
    character level: today's `getAsiCountAtLevel` hands a Fighter 6 / Wizard 1 the Fighter's
    level-6 ASI at character level 6, and would hand a Fighter 1 / Wizard 6 the same. 2024
    characters get one extra slot for the Origin Feat their background grants (`grantsOriginFeat`
    already says so).
    *(`vtt-modules` `467e534`. Also **retired the duplicate validator**: `abilityScoreConfig.validateAbilityScores` carried its own standard-array, point-buy and ASI-budget checks emitting issues with **no `ruleId`**, so the platform could display them and never mute them — a rule a GM had switched off kept reporting through that path. Its ASI reader assumed every ASI is spent as two points, which this model contradicts by design. `availableChoices` stays; it is a prompt, not a finding. `getAsiCountAtLevel` deprecated. 591 module tests.)*
    
  • [x] 2e.6 — Skills. skill-proficiency-count (warning) — proficiencies ≤ what the class grants, plus two for a background this release cannot yet see; skill-not-on-class-list (info, because backgrounds and species grant off-list skills constantly); expertise-not-available (warning) — expertise on a character whose classes grant none. Tightens in v0.53.0, when the Background section makes the +2 exact rather than assumed. (vtt-modules 259c17b. Multiclassing counted properly — 5e's multiclassing table grants a skill only for Bard, Ranger and Rogue, so which class came FIRST changes the answer. A homebrew class silences the count and the list entirely rather than reporting a shortfall that is an artefact of not knowing. The projection's inline skill-tier resolution moved to a shared resolveSkillTier. 611 module tests.)

  • [ ] 2e.7 — Candidate rules, deliberately not this release. Recorded so they are not lost: attack to-hit and damage (waits on Buffs, as System 8's does), starting equipment, attunement and encumbrance (no equipment rules, by decision), and duplicate-trait.

2f — Ability scores and feat slots on the sheet (apps/web)

  • [x] 2f.1 — The generation modal. Level-1 score generation moves into a modal that owns the method picker and whatever UI that method needs. Today's inline AbilityScoreMethodPicker becomes the modal's first control rather than a Select on the sheet, and Build mode gains a Regenerate initial stats button that re-opens it. Generation writes the base component only. (Landed with 2f.2 — a shell with no body is not a shippable unit, and the assignment body is what makes the standard array work. The modal holds a draft and commits once: generating is the one edit on this sheet that is not incremental, and saving as you go would write scores nobody chose and make Cancel a lie. Apply is ONE write — six saveAttributeComponents calls would each fold into the same stale character.attributes and lose five of the six — and it upserts onto the existing components, so re-rolling never silently refunds the ASIs a character has spent. The old AbilityScoreMethodPicker is deleted; its method label and re-roll tally moved beside the button.)
  • [x] 2f.2 — Standard array assignment. The six fixed values, assigned to abilities by drag-and-drop, each usable once. (By click as well as drag — drag alone is unusable on a touch screen and this sheet is used on tablets. Pick a value, then the ability; dropping onto a filled ability swaps them, which is 2d.1's permutation rule and the only behaviour that cannot strand a value where the UI can no longer reach it. The body is shared with rolling (2f.4): both are "six values exist, say which ability each belongs to", differing only in provenance.)
  • [x] 2f.3 — Point buy. Steppers per ability over the 8–15 range with the SRD's cost curve and a running "n of 27 spent" total. Overspending is shown, not prevented — the modal obeys the same never-block rule as the panel. (The cost curve is the MODULE's: new AbilityScoreConfig.pointBuyCosts — MODULE_API_VERSION 1.15.0 → 1.16.0 — joining pointBuyBudget and pointBuyRange, which were already there. A module that declares no table gets the steppers and no total, because a wrong total is worse than none — a player would build a character against it. The steppers ARE bounded by the range, since outside 8–15 it is simply not point buy and the table prices nothing there; the budget is not, which is where never-block applies.)
  • [x] 2f.4 — The roller. Rolls all six through the built-in dice engine using the formula picked from 2d.2's list, assigns each roll to an ability by drag-and-drop and by click (drag alone is unusable on touch, and this sheet is used on tablets), and offers a Re-roll all button that increments the persisted tally. The tally is displayed, not enforced — no cap, per the overhaul's recorded decision. (The chat roller could not do this. parseDiceExpression matches ^(\d+)d(\d+)([+-]\d+)?$ and nothing else — no keep-highest, no drop-lowest, no re-roll — so every formula worth offering fails it except 3d6-straight. Rather than widen a parser the whole app rolls through, 2f.4 adds a small evaluator for this one closed grammar (NdS + rX + khN/klN/dlN/dhN + ±N). The two jobs are genuinely different anyway: the chat roller produces a TOTAL for a message, this produces six independent scores and keeps each one's dice in ROLL order with the dropped ones marked, so a player can see the 4, 4, 3 they discarded. A formula outside the grammar fails visibly rather than rolling something else. Re-rolls are counted in the draft and committed on Apply — a player who rolls four times and cancels has re-rolled nothing, because nothing was ever theirs — and the tally is added AFTER the method patch, which zeroes it when the method changes.)
  • [x] 2f.5 — +/− means an ASI. Closes the decision v0.51.0 deferred to this release ("whether the method constrains the steppers or merely records intent"): the steppers in Build mode write the asi component, never base, because base scores are now the modal's exclusive output. The 5e schema has declared an asi component since CS-1 landed, so this is a wiring change, not a data-model one. (Built as CS-1.1 AbilityScoreSchema.stepComponent — MODULE_API_VERSION 1.13.0 → 1.14.0, additive, and the module NAMES the component rather than setting a flag, so the platform never learns what an ASI is. A declared step component takes the whole decision: no base, no spill, and no cap beyond a floor of zero, because what a player may spend is the ASI budget and the never-block rule makes that validation's to report rather than the stepper's to refuse. System 8 declares none and steps exactly as it did. vtt-modules a7d6368 declares 'asi'; without it the stepper would raise a rolled 15 toward 20 and produce a score nobody rolled, which no rule could object to since base is not what the budget counts.)
  • [x] 2f.6 — Feat slots on the 5e sheet. 5e's feat subtype declares renderMode: 'flat' and no schedule, so a 5e character has no feat slots at all today. Declare the schedule through 2d.3's slotsAtLevel(character) so it reads the class mix. (vtt-modules ef074eb. The common [4, 8, 12, 16, 19] stays declared as the static fallback the platform uses on a surface with a level but no character — right for the single-class majority and never over-reporting. 2024 adds one at level 1 for the Origin Feat.)
  • [x] 2f.7 — "Ability Score Improvement" in the feat picker. The virtual feat 2e.5 counts. It slots like any other feat, grants 2 ability points, and can be taken as many times as there are slots — so the sheet shows what the player chose at each ASI level, which a disappearing slot never could. Half-feats are ordinary feats with 2d.4's grant set to 1. (vtt-modules 5296bb1, plus a platform half. Needed a new CS-5 ContentRecord.repeatable — MODULE_API_VERSION 1.14.0 → 1.15.0 — because the sheet refuses a duplicate by slug and syncCharacterCollection DIFFS by slug, so without it the second ASI silently overwrote the first. The wrinkle worth knowing: these are name-keyed collections, so a repeated trait needs a distinct NAME, and nameToSlug is replace(/[^A-Za-z ]+/g, '') — it strips DIGITS, so "(2)", " 2" and "#2" all slugify back to the unsuffixed name and collide with the very instance they were meant to distinguish. Letters are all that survive, which makes roman numerals the scheme rather than an affectation: "Ability Score Improvement II". Widening nameToSlug was the alternative and is far worse — every slug already stored was computed under the current rule. The first instance keeps its plain name, and gaps are reused.)

2h — Ship the modules

  • [x] 2h.1 — Publish the System 8 releases still sitting on branches. v0.49.0's own 2b.7 is still open: system8 0.7.0 (the whole validation rewrite) is on version/system8-0.7.0 and 0.6.3 (the content-provider auth fix from v0.51.0) is on main, while modules.hosted.json pins 0.6.2. Both need tags pushed before any platform deploy.
  • [x] 2h.2 — Publish @purple-cat-games/dnd5e. v0.51.0's 5E-4 is likewise still open — 0.3.0 is prepared and pinned but never tagged. Tag 0.3.0, then release this version's work as 0.5.0 (0.4.0 is already spoken for by the unpublished AI-authoring draft). Mind the hazard: more than three tags in one push and GitHub drops every tag event.
  • [x] 2h.3 — Bump modules.hosted.json to the published versions, before the platform deploys — modules install at image build time, so a deploy ahead of the tags looks for versions that are not on the registry.

Fixes from playtesting (2026-08-15)

  • [x] The Ability Scores section vanished until a reload. A load-order race, not a data problem: resolveAttributeGroupId answers undefined for every attribute while propertyDefinitions are still being fetched, so nothing is "populated" and the emptiness filter dropped every attribute-group section. "No definitions yet" is unknown, not empty — the filter now runs only once it can answer the question. An empty section is a far smaller problem than a missing one: a player looking for somewhere to put their scores can work with a heading and no rows, and cannot work with nothing at all.

  • [x] HP existed nowhere, and validation reported it twice — module-side, vtt-modules b75304e. Nothing creates an HP attribute, and the projection emitted the row only when one was stored, so every 5e character had no HP field to read or edit while the panel showed both "No HP attribute found" and a mismatch against a max of 0. HP is now derived from the class hit dice when unstored, validation measures against the same number, and hp-missing narrows to HP that can be neither read nor derived.

  • [x] Taking the Ability Score Improvement granted no points — module-side, same commit. The 5e trait picker reduces an SRD record to the fields worth storing and was reducing away both repeatable and metadata, so the ASI arrived granting 0. Also adds an "Ability points granted" field to the custom-feat form, which is how half-feats get authored — the SRD ships exactly one feat.

  • [x] A raw-attributes section, collapsed, at the foot of the sheet. Every other attribute surface is curated by the module's schema, so an attribute the schema does not mention is invisible and one the projection derives has no stored row to inspect — which is precisely why the HP problem was unanswerable from the UI. This shows every attribute as stored, every component, freely editable in Build mode, with no schema and nothing derived. Deliberately plain, deliberately last, and not in the jump menu: the jump menu is for the parts of a character, and this is a part of the data. Expected to be removed once the derived-versus-stored distinction stops surprising people.

  • [x] Apply in the generation modal 400'd. It PATCHed { attributes, metadata }, and the server's patch guard rejects an array sent alongside any other field — sub-collections are meant to travel alone. Now two sequential writes, attributes first: if the second fails the character has its new scores and no recorded method, which the next Apply puts right, where the other order leaves a character claiming a method its scores never came from. Both payloads are still computed up front from ONE snapshot, so the original reason this is not seven writes survives. apps/web has no server, so the client suite happily asserted a payload the API rejects — the rule is now pinned in server-core, which is what CI runs.

  • [x] The validation panel never appeared. Nothing was wrong with the rules: the platform loads the module through main: "dist/index.js", and dist/ had never been rebuilt, so validate and the CS-3.3 catalog did not exist as far as the app was concerned. Second time this trap has bitten (System 8's Character Sheet API, v0.44.0), so dnd5e now has a test asserting every non-test module under src/ has a .js beside it in dist/, with the build command in the failure message — the symptom is a feature silently not being there, which gives no hint that a build is what is missing.

Misc

  • [x] When using the 'new character' option in Create Token, after the Character Context pops up in build mode, focus the character's name input field automatically and preselect its contents. (A one-shot uiStore.pendingCharacterNameFocus holding the character id, not a boolean: the sheet mounts behind an async fetch, so a player who opens something else in the meantime must not have that sheet's name field grabbed. Consumed and cleared by MetadataSection once the focus has actually happened — clearing it where it is set would race the mount it exists to wait for. select() as well as focus(), since "New Character" is a name nobody chose and the next keystroke should replace it. Reached through a new AsyncTextInput inputRef prop rather than ref, which React 18 silently discards on a plain function component.)