# TTRPG Manager — Full Code Audit

_Generated 2026-06-09 from an automated multi-agent audit (20 specialist analyzers; every calculation/data-accuracy claim independently re-derived from canonical 5e/PF2e rules by an adversarial verifier). This file is a work product — safe to delete or move._

**Tally:** 99 findings — 38 accuracy/data defects confirmed, 2 refuted (excluded below), 59 usability/feature findings (not independently verified, lower-confidence by nature).

Severity shown as `severity` or `analyst→verifier-corrected` where the independent check adjusted it.

---

## Resolution status (branch `fix/audit-2026-06-09`)

Most confirmed accuracy/data defects and the high-impact UX traps are **fixed** on this branch (8 commits, +1716/−125, full test suite green, production build passes). Highlights of what landed:

- **PF2e math:** AC level+proficiency scaling, potency-not-on-damage, Multiple Attack Penalty, striking runes, level-1 slot counts, Refocus = 1 point.
- **Conditions:** per-statistic PF2e penalties, 5e Exhaustion levels, Slowed/Fatigued correctness, Frightened end-of-turn decay.
- **Spell data:** Wish backlash excluded, Magic Missile dice, multi-save spells.
- **Builder:** racial ASI + background/trait skill proficiencies applied; canonical skill lists/counts; **PF2e template softlock fixed** (verified in-app); manual ability mode; array-swap. **PF2e boost-based ability generation** (ancestry/background/class/free boosts + flaw, replacing the wrong 5e arrays) — verified in-app.
- **Monsters:** all speeds + saves/skills rendered; passive Perception computed (fixes the 7 wrong ones).
- **Spellcasting:** slot-level picker (warlocks can cast; upcasting works). PF2e compendium de-duplicated. Drow ASI corrected.
- **Safety:** confirm-before-delete across world content; dice clear scoped to active campaign; sessionLog in backup + cascade; 404 route; NumberField no longer snaps to min while typing; massive-damage / death-at-0 reminders in combat.
- **Live play:** **player WebSocket-reconnect** now keeps the seat (stable client playerId + 5-min server grace + anti-hijack) so a brief drop no longer silently swallows HP edits/rolls; **GM concentration flag** on combatants so the post-damage save prompt actually fires for monsters/NPCs (verified in-app); inline Level edits now warn they don't recompute HP/slots.

All CRITICAL and HIGH confirmed defects are now addressed. **Remaining deferred** (medium severity or net-new features): subclass/class-feature/feat mechanical effects; PF2e Drained HP reduction; delay/ready combat action; net-new features (D&D Beyond import, loot builder, XP tracking, real multiclass model). The `--app-faint` contrast tweak was left to the designer.

## 🎲 Calculation accuracy (23)

#### [MEDIUM→HIGH] PF2e AC omits the level-scaling armor proficiency bonus
*system: pf2e · dimension: calc-pf2e-core · _verified: confirmed_*

- **Where:** `src/lib/rules/pf2e/index.ts:78-87`
- **Issue:** baseArmorClass for PF2e computes AC as baseAc + Dex(capped) + misc (with armor) or 10 + Dex + misc (unarmored). It never adds the character's armor/defense proficiency, which in PF2e contributes (level + rank bonus) to AC and scales every level. The comment on line 85 admits proficiency is 'folded into armorBonus for the MVP sheet', i.e. the user must manually enter it; nothing auto-computes or auto-fills it (CreationWizard.tsx:498 even passes armorBonus: 0). So a defended character's AC is wrong by the entire proficiency amount by default.
- **Expected:** PF2e AC = 10 + Dex(capped by armor) + proficiency(level + rank bonus) + item bonus. Worked example: a level-5 fighter trained (rank +2) in their armor, Dex +1 (cap permitting), unarmored: AC = 10 + 1 + (5 + 2) + 0 = 18. Current code returns 10 + 1 + 0 = 11 unless the GM manually types +7 into armorBonus. Unlike to-hit, saves, and skills (which all auto-scale), AC silently fails to scale with level.
- **Fix:** Thread an armor/AC proficiency rank into CharacterRulesInput (e.g. acRank) and add pf2eProficiency(level, acRank) to baseArmorClass, mirroring how saves and skills already work. Keep armorBonus for shield/item bonuses only. At minimum, surface in the UI that AC excludes proficiency so GMs know to compensate.

#### [HIGH] PF2e weapon attack wrongly adds the item (potency) bonus to damage
*system: pf2e · dimension: calc-pf2e-weapons-misc · _verified: confirmed_*

- **Where:** `src/lib/rules/pf2e/index.ts:105`
- **Issue:** weaponAttack computes dmgMod = (abilityMod) + item, applying the flat itemBonus to the damage expression in addition to the attack roll. In PF2e the item bonus to an attack comes from a weapon potency rune (+1/+2/+3) that modifies ONLY the attack roll; it never adds a flat bonus to damage. Damage from magic weapons comes from striking runes (extra weapon dice), not a flat item bonus. The 5e implementation does the same (dnd5e/index.ts:108-110), which is correct for 5e but was copied into PF2e where it is wrong. The types.ts:33 JSDoc 'flat magic/enhancement bonus to hit and damage' and the UI label '+item' (AttacksSection.tsx:71) cement the incorrect dual application.
- **Expected:** In PF2e the potency/item bonus applies only to the attack roll. Worked example: level 3 fighter, STR 18 (+4), trained (3+2=+5), +1 potency weapon, 1d8 -> to-hit = +4 +5 +1 = +10 (correct in code); damage = 1d8+4 (STR only). Code returns damage '1d8+5' (4 STR + 1 item), which is +1 too high. For a +3 striking weapon the code would be +3 too high on the flat damage and still miss the extra striking dice entirely. Fix: drop `+ item` from dmgMod in the PF2e weaponAttack (keep it only in toHit), and model striking runes as additional weapon dice rather than a flat damage bonus.
- **Fix:** Remove the item bonus from the PF2e damage modifier (apply potency only to toHit). Optionally add a separate 'striking' field that multiplies the weapon's damage dice (striking=2, greater=3, major=4) to model magic-weapon damage correctly.

#### [HIGH] PF2e clumsy/enfeebled/drained/stupefied all collapse into one undifferentiated status penalty
*system: pf2e · dimension: calc-conditions-damage · _verified: confirmed_*

- **Where:** `src/lib/mechanics/conditionEffects.ts:51-66; src/lib/mechanics/creatureState.ts:67`
- **Issue:** All PF2e valued conditions are mapped to a generic `statusPenalty: true` and deriveState aggregates them with `statusPenalty = Math.max(statusPenalty, cond.value)` into a single number. But each condition penalizes a DIFFERENT statistic: clumsy -> Dexterity-based (AC, Reflex, ranged attacks); enfeebled -> Strength-based (melee attack/damage, Athletics); drained -> Constitution-based (Fortitude); stupefied -> Int/Wis/Cha (spell attacks, spell DCs, Will). Because they target different stats they should never be compared against each other, yet the code takes the single worst value and presents one undifferentiated '−N status' badge. A creature with Enfeebled 2 + Clumsy 1 should take −2 to Strength rolls and −1 to Dexterity rolls; the code yields a single −2 with no indication which stat it applies to.
- **Expected:** Each valued condition should carry which statistic family it penalizes so the GM/tracker can apply it to the correct roll. Worked example: a creature Enfeebled 2 and Clumsy 1 attacking with a Strength melee weapon takes −2 (enfeebled), while its AC/Reflex take −1 (clumsy); the current model reports a single '−2 status' with no stat, implying −2 to everything including AC/Reflex, which is wrong.
- **Fix:** Tag each PF2e valued condition with its target statistic (str/dex/con/mental/all) and aggregate per-statistic (worst value within the same statistic), instead of one global max. Render distinct badges (e.g. '−2 Str rolls', '−1 Dex/AC').

#### [HIGH] PF2e Slowed treated as a status penalty to checks/DCs instead of an action loss
*system: pf2e · dimension: calc-conditions-damage · _verified: confirmed_*

- **Where:** `src/lib/mechanics/conditionEffects.ts:64; src/lib/rules/conditions.ts:57`
- **Issue:** Slowed is mapped to `{ statusPenalty: true }` and is declared `valued: true`. deriveState therefore turns a 'Slowed 2' into a −2 status penalty to checks/DCs and shows a '−2 status' badge. Slowed in PF2e is purely an action-economy condition: 'When you regain your actions at the start of your turn, reduce the number of actions you regain by your slowed value.' It imposes NO penalty to any check, DC, AC, or save.
- **Expected:** Slowed should contribute no statusPenalty. Worked example: a Slowed 2 fighter rolling a Strike should roll at its normal modifier (slowed only costs it actions that turn); the code instead applies −2 to the roll, a fabricated penalty.
- **Fix:** Remove `statusPenalty` from slowed (and represent it, if at all, as an actions-lost note/badge). Fatigued likewise is a flat −1 to AC/saves regardless of value and should not use value-scaled statusPenalty.

#### [CRITICAL→HIGH] Racial ability score increases are never applied to the character
*system: 5e · dimension: data-classes-races-feats · _verified: confirmed_*

- **Where:** `src/features/characters/builder/CreationWizard.tsx:97-99,196-200,243-265; src/lib/rules/progression.ts:111`
- **Issue:** The wizard parses a race's ASI text only into a display string (`asiSummary`/`meta`) and never adds it to the ability scores. `buildCharacter` returns `abilities: choices.abilities` unchanged, and `finish()` spreads `...built` into the saved character. So a Mountain Dwarf Fighter with STR 15 is saved as STR 15, not 17; a Human is never given +1 to all six scores.
- **Expected:** A race's ASI must be added to the final ability scores. Worked example: SRD Hill Dwarf adds +2 CON (+1 WIS); a character built with CON 14 should be saved with CON 16 (mod +3), giving +1 HP/level and better CON saves. Standard-array Human (+1 to all) with the array 15/14/13/12/10/8 should become 16/15/14/13/11/9. Currently every score is stored exactly as picked, so HP, AC (Dex), attack/save/skill mods, and spell save DCs are all systematically too low for every race except (coincidentally) one with no ASI.
- **Fix:** Parse each race's ASI into a structured {ability: bonus} map (the regex at lines 97-99 already extracts ability+amount) and add it into `abilities` before calling buildCharacter / saving, including the 'one/two of your choice' cases via a small picker. Handle Human '+1 each' explicitly.

#### [HIGH] PF2e character builder uses 5e ability arrays and never applies ancestry/background/class attribute boosts
*system: pf2e · dimension: data-pf2e · _verified: confirmed_*

- **Where:** `src/features/characters/builder/CreationWizard.tsx:142-156, 196-200; src/lib/rules/abilityGen.ts:6-20`
- **Issue:** Ability scores for PF2e characters are generated with the 5e system: STANDARD_ARRAY = [15,14,13,12,10,8], 27-point point-buy (POINT_BUY_BUDGET 27, min 8 / max 15), or 4d6-drop-lowest. PF2e's actual method (every score starts at 10, then ancestry +2/+2/free, background +2/+2, class key +2, and four free +2 boosts, with the +1-instead-of-+2 rule above 18) is not implemented. The ancestry's `attribute` boosts are read only to build a display label (line 110-111) and are never added to abilities; background and class boosts are ignored entirely. buildCharacter (line 196) receives the raw 5e-array abilities unchanged.
- **Expected:** A PF2e level-1 character starts every ability at 10 and applies boosts: e.g. a Human Fighter (key STR) with Warrior background (STR/CON) choosing free boosts to DEX/CON/CON/WIS would reach STR 16, DEX 14, CON 14, WIS 12 — not the 5e 15/14/13/12/10/8 spread. Because every AC, save, attack, spell DC and HP derives from abilities, generated PF2e characters have systematically wrong scores.
- **Fix:** Add a PF2e boost-based ability step (start at 10, apply ancestry attribute[] + background + class key + 4 free boosts with the >=18 +1 rule already present in applyIncreases at progression.ts:185). The data needed (ancestry attribute[], class keyAbilities) is already bundled.

#### [HIGH] PF2e AC omits the level + armor-proficiency term
*system: pf2e · dimension: features-gap*

- **Where:** `src/lib/rules/pf2e/index.ts:78-87`
- **Issue:** baseArmorClass for pf2e returns baseAc + min(dex, dexCap) + armorBonus and the comment concedes 'item/proficiency folded into armorBonus for the MVP sheet'. PF2e AC = 10 + Dex(capped) + proficiency(level + rank bonus) + item bonus. The entire level+proficiency contribution is missing unless the GM manually types it into armorBonus, which also doubles as the shield/misc field.
- **Expected:** AC should add pf2eProficiency(level, armorRank). Worked example: a level-5 PF2e fighter (Dex +2) trained in their armor should get +5 (level) +2 (trained) = +7 from proficiency. With studded leather (base 12) that's 12 + 2 + 7 = AC 21; the current code returns 12 + 2 + armorBonus, so without a manual +7 fudge the sheet shows AC 14 — 7 too low.
- **Fix:** Add an armor proficiency rank to equippedArmor (or character) for pf2e and fold pf2eProficiency(level, rank) into baseArmorClass, the same way saves/skills already add it. Keep armorBonus for true item/circumstance bonuses only.

#### [MEDIUM] Pf2e Refocus restores the entire Focus Point pool instead of 1 point
*system: pf2e · dimension: calc-mechanics-resources · _verified: confirmed_*

- **Where:** `src/lib/rules/rest.ts:22-24, src/lib/rules/pf2e/index.ts:15`
- **Issue:** The Refocus option is defined with recovers: ['short'] and a comment 'Refocus restores 1 Focus Point' (pf2e/index.ts:14-15). But applyRest refreshes every short-recovery resource to its max: `opt.recovers.includes(r.recovery) ? { ...r, current: r.max } : r` (rest.ts:22-24). So a Focus Point pool modeled as recovery:'short' is fully restored by a single Refocus.
- **Expected:** Per the PF2e Core Rulebook, the Refocus activity recovers exactly 1 Focus Point (baseline). A caster with a 3-point focus pool who has spent all 3 should be at 1 after one Refocus, not 3. Worked example: focus {current:0, max:3, recovery:'short'} after Refocus → expected current 1; code yields current 3 (a 3x over-restore).
- **Fix:** Distinguish 'recover all of this tag' (5e short-rest: pact, channel divinity) from 'recover 1 of this tag' (pf2e Refocus). Either give RestOption a per-tag recoverAmount, or model focus points with a dedicated recovery tag whose rest restores +1 (clamped to max) rather than = max. Long-rest 'Rest for the Night' should still restore the full pool.

#### [MEDIUM] 5e Prone grants advantage to all attackers, ignoring ranged disadvantage
*system: 5e · dimension: calc-conditions-damage · _verified: confirmed_*

- **Where:** `src/lib/mechanics/conditionEffects.ts:38`
- **Issue:** Prone is mapped to `attacksAgainst: 'advantage'` unconditionally. The 5e rule is range-dependent: an attack against a prone creature has advantage only if the attacker is within 5 feet (melee); otherwise (ranged/beyond 5 ft) it has DISADVANTAGE. The data file states this correctly (conditions.json:90: 'Otherwise, the attack roll has disadvantage'), but the enforced table always yields advantage, overstating the benefit for ranged attackers.
- **Expected:** A ranged attacker shooting a prone target should have disadvantage, not advantage. Worked example: an archer 30 ft from a prone goblin should roll with disadvantage; the derived badge/state tells the GM the goblin is 'Attacked w/ adv.', which is wrong at range.
- **Fix:** Since the engine lacks attacker range, surface prone's against-attack modifier as range-conditional in the badge text (e.g. 'Adv. if melee, disadv. if ranged') rather than a flat advantage, or thread attacker distance into deriveState. Note: paralyzed/unconscious also omit the within-5ft auto-crit rule, which is acceptable since crits aren't modeled, but prone's ranged case actively misstates the modifier.

#### [MEDIUM] Sight rays leak through wall corners and endpoints (touching treated as non-blocking)
*system: both · dimension: calc-map-geometry · _verified: confirmed_*

- **Where:** `src/lib/map/vision.ts:28-37 (segmentsIntersect), used by computeVisibleCells:72`
- **Issue:** segmentsIntersect only returns true for a strict proper crossing (all four orientation signs strictly opposite). It explicitly returns false for collinear/touching cases, including when a sight ray passes exactly through a wall's endpoint or through the shared vertex where two wall segments meet (a corner). So a viewer can see 'around'/through a wall corner and through wall joints that should fully block sight. Reproduced: two walls forming an L-corner at (100,100) — wall A (0,100)-(100,100), wall B (100,100)-(100,200) — with eye at (50,50) and target (150,150); pathBlocked returns false (the diagonal ray slips through the shared corner vertex). Also a cell center landing exactly on a wall, or a ray grazing a single wall's endpoint, both return non-blocked. The 0.01 eye nudge does not fix corner cases because cell centers are not nudged and rays can still pass exactly through a vertex.
- **Expected:** A sight ray that touches/crosses a wall endpoint or a shared corner vertex of two contiguous blocking segments should be treated as blocked (rooms must not leak vision/fog at their corners). Canonical: line of sight is blocked if the segment from eye to cell intersects any wall, including endpoint contact. Worked example: viewer just inside an L-shaped room corner should NOT reveal the cell diagonally outside the corner; current code reveals it.
- **Fix:** Treat endpoint/collinear contact as blocking in segmentsIntersect (return true when any d is 0 and the touch point lies on both segments), OR cast the ray to a point slightly inset toward the eye and test multiple sample points per cell, OR build walls as closed polygons and block on any boundary touch. Add tests for: ray through a wall endpoint, ray through a shared corner vertex of a polyline wall, and cell center exactly on a wall.

#### [HIGH→MEDIUM] normalizeSpell5e snapshots phantom 1d10 necrotic damage onto Wish
*system: 5e · dimension: data-spells · _verified: confirmed_*

- **Where:** `src/lib/mechanics/normalize/spell.ts:28-43 (parseDamage); src/data/srd/spells-srd.json (wish)`
- **Issue:** parseDamage runs a global regex /(\d+d\d+)\s+(\w+)\s+damage/ over the whole description. Wish's description contains the backlash clause 'you take 1d10 necrotic damage per level of that spell'. That text is the caster's self-inflicted penalty for casting Wish, not damage the spell deals to a target. The normalized output is damage:[{dice:'1d10',type:'necrotic'}], which is snapshotted onto the SpellEntry (CompendiumPage.tsx:352) and persisted on the character sheet.
- **Expected:** Wish deals no inherent spell damage; its normalized `damage` array should be empty ([]). Concrete: a Wish SpellEntry should show no damage dice. Instead the snapshot stores 1d10 necrotic, implying the spell deals 1d10 necrotic to a target, which is false.
- **Fix:** Do not derive a spell's offensive damage from free-text regex. At minimum, exclude clauses containing 'you take'/'per level of that spell'/self-damage backlash. Better: source structured damage from a per-spell data field rather than prose, or gate parseDamage to only the sentence(s) describing the save/attack effect.

#### [MEDIUM] parseSave picks the FIRST save ability mentioned, snapshotting the wrong save for multi-save spells
*system: 5e · dimension: data-spells · _verified: confirmed_*

- **Where:** `src/lib/mechanics/normalize/spell.ts:18-25 (parseSave)`
- **Issue:** parseSave uses a single (non-global) regex match and returns the first '<ability> saving throw' found. For spells that mention more than one ability, the first mention is not always the operative save. Irresistible Dance: desc first says the target 'has disadvantage on dexterity saving throws', then 'makes a wisdom saving throw to regain control'. parseSave returns ability:'dex' though the actual save is Wisdom. 11 SRD spells mention multiple save abilities (contagion, earthquake, enlargereduce, irresistible-dance, prismatic-spray, prismatic-wall, sleet-storm, slow, storm-of-vengeance, symbol, wall-of-ice).
- **Expected:** Irresistible Dance's save is a Wisdom save (made as an action to end the effect); the snapshot should be {ability:'wis',basis:'negates'}. Code returns {ability:'dex',basis:'negates'}. (Most others happen to put the operative save first, so impact is concentrated in irresistible-dance, and potentially contagion/symbol where the first-listed ability differs from the casting save.)
- **Fix:** Prefer the save attached to the spell's primary effect (e.g. the one paired with 'must succeed on'/'or be <condition>'/'or take') rather than the first textual match; or source the save from a structured per-spell field. Add a regression test for irresistible-dance and contagion.

#### [HIGH→MEDIUM] PF2e builder grants too many trained skills at level 1 for ~13 of 25 classes
*system: pf2e · dimension: data-pf2e · _verified: confirmed_*

- **Where:** `public/data/pf2e/classes.json (skillCount) consumed at src/features/characters/builder/CreationWizard.tsx:163-164`
- **Issue:** The builder computes trained-skill count as selectedClass.skillCount + max(0, INT mod), reading skillCount from classes.json. That data-file value is derived in src/lib/ruleset/normalize.ts:125 as `trainedSkills.additional + trainedSkills.value.length`, i.e. it ADDS the class's auto-trained (fixed) skills to the number of FREELY chosen skills. The builder then lets the player freely choose that inflated total. Result: Champion shows 3 (canon 2), Bard 6 (canon 4), Thaumaturge 7 (canon 3), Rogue 8 (canon 7), Investigator/Ranger/Swashbuckler 5 (canon 4), Alchemist/Barbarian/Inventor/Kineticist/Oracle/Psychic 4 (canon 3), Cleric/Druid/Wizard 3 (canon 2). 13 classes over-count.
- **Expected:** PF2e trained skills at level 1 = a class-specific number of FREELY chosen skills + INT mod; the class's fixed/auto-trained skills are granted separately, not chosen. Worked example: a Champion with INT +1 trains 2 freely-chosen skills + 1 (INT) = 3 chosen skills, and is separately auto-trained in Religion. The builder instead offers 3 + 1 = 4 freely-chosen skills (counting Religion as a free pick). Correct per-class free counts: Alchemist 3, Barbarian 3, Bard 4, Champion 2, Cleric 2, Druid 2, Fighter 3, Inventor 3, Investigator 4, Kineticist 3, Magus 2, Monk 4, Oracle 3, Psychic 3, Ranger 4, Rogue 7, Sorcerer 2, Summoner 3, Swashbuckler 4, Thaumaturge 3, Witch 3, Wizard 2.
- **Fix:** In normalizeFoundryClass set skillCount to trainedSkills.additional only (the freely-chosen count), and seed the fixed trainedSkills.value as pre-applied trained skills. Then the builder's `+ INT mod` math matches PF2e. Re-run scripts/fetch_foundry_pf2e.ts to regenerate classes.json.

#### [MEDIUM] PF2e full casters are granted 3 spell slots per rank at level 1 (canon is 2)
*system: pf2e · dimension: data-pf2e · _verified: confirmed_*

- **Where:** `src/lib/rules/pf2e/progression.ts:60-70 (pf2eSlots)`
- **Issue:** pf2eSlots gives every available spell rank exactly 3 slots (1 for rank 10) regardless of level. At character level 1 a full caster therefore gets 3 first-rank slots, but PF2e grants only 2 spell slots of 1st rank at level 1 (the 3rd slot arrives at level 2). The function comment acknowledges it is 'approximate and editable'.
- **Expected:** PF2e spell slots per rank scale by level: a new caster has 2 slots of 1st rank at level 1, 3 at level 2; each rank generally reaches 3 slots only a level or two after it unlocks, and the highest rank often has fewer. Worked example: level-1 Wizard = 2 first-rank slots (+ cantrips), not 3; level-3 Wizard = 3 first-rank + 2 second-rank, not 3/3.
- **Fix:** Encode the standard PF2e per-level slot table (or at minimum: 2 slots at the level a rank unlocks, 3 thereafter, highest rank reduced). Lower priority since slots are editable on the sheet.

#### [LOW] Proficiency floors level to a minimum of 1 (level-0 edge)
*system: pf2e · dimension: calc-pf2e-core*

- **Where:** `src/lib/rules/pf2e/index.ts:34`
- **Issue:** pf2eProficiency clamps level with Math.max(1, Math.floor(level) || 1) before adding the rank bonus. For a level-0 entity that is trained, this returns 1 + 2 = 3 instead of the rules-accurate 0 + 2 = 2. This does not affect PC math (PF2e PCs are level 1-20) but would mis-score any level-0 creature/object fed through this path.
- **Expected:** Proficiency = (level if rank > untrained else 0) + rank bonus, with level used as-is. At level 0, trained should give 0 + 2 = 2. The clamp to a minimum of 1 inflates this by 1. Negligible for standard PCs (level >= 1) but technically incorrect at the boundary.
- **Fix:** If level-0 inputs are never possible, leave as-is (impact is nil). If they can occur, clamp the floor at 0 instead of 1: Math.max(0, Math.floor(level) || 0).

#### [LOW] PF2e night's rest full-heals instead of Con-mod x level
*system: pf2e · dimension: calc-pf2e-weapons-misc*

- **Where:** `src/lib/rules/rest.ts:14-16`
- **Issue:** applyRest sets hp.current = hp.max whenever opt.restoresHp is true. The PF2e 'Rest for the Night' restores HP equal to your Constitution modifier (minimum 1) multiplied by your level, not full HP. This is a shared rest path with 5e (where a long rest does full-heal, correctly), so PF2e overheals on a night's rest.
- **Expected:** PF2e: a full night's rest recovers Con mod x level HP (min 1 x level). Example: level 5, Con +2 -> 10 HP back, not to full. (5e long rest correctly restores all HP, so the rule must branch by system.) Refocus correctly restores no HP, so only the night's-rest HP amount is affected.
- **Fix:** Branch HP recovery by system: keep full-heal for 5e long rest; for PF2e night's rest restore max(1, conMod) x level HP capped at hp.max.

#### [LOW] 5e nat-20 and nat-1 force a crit on all DC checks
*system: 5e · dimension: calc-dice-rng*

- **Where:** `src/lib/dice/check.ts:40-43`
- **Issue:** Natural 20 returns critical-success and natural 1 returns critical-failure before total is compared to DC, so every DC check auto-succeeds on a 20 and auto-fails on a 1. In 5e RAW this applies only to attack rolls and death saves.
- **Expected:** For 5e ability checks and saves the degree should be success when total is at least DC and failure otherwise, with no natural-die override. Example: a plus-0 Stealth check rolling a natural 20 for total 20 against DC 25 is a RAW failure but the code returns critical-success.
- **Fix:** Apply the nat-20 and nat-1 auto-results only for attack rolls and death saves in 5e.

#### [LOW] 5e encounter multiplier ignores party-size adjustment (no column shift for <3 or >=6 PCs)
*system: 5e · dimension: calc-encounter-budget*

- **Where:** `src/lib/combat/budget.ts:58-65,67-70`
- **Issue:** encounterMultiplier(count) is a function of monster count only. The DMG p.83 simplified encounter rules say that when the party has fewer than 3 PCs you should treat the encounter as if it had one MORE monster (move one column right / use the next-higher multiplier), and when the party has 6+ PCs treat it as one FEWER monster (next-lower multiplier). budget5e never passes party size into encounterMultiplier, so a 3-monster fight always uses x2 regardless of whether 2 PCs or 7 PCs face it.
- **Expected:** Per DMG p.83: with a small party (<3 PCs) bump the multiplier up one step on the monster-count table; with a large party (>=6 PCs) drop it one step. Worked example: 3 monsters vs a 2-PC party should use the x2.5 column (one step up from x2). Code returns x2 for any party size. Note this is partially mitigated here because the difficulty thresholds are summed per-character (line 72-76), so larger/smaller parties already get scaled budgets; the missing piece is specifically the multiplier-column shift, whose absence slightly under-rates large parties' fights as harder-than-shown and small parties' fights as easier-than-shown relative to strict DMG output.
- **Fix:** Pass partyLevels.length into encounterMultiplier and shift the effective monster count by +1 when size<3 and -1 when size>=6 before selecting the multiplier, matching DMG p.83. Optionally gate behind a setting since many tables ignore this rule.

#### [LOW] Visibility nudge applied to viewer eye only, not to target cell centers (potential asymmetry)
*system: both · dimension: calc-map-geometry*

- **Where:** `src/lib/map/vision.ts:64,69`
- **Issue:** computeVisibleCells nudges only the viewer: `const eye = { x: v.x + 0.01, y: v.y + 0.01 }` while target cell centers stay on exact grid lines `(c + 0.5) * gridSize`. Because the ray's two endpoints are treated asymmetrically (one nudged, one exact), whether a grazing wall blocks can in principle differ depending on which token is the viewer, so 'A sees B' is not guaranteed identical to 'B sees A' at exact-grazing geometry. In practice the dominant failure is the touching=non-blocking rule (which leaks symmetrically), so a clean A-yes/B-no split is hard to trigger, but the construction is not provably symmetric.
- **Expected:** Mutual visibility should be symmetric: if viewer at P can see cell at Q's center, a viewer at Q should see cell at P's center under identical wall geometry. Apply the same epsilon handling to both endpoints, or remove the directional nudge in favor of robust endpoint-inclusive intersection.
- **Fix:** Nudge consistently (or not at all) and resolve grazing via the inclusive-intersection fix from vision-corner-fog-leak; add a symmetry test asserting sees(P,Q) === sees(Q,P) across a wall grid.

#### [LOW] Square AoE over-covers when cursor sits on a cell center instead of a grid corner
*system: 5e · dimension: calc-map-geometry*

- **Where:** `src/lib/map/shapes.ts:67-74 (squareCells); caller MapEditor.tsx:116,163`
- **Issue:** squareCells includes every cell whose center is within ±(side/2) px of the cursor point, with no grid snapping. A 20-ft square (half = 100px on a 50px/5ft grid) anchored on a grid corner (200,200) correctly yields 16 cells (4x4), but anchored on a cell center (125,125) yields 25 cells (5x5) because cell centers at the ±100 boundary are inclusively counted on both sides. The template thus changes size with sub-cell cursor placement.
- **Expected:** A 5e NxN-ft square/cube on a grid should always cover (N/5)x(N/5) cells (e.g. 20 ft -> 4x4 = 16 cells) regardless of cursor sub-cell position; templates are normally snapped to grid intersections. Worked example: 20 ft square should be 16 cells, not 25.
- **Fix:** Snap the square's anchor to the nearest grid intersection before rasterizing (or use a strict '<' half-open bound on the high side), so coverage is grid-count exact. Note: this is an advisory overlay (no auto-applied effect), hence low severity.

#### [LOW] Cone AoE clips far corners using radial distance rather than axial length
*system: 5e · dimension: calc-map-geometry*

- **Where:** `src/lib/map/shapes.ts:76-90 (coneCells); caller MapEditor.tsx:164`
- **Issue:** coneCells includes a cell only if its radial distance from the origin is <= lenPx AND its angle from the cone direction is within halfAngle. A 5e cone's length is measured along its axis; cells near the cone's outer edge but within the triangular extent can have radial distance slightly greater than the axial length and get excluded, so the far corners of the cone are clipped to an arc. The apex angle 53 deg (MapEditor.tsx:164) is the correct ~53.13 deg = 2*atan(0.5) 5e cone approximation, so only the length metric is at issue.
- **Expected:** 5e cone of length L covers cells out to L feet measured straight from the origin along the cone; the template is a triangle/wedge, not a pie slice clipped by a circle. Effect is a slight under-coverage of the two far corners of the cone wedge.
- **Fix:** Cap by axial projection length (distance along the cone direction) instead of radial distance, or accept the arc approximation. Advisory overlay only, so low severity.

#### [LOW] Spell save/damage normalization is prose-regex and misses ~8% of saves
*system: 5e · dimension: data-loaders*

- **Where:** `src/lib/mechanics/normalize/spell.ts:18-43`
- **Issue:** normalizeSpell5e derives save ability and damage by regex over desc. On the live SRD set, of 128 spells whose desc contains "saving throw", the /(ability) saving throw/ regex matches 118 — 10 are missed (phrasings like "a saving throw against your spell save DC" where the ability word is not adjacent). Damage parsing catches 73 of 133 desc mentions of "damage" (the rest are flat/scaling/half-damage prose with no NdN dice). Missed saves yield save:null, so the assistant would not surface a DC prompt for those spells.
- **Expected:** Ideally pull save/damage from structured fields, but Open5e SRD spells don't expose them. Worked example: a spell described as "…must make a saving throw against your spell save DC…" parses to save:null and the EncounterTracker would not surface its save. Given the project's "never auto-roll, only surface the DC" rule, the impact is a missing prompt, not a wrong number — hence low severity. Degrades gracefully.
- **Fix:** Add fallback patterns ("saving throw against your spell save DC", "Dexterity save", etc.) and consider a small curated override table for the ~10 misses. Keep it best-effort; never block on it.

#### [MEDIUM→LOW] Magic Missile normalized damage is empty (regex can't match '1d4 + 1 force damage')
*system: 5e · dimension: data-spells · _verified: confirmed_*

- **Where:** `src/lib/mechanics/normalize/spell.ts:31 (parseDamage regex); src/data/srd/spells-srd.json (magic-missile)`
- **Issue:** parseDamage's regex /(\d+d\d+)\s+(\w+)\s+damage/ requires the dice to be immediately followed by '<type> damage'. Magic Missile's desc reads 'A dart deals 1d4 + 1 force damage', so the token after '1d4' is '+', not a damage type, and the regex fails. Magic Missile normalizes to damage:[].
- **Expected:** Magic Missile deals 1d4+1 force damage per dart (3 darts at 1st level). Canonical: 3 x (1d4+1) force. The snapshot should at least capture the force-damage dice; instead it captures nothing, so the sheet/combat snapshot for Magic Missile shows no damage.
- **Fix:** Broaden the damage regex to tolerate flat modifiers, e.g. /(\d+d\d+(?:\s*[+-]\s*\d+)?)\s+(\w+)\s+damage/, and preserve the modifier in the dice string. Same gap affects any spell phrased '<dice> + N <type> damage'.

## 📚 Data accuracy (15)

#### [HIGH] Fighter skillChoices splits "Animal Handling" into two bogus entries, dropping that skill
*system: 5e · dimension: data-loaders · _verified: confirmed_*

- **Where:** `src/data/srd/classes.json (fighter entry) / src/lib/ruleset/normalize.ts:66`
- **Issue:** classes.json stores fighter skillChoices as ["Acrobatics","Animal","Handling","Athletics","History","Insight","Intimidation","Perception","and Survival"]. "Animal Handling" was split into separate "Animal" and "Handling" tokens (a comma in the source between the two words, or a normalization artifact). Neither "animal" nor "handling" matches the skill key `animal-handling`/label "Animal Handling" in CreationWizard.tsx:169-171, so a Fighter cannot select Animal Handling as a proficiency despite it being a valid Fighter skill.
- **Expected:** Fighter skillChoices should contain a single "Animal Handling" entry (and "Survival", not "and Survival"). A level-1 Fighter choosing 2 skills should be offered Animal Handling; the builder currently omits it.
- **Fix:** Regenerate classes.json from a clean source list, or post-process to rejoin "Animal"/"Handling" and strip the "and " prefix. Validate every class's skillChoices against the known 5e skill label set and fail the scraper on unmatched tokens.

#### [MEDIUM→HIGH] Blink Dog passive Perception listed as 10, should be 13
*system: 5e · dimension: data-monsters · _verified: confirmed_*

- **Where:** `src/data/srd/monsters-srd.json (Blink Dog, senses field)`
- **Issue:** Blink Dog's senses string is 'passive Perception 10'. The same record lists skills.perception = 3, and WIS 13 (+1). Passive Perception = 10 + Perception bonus = 10 + 3 = 13. The stated 10 is internally inconsistent with its own perception skill and wrong vs the SRD.
- **Expected:** Canonical SRD Blink Dog: Skills Perception +3, passive Perception 13. Worked example: 10 + (Perception +3) = 13. senses should read 'passive Perception 13'.
- **Fix:** Correct the senses string to 'passive Perception 13'. Better: compute passive Perception (10 + perception-or-WIS mod) at render time instead of trusting the source string, so it can never disagree with the skills/ability data.

#### [MEDIUM→HIGH] Bone Devil passive Perception listed as 9, should be 12
*system: 5e · dimension: data-monsters · _verified: confirmed_*

- **Where:** `src/data/srd/monsters-srd.json (Bone Devil, senses field)`
- **Issue:** Bone Devil's senses string is 'darkvision 120 ft., passive Perception 9'. WIS 14 (+2), no Perception proficiency, so passive Perception = 10 + 2 = 12. Stated 9 is too low by 3.
- **Expected:** Canonical SRD Bone Devil: passive Perception 12. Worked example: WIS 14 -> +2; 10 + 2 = 12.
- **Fix:** Fix the senses string to 'passive Perception 12', or compute passives at render time from WIS + perception skill.

#### [MEDIUM→HIGH] Chain Devil passive Perception listed as 8, should be 11
*system: 5e · dimension: data-monsters · _verified: confirmed_*

- **Where:** `src/data/srd/monsters-srd.json (Chain Devil, senses field)`
- **Issue:** Chain Devil's senses string is 'darkvision 120 ft., passive Perception 8'. WIS 12 (+1), no Perception proficiency, so passive Perception = 10 + 1 = 11. Stated 8 is too low by 3.
- **Expected:** Canonical SRD Chain Devil: passive Perception 11. Worked example: WIS 12 -> +1; 10 + 1 = 11.
- **Fix:** Fix the senses string to 'passive Perception 11', or compute passives at render time.

#### [HIGH→MEDIUM] Class skillChoices retain leading "and ", silently dropping a valid skill from the 5e builder
*system: 5e · dimension: data-loaders · _verified: confirmed_*

- **Where:** `src/lib/ruleset/normalize.ts:65-66 (data: src/data/srd/classes.json)`
- **Issue:** normalizeOpen5eClass splits the skill list only on commas and never strips the trailing Oxford "and ". The last skill of nearly every class is stored as "and Survival", "and Religion", "and Stealth", etc. In CreationWizard the choices are lowercased and matched against skill keys/labels (CreationWizard.tsx:169-171); "and survival" matches neither the key `survival` nor the label "Survival", so that skill is dropped from the class's allowed proficiency list. Confirmed in data: barbarian/cleric/paladin/sorcerer/warlock/wizard end in "and Religion"/"and Survival", druid/ranger "and Survival", monk "and Stealth", fighter "and Survival".
- **Expected:** The final list element should be the bare skill name. Worked example: Barbarian skillChoices should be ["Animal Handling","Athletics","Intimidation","Nature","Perception","Survival"]; code stores [...,"and Survival"] so Survival is unselectable for a Barbarian in the builder. Fix: in normalize.ts strip a leading /^and\s+/i (and split on "and" as well as commas) before pushing each choice, and/or regenerate classes.json.
- **Fix:** In normalizeOpen5eClass, map each split token through .replace(/^and\s+/i,'').trim(), or split on /,|\band\b/. Regenerate src/data/srd/classes.json. Add a unit test asserting Barbarian.skillChoices includes "Survival" and excludes "and Survival".

#### [MEDIUM] Drow ASI in races.json is wrong (+2 INT instead of Elf +2 DEX / Drow +1 CHA)
*system: 5e · dimension: data-classes-races-feats · _verified: confirmed_*

- **Where:** `src/data/srd/races.json (Drow entry)`
- **Issue:** The Drow entry lists 'Your Intelligence score increases by 2' and only the generic Fey Ancestry trait. This is the Kobold-Press standalone-Drow writeup, not the SRD elf/drow. SRD Drow is a subrace of Elf: +2 DEX (from Elf) and +1 CHA (from Drow), with Superior Darkvision, Sunlight Sensitivity, Drow Magic, and Keen Senses.
- **Expected:** A Drow should receive +2 DEX and +1 CHA. As written, even if ASI were applied (it isn't currently), a Drow would get +2 INT — wrong ability entirely, mis-stating attack/AC (Dex) and Charisma-based casting/saves. Example: an intended Drow rogue would get no Dex bonus from race here.
- **Fix:** Replace race data with SRD-correct values (Drow as Elf subrace: +2 DEX, +1 CHA, Superior Darkvision 120 ft, Sunlight Sensitivity, Drow Magic, Keen Senses, Trance).

#### [MEDIUM] All PF2e datasets contain large numbers of duplicate (remaster/legacy) entries shown unfiltered
*system: pf2e · dimension: data-pf2e · _verified: confirmed_*

- **Where:** `scripts/fetch_pf2e.ts:117-120 (postProcess only dedupes 'action'); consumed raw by src/features/compendium/registry.tsx via loadPf2e()`
- **Issue:** The fetch script de-duplicates only the 'action' category; every other category is written verbatim from Archives of Nethys, which lists each reprinted item once per source (e.g. Remastered + legacy). Measured duplicate counts (total - unique-by-name): creatures 866 (4702 vs 3836), feats 2143 (8402 vs 6259), equipment 2434 (8605 vs 6171), spells 659 (2455 vs 1796), weapons 280 (614 vs 334), deities 230 (716 vs 486), backgrounds 116, heritages 93, archetypes 89, ancestries 19. The compendium (registry.tsx pf2eCategory -> loadPf2e) renders these raw, so e.g. 'Goblin Warrior', 'Longsword', and '8-Round Magazine' each appear two or three times in the browser.
- **Expected:** Each game element should appear once in the compendium (preferring the remastered entry). A user browsing the PF2e Bestiary should see one 'Goblin Warrior', not two identical rows; the Weapons list should show one 'Longsword'.
- **Fix:** Apply dedupeByName (already defined in fetch_pf2e.ts) to all categories, not just actions — preferring the Remastered source — or dedupe by slug/name at load time in loadPf2e(). The builder already does this locally for ancestries/backgrounds via dedupeByName in overview.ts, confirming the duplication is a known problem only partially patched.

#### [MEDIUM] Undo writes a captured snapshot with save() (not the transactional mutate), clobbering any concurrent live-session player edits
*system: both · dimension: ux-combat-play*

- **Where:** `src/features/combat/EncounterTracker.tsx:57-67; src/lib/db/repositories.ts:181-197`
- **Issue:** mutate() pushes the closure's `encounter` prop onto undoStack before the async write resolves, and undo() restores it via encountersRepo.save(prev) — a full put() (repositories.ts:181-184), NOT the transactional read-modify-write mutate(). The save bypasses the freshest-state read that mutate uses to avoid clobbering. In a hosted session, a player's HP patch lands on the GM's Character docs (not the encounter), so this specific clobber is limited to encounter state; but two combat surfaces (e.g., the GM's own tracker and the embedded RollFeed/CombatPage) or a rapid mutate-then-undo can still resurrect a stale encounter document over newer combat changes. The undo snapshot is the React prop value, which may already be one render behind the committed DB state.
- **Expected:** Undo should reapply prior state transactionally (or store and replay the inverse) rather than blindly put()-ing a possibly-stale full document, so it cannot overwrite changes committed after the snapshot was captured.
- **Fix:** Have undo() go through encountersRepo.mutate(id, () => prev) or capture the post-commit state; consider snapshotting inside the mutate callback rather than from the render prop.

#### [LOW] Spell picker uses full-caster level curve for half-casters and Warlock
*system: 5e · dimension: calc-5e-weapons-hp*

- **Where:** `CreationWizard.tsx:189`
- **Issue:** Picker maxLevel uses ceil of level over 2 for all casters; a L5 Paladin sees 3rd-level spells though its top slot is 2nd.
- **Expected:** Half-casters trail about 2 levels; use built.spellcasting.slots for the cap. Minor, trimmable later.
- **Fix:** Cap picker by highest level in built.spellcasting.slots.

#### [LOW] Spider (tiny) passive Perception listed as 12, should be 10
*system: 5e · dimension: data-monsters*

- **Where:** `src/data/srd/monsters-srd.json (Spider, senses field)`
- **Issue:** The tiny Spider's senses string is 'darkvision 30 ft., passive Perception 12'. WIS 10 (+0), no Perception proficiency (only Stealth +4), so passive Perception = 10 + 0 = 10. Stated 12 is too high by 2.
- **Expected:** Canonical SRD Spider: passive Perception 10. Worked example: WIS 10 -> +0; 10 + 0 = 10.
- **Fix:** Fix the senses string to 'passive Perception 10', or compute passives at render time.

#### [LOW] Swarm of Ravens passive Perception listed as 15, should be 13
*system: 5e · dimension: data-monsters*

- **Where:** `src/data/srd/monsters-srd.json (Swarm of Ravens, senses field)`
- **Issue:** Swarm of Ravens' senses string is 'passive Perception 15'. WIS 12 (+1) with Perception +3 (proficient) in the SRD, so passive Perception = 10 + 3 = 13. Stated 15 is too high by 2 (and the skills object is empty, so the record cannot self-justify 15).
- **Expected:** Canonical SRD Swarm of Ravens: Skills Perception +3, passive Perception 13. Worked example: 10 + 3 = 13.
- **Fix:** Fix the senses string to 'passive Perception 13' and add skills.perception = 3, or compute passives at render time.

#### [LOW] Acid Arrow school is Evocation in source data; canonical is Conjuration
*system: 5e · dimension: data-spells*

- **Where:** `src/data/srd/spells-srd.json:24 (acid-arrow .school)`
- **Issue:** The acid-arrow entry has "school": "Evocation". In the 5e SRD/PHB, Melf's Acid Arrow (Acid Arrow) is a 2nd-level Conjuration spell. This is an Open5e source-data error that flows through normalizeSpell5e unchanged (spell.ts:54 copies raw.school verbatim) and is displayed in the compendium.
- **Expected:** Acid Arrow is 2nd-level Conjuration. The school field should read 'Conjuration'. Worked example: filtering/searching the compendium by school 'Conjuration' will omit Acid Arrow, and by 'Evocation' will wrongly include it.
- **Fix:** Correct the school to 'Conjuration' in spells-srd.json (and spells-full.json if mirrored). Low gameplay impact (school rarely affects mechanics) but it is a factual data error.

#### [LOW] Magic item subtitle duplicates 'requires attunement' phrase
*system: 5e · dimension: data-equipment*

- **Where:** `src/features/compendium/details.tsx:121`
- **Issue:** The magic-item detail subtitle concatenates the literal string 'requires attunement ' with the raw requires_attunement field. In magicitems-srd.json that field is itself the full phrase (e.g. 'requires attunement' for Cloak of Protection, or 'requires attunement by a paladin' for Holy Avenger). So the rendered subtitle becomes '(requires attunement requires attunement)' for the common case and '(requires attunement requires attunement by a paladin)' for restricted items.
- **Expected:** Render the field value alone since it already contains the verb. E.g. for Cloak of Protection show '(requires attunement)'; for Holy Avenger show '(requires attunement by a paladin)'. Concrete fix: change the template to ` (${item.requires_attunement})` (the field already starts with 'requires attunement').
- **Fix:** Drop the hard-coded 'requires attunement ' prefix and interpolate only the field: ` (${item.requires_attunement})`. Verify across the ~125 attuned items in magicitems-srd.json which all begin with 'requires attunement'.

#### [MEDIUM→LOW] feats.json contains non-SRD, benefit-stripped feats and is never loaded; SRD feats live in mpmb-feats.json
*system: 5e · dimension: data-classes-races-feats · _verified: confirmed_*

- **Where:** `src/data/srd/feats.json; src/lib/compendium/index.ts:66-73`
- **Issue:** src/data/srd/feats.json is an open5e/Kobold-Press set (Ace Driver, Brutal Attack, Boundless Reserves, …) whose descriptions are truncated to lead-ins like 'You gain the following benefits:' with the actual benefits removed. It is not imported anywhere. The compendium's loadFeats5e() instead reads mpmb-feats.json, which DOES contain the 104 SRD feats with usable effect text (e.g. Great Weapon Master full rules).
- **Expected:** The standard PHB/SRD feats (Great Weapon Master, Sharpshooter, Lucky, Alert, War Caster, Sentinel, Resilient, Tough, Mobile, Polearm Master, Crossbow Expert, etc.) should be the available feat list with their mechanical text. They are entirely absent from feats.json (only 'Grappler' overlaps) but present and complete in mpmb-feats.json.
- **Fix:** Delete or replace the unused open5e feats.json to avoid confusion, and standardize on mpmb-feats.json (with its structured descriptions) as the single feat source used by both compendium and a future feat picker.

#### [LOW] Two divergent PF2e class data sources (data file vs hardcoded table) can disagree
*system: pf2e · dimension: data-pf2e*

- **Where:** `src/lib/rules/pf2e/progression.ts:9-35 (hardcoded PF2E_CLASSES) vs public/data/pf2e/classes.json (Foundry-scraped) merged in CreationWizard.tsx:76-83`
- **Issue:** Class info comes from two places that are independently authored and already drift. The builder's class LIST, HP-per-level display and skillCount come from classes.json; but buildCharacter() (progression.ts:85) pulls HP, save ranks, perception and caster from the hardcoded PF2E_CLASSES table via getClassDef. Where they disagree the results are inconsistent: e.g. skillCount is 3 in the hardcoded table but 3-8 in the data file (the data file value wins for the picker), and Champion keyAbility is ['str'] hardcoded vs ['dex','str'] in the data file. HP values happen to match canon in both, but the split invites future drift and makes the skillcount bug harder to spot.
- **Expected:** A single source of truth for each class field. HP/saves/caster should be read from the same record the picker displays, or the hardcoded table should be the authoritative enrichment of the data file (it already supplies caster/subclasses).
- **Fix:** Either drive buildCharacter from the loaded RulesetClass (passing saveRanks/caster through, as finish() already does for saves at line 250-253), or fold the data-file fields into PF2E_CLASSES. Reconcile Champion keyAbility to ['str','dex'].

## 🧩 Data completeness (15)

#### [MEDIUM→HIGH] Statblock view shows only walk speed; fly/swim/climb/burrow dropped for 174 monsters
*system: 5e · dimension: data-monsters · _verified: confirmed_*

- **Where:** `src/features/compendium/MonsterDetail.tsx:43`
- **Issue:** The Speed line renders only m.speed.walk ('{m.speed.walk} ft.'). The JSON stores full speed maps (e.g. Adult Red Dragon {walk:40, climb:40, fly:80}; Aboleth {walk:10, swim:40}; Giant Spider {walk:30, climb:30}), but every non-walk movement mode is silently dropped. 174 of 322 monsters have at least one non-walk speed, so a GM reading the in-app statblock cannot see that the red dragon flies 80 ft. or the aboleth swims 40 ft.
- **Expected:** Render all movement modes, e.g. 'Speed 40 ft., climb 40 ft., fly 80 ft.' by iterating m.speed entries rather than reading only .walk.
- **Fix:** Iterate over all keys of m.speed (walk, fly, swim, climb, burrow, hover) and join into the Speed line, matching the SRD statblock format.

#### [HIGH] Background skill/tool/language proficiencies are dropped (saved only as a note)
*system: 5e · dimension: data-classes-races-feats · _verified: confirmed_*

- **Where:** `src/features/characters/builder/CreationWizard.tsx:103,255`
- **Issue:** The chosen background is stored only as free text in `notes` ('Background: Acolyte'). The background's granted skill proficiencies, tool proficiencies and languages (present in backgrounds.json as `skills`/`tools`/`languages`) are never written into `skillRanks` or anywhere mechanical.
- **Expected:** A 5e background grants 2 fixed skill proficiencies plus tools/languages. Worked example: Acolyte grants Insight and Religion as trained skills. After building a Fighter with the Acolyte background, the sheet should show Insight and Religion as proficient (skillRanks set to 'trained'), on top of the class skill picks. Currently those skills remain untrained, understating those skill checks by the proficiency bonus (e.g. -2 at levels 1-4).
- **Fix:** Parse background.skills into skill keys and merge them as 'trained' into the built character's skillRanks at finish(); surface tools/languages on the sheet.

#### [HIGH] Subclasses are names-only blurbs; no subclass feature is ever applied
*system: 5e · dimension: data-classes-races-feats · _verified: confirmed_*

- **Where:** `src/data/srd/classes.json; src/features/characters/builder/CreationWizard.tsx:255; src/lib/ruleset/normalize.ts:78`
- **Issue:** Subclasses in classes.json carry only `{name, desc}` (a flavor paragraph) — no features, no level table, no spells. The chosen subclass is saved only as a note ('Subclass: Champion'). Nothing mechanical (e.g. Champion's Improved Critical, Life Domain's bonus spells/heavy-armor proficiency, Draconic Bloodline's bonus HP) is granted.
- **Expected:** Choosing a subclass at the appropriate level must grant its features. Worked example: a level-3 Cleric (Life Domain) should gain Heavy Armor proficiency and the Life Domain bonus spells (e.g. bless, cure wounds) plus Disciple of Life; a Sorcerer (Draconic Bloodline) should gain +1 HP per level and 13+Dex unarmored AC. None of this happens — the subclass is purely descriptive text.
- **Fix:** Either clearly scope subclasses as descriptive-only (and tell the user to add features manually) or add structured subclass feature/spell/proficiency data and apply it at build/level-up.

#### [MEDIUM→HIGH] Racial traits (proficiencies, vision, resistances) are never applied — only shown as text
*system: 5e · dimension: data-classes-races-feats · _verified: confirmed_*

- **Where:** `src/features/characters/builder/CreationWizard.tsx:94-101`
- **Issue:** Racial traits like Elf 'Keen Senses' (Perception proficiency), Half-Orc 'Menacing' (Intimidation proficiency), Dwarf darkvision/poison resistance, and racial speed beyond a parsed number are concatenated into a display `desc` string but never applied to skillRanks, proficiencies, defenses, or senses.
- **Expected:** Race traits with mechanical weight should be applied. Worked example: an Elf should be proficient in Perception (skillRanks.perception='trained'); a Half-Orc should be proficient in Intimidation. After building an Elf, Perception remains untrained, understating Perception by the proficiency bonus.
- **Fix:** Map known trait grants (skill proficiencies from Keen Senses/Menacing, damage resistances, darkvision) into the built character's mechanical fields; at minimum auto-add trait-granted skill proficiencies to skillRanks.

#### [HIGH] Dice Clear history wipes ALL campaigns rolls when no campaign active, no confirm
*system: both · dimension: ux-global*

- **Where:** `src/features/dice/DicePage.tsx:84`
- **Issue:** campaignId=activeCampaignId??undefined (36); Clear history->diceRepo.clear(campaignId) (84); repositories.ts:220-223 else-branch db.diceRolls.clear() wipes table for ALL campaigns when none active. recent() shows all rolls globally (206-209). No confirm.
- **Expected:** Disable when undefined or scope to visible rows, confirm via Modal; else a GM with no active campaign loses every campaigns rolls.
- **Fix:** Disable when no campaign active; confirm via Modal.

#### [MEDIUM] Saving throws, skills, and perception present in data but never shown in statblock
*system: 5e · dimension: data-monsters · _verified: confirmed_*

- **Where:** `src/features/compendium/MonsterDetail.tsx:60-71 (Saves/Skills lines absent)`
- **Issue:** The JSON carries explicit saving-throw bonuses for 91 monsters (strength_save, dexterity_save, etc.) and a skills object, but MonsterDetail renders neither. Example: Adult Red Dragon has saves DEX +6, CON +13, WIS +7, CHA +11 and skills Perception +13 / Stealth +6, none of which appear in the rendered statblock. A GM cannot see the dragon's saving-throw bonuses, which are needed to resolve spells and effects against it.
- **Expected:** Add 'Saving Throws' and 'Skills' lines (and surface perception) using the *_save fields and skills object, e.g. 'Saving Throws DEX +6, CON +13, WIS +7, CHA +11'.
- **Fix:** Add Saving Throws and Skills StatLines built from the *_save fields and the skills map so the displayed statblock is complete.

#### [MEDIUM] races.json is non-SRD-heavy with no subraces; real SRD subraces in mpmb-race.json are name-only and unused
*system: 5e · dimension: data-classes-races-feats · _verified: confirmed_*

- **Where:** `src/data/srd/races.json; src/data/srd/mpmb-race.json; src/lib/compendium/index.ts:98-104`
- **Issue:** The wizard's race list (races.json, 20 entries) mixes the 9 SRD base races with 11 non-SRD Kobold-Press races (Alseid, Catfolk, Darakhul, Derro, Erina, Gearforged, Minotaur, Mushroomfolk, Satarre, Shade, plus base-Drow) and contains NO subraces (Hill/Mountain Dwarf, High/Wood Elf, Lightfoot/Stout Halfling, Forest/Rock Gnome). mpmb-race.json has the proper SRD subraces (mountain dwarf, wood elf, forest gnome, stout halfling, …, 126 entries) but only stores names, so it can't be used for mechanics and isn't loaded by the wizard.
- **Expected:** 5e SRD characters choose a subrace whose ASI/traits matter (e.g. Hill Dwarf +1 WIS and +1 HP/level vs Mountain Dwarf +2 STR and armor training; Wood Elf 35 ft speed + Mask of the Wild vs High Elf cantrip + extra language). With only base races and no subrace, players cannot build a correct SRD character and lose the subrace ASI entirely.
- **Fix:** Source structured SRD race+subrace data (ASI, speed, traits) and feed the wizard from it; either drop non-SRD races or clearly label them as homebrew.

#### [MEDIUM] sessionLog excluded from cascade-delete and backup/restore
*system: both · dimension: ux-global*

- **Where:** `src/lib/io/backup.ts:9`
- **Issue:** sessionLog (db.ts:29) missing from backup.ts:9-12 and campaignsRepo.remove cascade (66-79); sessionLogRepo.clear() (236-239) never called -> dropped on backup, orphaned on delete.
- **Expected:** Add sessionLog to backup TABLES+SCHEMA and the cascade.
- **Fix:** Add sessionLog to backup.TABLES and remove() cascade.

#### [LOW] Bard skillChoices is empty — skill-list parse failed (works only by accident)
*system: 5e · dimension: data-loaders*

- **Where:** `src/data/srd/classes.json (bard entry) / src/lib/ruleset/normalize.ts:65-66`
- **Issue:** Bard's skillChoices is []. The Open5e bard text is "Choose any three skills" which has no "from <list>" clause, so the /from (.+)$/ regex yields nothing. CreationWizard.tsx:167 treats an empty skillChoices as "any skill" and falls back to allSkillKeys. For Bard the correct 5e rule is indeed "any three skills," so the outcome is coincidentally correct, but the data is a silent parse failure rather than an intentional value and would mislead the ClassDetail "Skills — from …" display (details.tsx:62 shows just the count with no list).
- **Expected:** Either store the full skill list (all 18) for "any" classes, or carry an explicit "any" flag, so the empty array is not ambiguous between "parse failed" and "choose from all".
- **Fix:** In normalizeOpen5eClass, when the text says "any"/no list is found, populate skillChoices with the full skill set or set a boolean like anySkill:true, so downstream code and the compendium detail render correctly and the failure mode is not silent.

#### [LOW] ~15.8 MB of dead, unimported JSON sits in src/data/srd (incl. the once-empty feats.json)
*system: both · dimension: data-loaders*

- **Where:** `src/data/srd/ (21 files)`
- **Issue:** 21 of 31 files under src/data/srd are not imported anywhere (verified by grepping every filename across src and confirming no import.meta.glob/require over the data dir). Dead files total ~15.78 MB, dominated by monsters-full.json (11.1 MB), spells-full.json (2.3 MB), magicitems-full.json (1.8 MB). Notably feats.json (13.2 KB) — the file the audit brief flagged as "0 bytes critical" — is now populated but is NOT the file the feats loader uses: loadFeats5e imports mpmb-feats.json (index.ts:68), so feats.json is dead. weapons-srd.json is dead because loadWeapons5e imports weapons-full.json (index.ts:52). Because Vite only bundles files reached via import(), these do NOT inflate the JS bundle, but they bloat the repo/source tree and any copy-all build steps.
- **Expected:** src/data/srd should contain only files reachable from a loader. The 10 live files are: monsters-srd, spells-srd, magicitems-srd, weapons-full, equipment, mpmb-feats, conditions, classes, races, backgrounds. Worked example: deleting monsters-full/spells-full/magicitems-full/the mpmb-* (except mpmb-feats)/the *-sample/feats.json/weapons-srd/actions.json/rules.json reclaims ~15.8 MB with zero code changes needed.
- **Fix:** Move the *-full and *-sample variants out of the bundled source tree (or delete), and remove the unused mpmb-* / feats.json / weapons-srd.json / actions.json / rules.json. Either delete feats.json or repoint loadFeats5e to it if it is the intended SRD feats source.

#### [LOW] races/feats/classes/backgrounds.json are NOT 0 bytes (audit premise is stale)
*system: 5e · dimension: data-loaders*

- **Where:** `src/data/srd/races.json, feats.json, classes.json, backgrounds.json`
- **Issue:** The brief states these four files are 0 bytes and the UI would break loading them. Current on-disk sizes: races.json 12,694 B (20 entries), backgrounds.json 21,442 B (42), classes.json 46,266 B (12), feats.json 13,486 B (104) — all modified Jun 8 17:52. loadRaces5e/loadBackgrounds5e/loadClasses parse them successfully and their loader TS types match the JSON shapes (races: slug/name/desc/asi/speed/vision/traits; backgrounds: slug/name/desc/skills/tools/languages/feature). No runtime breakage from empty files exists today. (feats.json is populated but unused — see feats-loader-source-mismatch / dead-bundled-srd-json.)
- **Expected:** No action for emptiness; the files have valid content and load. This finding records that the headline "0 bytes -> runtime breakage" risk is resolved, so reviewers don't chase a non-issue.
- **Fix:** Treat the 0-byte concern as resolved. Focus remediation on the skillChoices corruption and the dead/duplicate-source files instead.

#### [LOW] spell_list stores raw Open5e API URLs instead of spell names/slugs
*system: 5e · dimension: data-monsters*

- **Where:** `src/data/srd/monsters-srd.json (spellcaster spell_list field, e.g. Lich)`
- **Issue:** Spellcaster spell_list arrays contain raw API URLs like 'https://api.open5e.com/v2/spells/mage-hand/?format=json' rather than spell names or slugs, making the field unusable for cross-linking. Impact is limited because the human-readable prepared-spell list is duplicated in the Spellcasting special_ability desc (which is rendered), and that text is accurate (e.g. Mage: DC 14, +6 to hit, correct for INT 17 / 9th-level).
- **Expected:** spell_list should hold spell slugs/names (e.g. 'mage-hand') to enable linking to the spells compendium; or rely on the special_ability text and drop the URL field.
- **Fix:** Normalize spell_list to slugs during data prep, or ignore the field since the rendered Spellcasting text already contains the prepared list.

#### [LOW] parseDamage flattens all dice clauses without marking the base hit, conflating riders/upcast/alt-damage
*system: 5e · dimension: data-spells*

- **Where:** `src/lib/mechanics/normalize/spell.ts:28-43 (parseDamage)`
- **Issue:** parseDamage collects every distinct '<dice> <type> damage' pair into a flat array with no notion of which is the base on-hit damage vs. secondary/rider/alternative damage. Examples: prismatic-spray yields 5 entries (10d6 fire/acid/lightning/poison/cold — these are mutually-exclusive random rays, not summed), wall-of-ice yields two cold entries (10d6 and 5d6 are different triggers), spirit-guardians yields 3d8 radiant AND 3d8 necrotic (the caster picks ONE). A consumer summing or displaying the array would overstate damage.
- **Expected:** The snapshot should distinguish base damage from conditional/alternative damage, or at least not present mutually-exclusive options as a single additive set. e.g. Prismatic Spray rolls a d8 to pick one ray; it does not deal 50d6.
- **Fix:** If the snapshot is only an informational hint, document that; if it feeds any auto-computed total, restrict to the first/base damage clause or tag entries (base vs alt vs rider). Add the at-higher-levels scaling as structured data so upcast damage can be computed.

#### [LOW] Morningstar properties is null and Net has damage '0'/empty type
*system: 5e · dimension: data-equipment*

- **Where:** `src/data/srd/weapons-srd.json:385, 396-397`
- **Issue:** Morningstar has `properties: null` rather than `[]` (SRD Morningstar correctly has no properties, so the value is semantically right but the null vs empty-array inconsistency can break consumers expecting an array). Net has `damage_dice: "0"` and `damage_type: ""`, and Blowgun has `damage_dice: "1"`. These are accurate to the SRD (Net deals no damage; Blowgun deals 1 piercing) but the non-dice string format ('0','1') and null properties are data-hygiene risks for any code that parses damage_dice as NdM or maps over properties.
- **Expected:** Morningstar should be `properties: []` for consistency with every other no-property weapon (e.g. Flail, Mace, War pick all use []). Net damage_type should be 'piercing' is not required (Net deals no damage in SRD, so empty is acceptable); Blowgun '1' piercing is correct per SRD.
- **Fix:** Normalize Morningstar `properties` to [] to match sibling entries; optionally guard consumers against non-NdM damage_dice strings ('0','1'). No stat is wrong — purely a consistency/robustness improvement.

#### [LOW] index.json reports 3950 actions but the file holds 646 (stale build artifact)
*system: pf2e · dimension: data-pf2e*

- **Where:** `public/data/pf2e/index.json (action count 3950) vs public/data/pf2e/actions.json (646 entries)`
- **Issue:** index.json lists the action category count as 3950, but actions.json actually contains 646 entries after the dedup/junk-filter added in commit 1c87b0e. index.json was not regenerated. This is harmless at runtime because no source file reads index.json (grep for 'index.json' in src returns nothing), but the metadata is misleading.
- **Expected:** index.json count should equal the file's entry count (646). The fetch script writes index from entries.length, so a re-run would self-correct.
- **Fix:** Regenerate index.json (re-run scripts/fetch_pf2e.ts) or drop the unused index.json. Cosmetic only.

## 🖱 Usability (20)

#### [CRITICAL] PF2e beginner templates softlock the Abilities step ("-Infinity / 27", Next disabled)
*system: pf2e · dimension: ux-character*

- **Where:** `src/features/characters/builder/CreationWizard.tsx:226-232 (applyTemplate) + 46-52 (TEMPLATES.pf2e) + 404/430 (point-buy UI)`
- **Issue:** applyTemplate() unconditionally sets method='pointbuy' and copies the template's ability array into pb. The pf2e templates use real PF2e starting scores above 15 (Stalwart Fighter str:18, Clever Wizard int:18, Sneaky Rogue dex:18, Healing Cleric wis:18). pointBuyRemaining() returns -Infinity whenever any score is outside 8..15 (pointCost returns null -> pointBuySpent returns Infinity), so pbValid is false and stepValid.Abilities is false, disabling 'Next'. The point-buy NumberFields are also hard-clamped to max={POINT_BUY_MAX}=15 (line 430), so the user cannot even see/keep the 18 once they touch a field. The header shows literally 'Points remaining: -Infinity / 27'. A beginner who clicks the headline 'Start from a ready-made hero' shortcut is trapped on the Abilities step.
- **Expected:** Applying a template must not leave the wizard in an invalid, un-advanceable state. PF2e characters legitimately start with an 18 in their key attribute, so they should not be forced into the 5e 27-point buy at all. Concrete fix: in applyTemplate, set method='manual'/'standard-as-assigned' style or introduce a 'manual' ability mode that accepts the template scores verbatim (clamp 1..30, not 8..15) and treats the step as valid; OR for pf2e skip point-buy and seed the pool/assignment so the 18 is representable. At minimum, do not setMethod('pointbuy') when any template score is outside 8..15. Worked example: choosing 'Stalwart Fighter' (pf2e) should leave STR 18 / DEX 14 / CON 14 / INT 10 / WIS 12 / CHA 10 intact and allow 'Next'.
- **Fix:** Add a 'manual' ability method (free-entry, clamp 1..30) and have applyTemplate select it; keep point-buy only for user-driven point-buy. Alternatively gate: `setMethod(t.ability values all in 8..15 ? 'pointbuy' : 'manual')`. Ensure stepValid.Abilities is true for manual mode.

#### [CRITICAL] Player silently loses their seat on any WebSocket reconnect; HP edits and rolls stop reaching the GM with no UI cue
*system: both · dimension: ux-combat-play*

- **Where:** `server/src/rooms.ts:111,153-162,205-222,264-275; src/lib/sync/wsSync.ts:53-58,72-75,115-121`
- **Issue:** The server assigns a fresh playerId via crypto.randomUUID() on every connection (rooms.ts:111) and deletes the player's seat on disconnect (rooms.ts:271). The client auto-reconnects (wsSync.ts:115-121) and re-sends `join`, getting a NEW playerId, but it never resets myCharacter/seatStatus — those only reset on joinSession/leaveRoom/stopSession (wsSync.ts:134,221-222). So after a transient drop the player UI still renders MyCharacterPanel as 'granted', but sendPlayerPatch/sendPlayerRoll are rejected server-side because the new playerId holds no seat (rooms.ts:210-211 `if (!seat || seat.characterId !== characterId) return;` and rooms.ts:221-222 `if (!seat) return;`). The player's HP/condition/spell changes and dice rolls silently vanish; the only feedback is the small 'Connecting…'/'Live' header chip.
- **Expected:** A reconnecting player should keep (or transparently re-acquire) their seat. Concrete fix: give the player a stable client-generated playerId persisted in joinIntent and send it on join, OR keep the seat alive across brief disconnects with a grace window keyed by a resume token (mirroring the GM's gmSecret resume at rooms.ts:71-80), OR on reconnect have the client detect seatStatus==='granted' and automatically re-send claimSeat for myCharacter.id and have the GM/server auto-regrant. At minimum, the client should drop seatStatus to 'pending' on socket close so the player sees they must re-claim instead of editing into the void.
- **Fix:** Persist a stable playerId on the client and resend it on join; on the server, rekey seats by that stable id and add a short disconnect grace period before deleting the seat. Until then, reset seatStatus to 'pending' in socket.onclose so the UI stops implying live control.

#### [HIGH] Editing Level on the sheet changes nothing else (HP/spell slots/proficiency don't update, no prompt)
*system: both · dimension: ux-character*

- **Where:** `src/features/characters/CharacterSheet.tsx:180-182`
- **Issue:** The header 'Level' NumberField does `onChange={(level) => update({ level })}` — a bare field write. Proficiency display and derived modifiers update (they read c.level), but HP max, spell slots, pact slots, and any per-level resources are NOT recomputed and there is no nudge to use Level Up. A GM who bumps a character from 1 to 5 here to fix data ends up with a level-5 character still showing level-1 HP and zero/level-1 spell slots, with no warning. Conversely, the dedicated 'Level up' button only ever does +1. There's no way to set an arbitrary level and have the sheet reconcile.
- **Expected:** Either (a) make this field read-only / informational and direct the user to 'Level up', or (b) when level is edited directly, surface a banner offering to recompute HP and spell slots (e.g. via buildCharacter/planLevelUp), or (c) hide direct level editing entirely. A user changing level should never be left with stale HP/slots silently. Worked example: level 1 Wizard (HP ~6-8, no slots) set to level 5 should not keep 8 HP and 0 first-level slots with no indication anything is wrong.
- **Fix:** Make the inline Level field non-editable (or add a confirm + recompute path). At minimum show a warning when c.level differs from what the HP/slots imply.

#### [HIGH] World-content deletes on one click, no confirm/undo
*system: both · dimension: ux-global*

- **Where:** `src/features/world/NotesPage.tsx:130`
- **Issue:** repo.remove() called directly, no confirm: NotesPage:130, QuestsPage:113, HomebrewPage:107, MapsPage:95. Campaign delete uses confirm Modal (CampaignsPage:181-207); no undo.
- **Expected:** Confirm deletes of authored content like campaign delete; one misclick destroys a whole quest, no recovery.
- **Fix:** Reuse confirm Modal or add undo.

#### [MEDIUM] NumberField has no empty/intermediate state — clears to min/0 every keystroke, making min-bounded fields painful to retype
*system: both · dimension: ux-character*

- **Where:** `src/components/ui/NumberField.tsx:21-33`
- **Issue:** The input is fully controlled with `value={Number.isFinite(value) ? value : 0}` and onChange immediately coerces+clamps: empty string -> Number('')===0 -> Math.max(min, 0). There is no local string buffer, so you can never transiently empty the field. With a min (point-buy min 8, ability min 1, qty min 0/1), clearing to retype snaps the field to min on the first keystroke and prevents natural editing. Example: a point-buy STR field (min 8) — to change 8 to 12 you cannot select-all and type '12' cleanly: deleting yields 8, then typing '1' before the 8 etc. is awkward; on mobile the value jumps around. Same for HP 'Cur' (no min but coerces blank to 0) and Max HP (min 0).
- **Expected:** Keep a local string state so the field can be transiently empty while typing, and only commit/clamp on blur (or when a valid number is parsed). A user should be able to clear the field and type a new number without it snapping to min mid-edit. Worked example: clearing a min=8 field should show empty until blur, then default to min if left blank.
- **Fix:** Add internal `useState` string buffer; render the raw string while focused, parse+clamp+emit onBlur (and on Enter). This is a one-component fix that improves every numeric input in creation and the sheet.

#### [MEDIUM] Creation wizard loses all entered data on stray backdrop click / Escape with no confirmation
*system: both · dimension: ux-character*

- **Where:** `src/features/characters/builder/CreationWizard.tsx:271-272 (Modal open onClose) + src/components/ui/Modal.tsx:43-46,72`
- **Issue:** The wizard renders inside Modal, which closes on Escape (Modal.tsx:43-46) and on backdrop click (Modal.tsx:72 `onClick={onClose}`). The wizard passes onClose straight through with no guard. A multi-step character (name, class, abilities, skills, spells) is discarded instantly on a misclick outside the panel or a stray Escape — there is no 'Discard character?' confirmation and nothing is persisted until 'Create character' on the final step.
- **Expected:** A multi-step creation flow should confirm before discarding non-trivial progress (e.g. if a class is selected or any field is filled). At minimum, disable backdrop-to-dismiss for the wizard, or intercept onClose to confirm. Worked example: user on the Spells step who clicks just outside the dialog should be asked to confirm, not silently lose 5 steps of work.
- **Fix:** Wrap onClose with a confirm when state is dirty (selectedClass set or name non-empty), or pass a Modal prop to disable backdrop/Escape dismissal for the wizard.

#### [MEDIUM] Standard-array / roll assignment offers no swap — reassigning a value forces manual juggling of every dropdown
*system: both · dimension: ux-character*

- **Where:** `src/features/characters/builder/CreationWizard.tsx:425-428 and src/features/characters/sheet/AbilityGenModal.tsx:95-106`
- **Issue:** In pool modes, each ability is a <Select> of pool indices where already-assigned indices are `disabled`. Because there is no swap-on-collision logic, the user cannot simply move the '15' from STR to DEX — DEX's 15 option is disabled (it's used by STR). To re-order, the user must first set STR to some free value, then set DEX to 15. The validity guard requires all six indices distinct (`new Set(assignment).size === 6`), so the natural 'pick 15 on two abilities then fix it' path is blocked at every step. This is classic array-assignment friction the UI doesn't solve.
- **Expected:** Selecting a value already held by another ability should swap the two assignments (the other ability takes the value this one gave up), so any reassignment is one action. Worked example: STR=15, DEX=10; choosing 15 for DEX should set DEX=15 and STR=10 automatically.
- **Fix:** On change, if the chosen pool index is held by ability j, swap assignment[i] and assignment[j] instead of disabling. Apply to both CreationWizard and AbilityGenModal (same pattern).

#### [MEDIUM] On disconnect, players keep showing the last snapshot with no on-board staleness indicator
*system: both · dimension: ux-combat-play*

- **Where:** `src/lib/sync/wsSync.ts:115-121; src/stores/playerSessionStore.ts:51 (reset only on join/stop); src/features/player/PlayerBoards.tsx; src/features/play/PlayerConnection.tsx:12`
- **Issue:** When the player's socket closes, the snapshot is intentionally NOT cleared (reset only runs on joinSession/stopSession), so the board (HP bars, initiative, map) keeps rendering the last-received state. That is reasonable, but the only indication that the data is frozen is the tiny header chip flipping to 'Connecting…' (PlayerConnection.tsx:12). During combat a player can stare at an out-of-date initiative/HP board believing it is live. There is no banner over the boards and no 'last updated' marker.
- **Expected:** While status !== 'connected' and a snapshot is shown, overlay a clear 'Reconnecting — showing last known state' banner on the player boards (and dim them), so players don't act on stale HP/turn info.
- **Fix:** Pass connection status into PlayerBoards (or render a sticky banner in NetworkedPlayerView) when status !== 'connected' and a snapshot exists.

#### [MEDIUM] text-faint fails WCAG AA contrast on normal body text
*system: both · dimension: ux-global*

- **Where:** `src/styles/globals.css:25`
- **Issue:** --app-faint ~3.37:1 light/~3.10:1 dark on panel, under 4.5:1 AA; on text-xs info CompendiumPage:221,248; DicePage:104; DashboardPage:179-215; SettingsPage:58.
- **Expected:** Raise --app-faint to >=4.5:1 or use text-muted.
- **Fix:** Raise --app-faint to >=4.5:1 or use text-muted.

#### [MEDIUM] No 404/not-found route; unknown URLs render blank shell
*system: n/a · dimension: ux-global*

- **Where:** `src/router.tsx:65`
- **Issue:** createRouter has no defaultNotFoundComponent, root no notFoundComponent, no catch-all; URLs outside 17 routes -> empty main, no way back.
- **Expected:** Add defaultNotFoundComponent rendering an EmptyState with a Go to Campaigns link.
- **Fix:** Add a 404 EmptyState not-found component.

#### [MEDIUM] Import button never advertises Pathbuilder support
*system: both · dimension: features-gap*

- **Where:** `src/features/characters/CharactersPage.tsx:47-49`
- **Issue:** The roster's 'Import' button has no label, tooltip, or helper text saying it accepts Pathbuilder JSON; pickTextFile() opens a generic file dialog. A user with a Pathbuilder export has no signal the feature exists, so a built importer goes unused.
- **Expected:** Surface accepted formats near the control, e.g. button title or subtext 'Import a saved character or Pathbuilder 2e export (.json)'. Empty-state copy could also mention it.
- **Fix:** Add a tooltip/subtitle listing supported import sources and constrain the file picker to .json. Cheap fix that unlocks an already-implemented feature.

#### [MEDIUM] AI assistant features silently inert without a user-supplied API key
*system: both · dimension: features-gap*

- **Where:** `src/lib/llm/client.ts:138-140; src/stores/assistantStore.ts:11-15`
- **Issue:** The assistant is disabled by default (enabled:false) and complete() returns 'disabled'/'no-key' unless the GM pastes their own API key and base URL into Settings. So LLM-backed features (NPC generator free-text flavor, session-prep hooks, level-up/encounter advisory prose) do nothing meaningful out of the box. Deterministic features (encounter builder, signals) still work, but the 'Assistant' surface looks broken to a GM who hasn't configured a provider.
- **Expected:** Either ship a working default (proxy/free tier) or make the no-key state self-explanatory at every assistant entry point, clearly separating the deterministic features (which always work) from the LLM ones (which need a key). Concrete: NpcGenCard should state 'Add an API key in Settings to generate flavor' rather than failing on click.
- **Fix:** Gate LLM-only cards behind a visible 'connect a provider' affordance and confirm each has a deterministic fallback. Document the BYO-key requirement prominently.

#### [LOW] Default attack ability hardcoded to STR; no finesse or ranged auto-pick
*system: 5e · dimension: calc-5e-weapons-hp*

- **Where:** `AttacksSection.tsx:28`
- **Issue:** New attacks default ability to str; weaponAttack has no finesse or ranged logic, so a Rapier or a ranged weapon uses STR not DEX.
- **Expected:** Ranged uses DEX, finesse uses the higher of STR and DEX. A Rogue with STR 10 and DEX 16 and prof bonus 2 using a Rapier should be plus 5 to hit and 1d8 plus 3, but the default shows plus 2 and 1d8.
- **Fix:** Default the ability to DEX for ranged or the higher of STR and DEX for finesse.

#### [LOW] applyRollMode applies advantage to the first single die of any size, not the d20
*system: both · dimension: calc-dice-rng*

- **Where:** `src/lib/dice/notation.ts:68-78`
- **Issue:** applyRollMode converts the first single-die term regardless of die size. For 1d4+1d20 with advantage it produces 2d4kh1+1d20, applying advantage to the d4. Verified by running the regex.
- **Expected:** Advantage is a d20 mechanic, so for 1d4+1d20 the intended output is 1d4+2d20kh1. The primary caller rollCheck always emits a leading 1d20 so checks work in practice.
- **Fix:** Prefer the first d20 term, falling back to the first single die only when no d20 is present.

#### [LOW] Prepared-spell checkbox has no limit and no count — no enforcement or guidance for prepared casters
*system: both · dimension: ux-character*

- **Where:** `src/features/characters/sheet/SpellcastingSection.tsx:176-181`
- **Issue:** Each leveled spell has a free 'Prepared' checkbox with no cap and no displayed count of how many a character may prepare (5e prepared casters = spellcasting ability mod + level/class level). There's no 'X / Y prepared' readout or warning, so the sheet gives zero help distinguishing the 'known/repertoire' list from the legal prepared set. The wizard copy explicitly says 'You prepare or cast them from slots on the character sheet', but the sheet offers no prepared-count support.
- **Expected:** Show a prepared count (e.g. 'Prepared 4/6') for prepared casters and optionally warn past the limit. Even a non-enforcing 'N prepared' tally would orient the user. Worked example: a level-5 Cleric (WIS +3) prepares 8 spells; the sheet should at least display how many are currently checked.
- **Fix:** Render a prepared tally next to 'Spell slots' (count of s.prepared && s.level>0); optionally compute the suggested max from class+ability and show it as guidance.

#### [LOW] Skills step requires an exact count but message/limit can mismatch when a class restricts choices
*system: both · dimension: ux-character*

- **Where:** `src/features/characters/builder/CreationWizard.tsx:163-175, 238, 442`
- **Issue:** skillCount is the class skill count (+ INT mod on pf2e), but the selectable list is skillOptions which, for classes with a restricted skillChoices list, can be SMALLER than skillCount. toggleSkill caps selections at skillCount (not at skillOptions.length), while stepValid.Skills requires `skills.length === Math.min(skillCount, skillOptions.length)`. The header text uses Math.min so it reads correctly, but the toggle cap uses the raw skillCount, so if skillOptions.length < skillCount a user could in principle be told to pick min(...) yet the checkbox-disable threshold is the larger skillCount — an inconsistent ceiling. For pf2e where intMod inflates skillCount, the restricted-list case can be confusing about exactly how many are required vs selectable.
- **Expected:** Use a single source of truth (the effective required count = Math.min(skillCount, skillOptions.length)) for the disable cap, the header, and validation, so the UI never lets/asks the user toggle beyond what the step requires. Worked example: a class whose skillChoices list has only 3 entries but skillCount=4 should require and allow exactly 3, consistently.
- **Fix:** Define `const required = Math.min(skillCount, skillOptions.length)` once and use it in toggleSkill, the header, and stepValid.

#### [LOW] Ambient signals are noisy: a per-turn 'turn' info signal plus always-on planning/resource items churn the list every action
*system: both · dimension: ux-combat-play*

- **Where:** `src/lib/assistant/advisors.ts:97-114,78-92,41-49; src/features/assistant/SignalsBell.tsx:44-49`
- **Issue:** buildSignals runs combatSuggestions which emits an info 'Round X: <name>'s turn' signal on every turn (advisors.ts:100-102), plus 'N quests in progress', 'has expended spell slots', and 'party running low' that persist across the whole session. SignalsBell rebuilds on every characters/encounter change (SignalsBell.tsx:44-47). Because the 'turn' signal id is the constant 'turn' but its title changes each turn, dismissing it once hides the turn indicator for the rest of the session, while not dismissing means the popover content shifts under the user on every Next-turn. The attention badge only counts non-info items (SignalsBell.tsx:49), which mitigates badge spam, but the open popover is still busy/low-signal.
- **Expected:** Drop the per-turn 'turn' info signal from the ambient bell (it duplicates the tracker header and changes every action), and consider collapsing always-on informational items behind a 'show info' toggle so the bell foregrounds danger/warn items.
- **Fix:** Remove the 'turn' signal from combatSuggestions (or mark it tracker-only), and de-emphasize standing info-level planning/resource signals.

#### [LOW] LLM encounter/level-up advisor surfaces only a terse one-line error and silently falls back, with no retry/raw-output affordance
*system: both · dimension: ux-combat-play*

- **Where:** `src/features/assistant/useEncounterAdvisor.ts:133-147; src/lib/llm/client.ts:182-189; src/features/assistant/useLevelUpAdvisor.ts:46`
- **Issue:** When the model returns malformed JSON or the wrong shape, complete() collapses every cause into generic strings: 'The model did not return valid JSON.' or 'The model output did not match the expected shape.' (client.ts:186-188). The encounter advisor shows 'AI unavailable: <message> Showing a deterministic pick.' (useEncounterAdvisor.ts:145) and the level-up advisor shows 'AI unavailable: … Showing general routes.' (useLevelUpAdvisor.ts:46) then silently uses deterministic output. There is no way to see the raw model text, no explicit 'retry AI' (Regenerate re-enters the same path and may silently go deterministic again because targetIsHarder/validation gates), and the empty-response case (token exhaustion) is only explained in one branch (client.ts:182-183).
- **Expected:** Distinguish failure modes (empty/timeout/HTTP/shape) in user-facing copy, indicate clearly when output is deterministic vs AI, and on a shape mismatch keep enough context to offer a real retry. When the LLM returns zero usable candidates (useEncounterAdvisor.ts:137-138 valid.length===0), tell the user the AI picked unavailable creatures rather than silently swapping to deterministic.
- **Fix:** Add a message on the valid.length===0 branch; pass through the zod issue path in the parse error; label the suggestion source prominently and offer an explicit 'Try AI again'.

#### [LOW] Down-PC death-save reminder and concentration note compute from a stale closure, so rapid Next-turn clicks can attribute the prompt to the wrong combatant
*system: both · dimension: ux-combat-play*

- **Where:** `src/features/combat/EncounterTracker.tsx:91-97`
- **Issue:** advanceTurn persists the turn via mutate(nextTurn) (transactional, correct), but then computes the 'upcoming' combatant by re-running nextTurn(encounter) on the closure's `encounter` prop, which is one render behind. If the GM presses Next (or the n/→ key) twice before React re-renders, the second invocation's closure still holds the pre-advance encounter, so the 'is down — make a death saving throw' / concentration reminder can be logged for the wrong combatant or round. The actual turn pointer stays correct (mutate reads fresh state); only the advisory log line can misfire.
- **Expected:** Derive the upcoming combatant inside the same transactional mutate (so it reads committed state), or log the reminder from within the mutate callback that advanced the turn, ensuring the named combatant matches the one whose turn actually began.
- **Fix:** Move the down/concentration reminder into the nextTurn mutate callback (compute from the post-advance encounter), eliminating the stale-closure path.

#### [LOW] Minor a11y: no skip link, palette ARIA/scroll, reduced-motion iteration-count
*system: n/a · dimension: ux-global*

- **Where:** `src/app/RootLayout.tsx:228; src/components/CommandPalette.tsx:70; src/styles/globals.css:143`
- **Issue:** RootLayout:228 no skip link/main id. CommandPalette:70 arrow keys set idx but no scrollIntoView + no listbox/option/aria-activedescendant. globals.css:143 reduced-motion omits animation-iteration-count:1 so infinite animations loop.
- **Expected:** Add skip link+main id; scrollIntoView+listbox ARIA in palette; animation-iteration-count:1 in reduced-motion block.
- **Fix:** Add skip link; palette scroll+ARIA; iteration-count:1.

## ➕ Missing features (24)

#### [MEDIUM→HIGH] Massive-damage instant death is not implemented
*system: 5e · dimension: calc-combat-engine · _verified: confirmed_*

- **Where:** `src/lib/combat/engine.ts:216-240; src/lib/mechanics/deathSaves.ts`
- **Issue:** Nothing checks instant death from massive damage. applyDamage lets current go negative (engine.ts:238, no lower clamp) which is the right substrate, but nothing compares the overflow to max HP and no dead outcome arises from damage. Grep for massive/instant finds only the deathSaves d20 path.
- **Expected:** 5e (Instant Death): if leftover damage after dropping a creature to 0 HP is >= its max HP, it dies instantly. Example: a 12-max PC at 4 HP takes 17 -> 0 with 13 overflow; 13>=12 so dead, no death saves. Engine should detect overflow>=max and mark dead.
- **Fix:** When a hit reduces current<=0, compute overflow and if overflow>=hp.max mark the combatant dead and stop the death-save clock; add boundary tests (==max -> dead, ==max-1 -> dying).

#### [CRITICAL→HIGH] UI never passes atLevel: warlocks cannot cast pact spells; no upcasting/heightening possible
*system: both · dimension: calc-mechanics-resources · _verified: confirmed_*

- **Where:** `src/features/characters/sheet/SpellcastingSection.tsx:61, src/features/player/MyCharacterPanel.tsx:121 (kernel: src/lib/mechanics/cast.ts:31-46)`
- **Issue:** Both call sites invoke castSpell(c, id) with no atLevel, so castSpell uses useLevel = Math.max(spell.level, spell.level) = the spell's own base level (cast.ts:39). consumeSlot(sc, useLevel) only matches a regular slot at that exact level OR a pact slot whose pact.level === useLevel (cast.ts:5-19). A warlock has no regular slots and a pact slot at the pact level (PACT[5]=[2,3] in dnd5e/progression.ts:50 → pact.level 3), while a typical warlock spell like Hex is level 1. Casting Hex calls consumeSlot(sc, 1): no regular level-1 slot and sc.pact.level (3) !== 1, so it returns null and castSpell fails with 'No level-1 slot available'. The warlock can therefore never spend a pact slot from the UI; the kernel's pact path (verified only by the test passing atLevel: 3 at enforce.test.ts:74) is unreachable in the app. The same omission means no 5e upcast and no pf2e heightening is ever selectable.
- **Expected:** The cast UI must let the player choose the slot level (a level picker / right-click menu) and pass it as atLevel. For a warlock, casting any leveled spell should consume a pact slot at the pact level: castSpell(c, hexId, c.spellcasting.pact.level) → consumeSlot finds sc.pact.level === 3, decrements pact.current. Worked example: warlock 5, pact {level:3, current:2}, casts Hex (level 1) → should consume 1 pact slot leaving current 1; currently the cast is rejected outright.
- **Fix:** Add a slot-level selector to the cast control in both SpellcastingSection and MyCharacterPanel; default it to spell.level but offer every level that has an available regular slot plus the pact level when sc.pact exists, and pass the chosen level as castSpell(c, id, level). The kernel already supports this correctly.

#### [HIGH] 5e Exhaustion's six-level mechanical effects are never derived
*system: 5e · dimension: calc-conditions-damage · _verified: confirmed_*

- **Where:** `src/lib/mechanics/conditionEffects.ts:29-42; src/lib/rules/conditions.ts:15`
- **Issue:** Exhaustion is the only valued 5e condition (conditions.ts:15) and its level effects are documented in conditions.json (disadvantage on ability checks at L1, speed halved at L2, disadvantage on attacks and saving throws at L3, HP max halved at L4, speed 0 at L5, death at L6). But CONDITION_EFFECTS_5E has no `exhaustion` key, so deriveState produces nothing for it: no abilityCheckDisadvantage, no speed reduction, no attack/save disadvantage, no speed 0 at level 5. The condition is a visible label only with zero mechanical enforcement.
- **Expected:** Exhaustion 3 should set abilityCheckDisadvantage=true AND attackModifier=disadvantage AND apply disadvantage to all saving throws (cumulative L1+L2+L3); Exhaustion 5 should additionally set speed 0. Worked example: a level-3-exhausted creature attacking should roll with disadvantage and have disadvantage on every save; the code returns normal on all of these.
- **Fix:** Add a value-aware exhaustion handler in deriveState that applies cumulative effects by level (L1 ability-check disadv, L2 speed halved, L3 attack+all-save disadv, L5 speed 0). Note speed-halved (L2) currently has no representation in MechanicalState (only speedZero), so a fractional-speed field is also needed.

#### [HIGH] No per-level 5e class-feature progression exists (Extra Attack, Sneak Attack, Rage, etc.)
*system: 5e · dimension: data-classes-races-feats · _verified: confirmed_*

- **Where:** `src/lib/rules/dnd5e/progression.ts:8-33; src/lib/rules/progression.ts; src/data/srd/classes.json`
- **Issue:** The 5e class data structure (ClassDef / RulesetClass) has no `features` field at all. classes.json `description` is just a one-word tag of the level-1 feature ('Rage','Expertise','Fighting Style'). buildCharacter and planLevelUp grant only HP, saves, skills, spell slots, ASI levels. Core combat-defining features are never recorded on the character.
- **Expected:** A level-5 Fighter should have Extra Attack (two attacks per Attack action); a level-5 Rogue should have 3d6 Sneak Attack; a level-2 Barbarian should track Rage uses; a level-2 Fighter should have Action Surge. None of these are added at build or level-up, so a built mid-level martial character is mechanically incomplete and the user must hand-author every feature in the free-text Feats section.
- **Fix:** Add a per-class, per-level feature table (at least names + effect text, ideally structured for Extra Attack / Sneak Attack dice) and write granted features into character.feats (or a features list) at build and level-up.

#### [HIGH] Feats are manual free-text with no link to feat data and no mechanical effect
*system: 5e · dimension: data-classes-races-feats · _verified: confirmed_*

- **Where:** `src/features/characters/sheet/FeatsSection.tsx:13-21; src/features/characters/sheet/LevelUpModal.tsx:128-130`
- **Issue:** The only way to add a feat is to type a name (and optionally a description) by hand in FeatsSection. There is no selector backed by the feat compendium, and a feat's effect is never applied to abilities/AC/saves/proficiencies. Even the ASI-vs-feat choice at level-up just says 'Add your chosen feat on the sheet after leveling.'
- **Expected:** Picking a feat should apply its mechanics. Worked examples: Tough should add +2 HP per level (a level-5 Tough character gains +10 max HP); Resilient (Con) should add Constitution save proficiency; Lightly/Heavily Armored should grant armor proficiency; a half-ASI feat like Athlete should add +1 to an ability. None of these are applied — the feat is a label with optional notes.
- **Fix:** Provide a feat picker backed by mpmb-feats.json (which has structured benefit text) and apply at least the deterministic mechanical feats (Tough HP, Resilient save proficiency, armor-training feats, +1 half-feats) to the character.

#### [HIGH] GM-applied damage almost never triggers a concentration prompt because the tracker reads Character.concentration, which the GM only knows if a seated player cast via their own panel
*system: 5e · dimension: ux-combat-play*

- **Where:** `src/features/combat/EncounterTracker.tsx:81-88,237-244; src/lib/sync/wsSync.ts:87-97; src/lib/schemas/encounter.ts (combatant has no concentration field)`
- **Issue:** noteConcentrationCheck looks up pcCharacter(combatant).concentration (a field on the Character doc), not on the Combatant. A Combatant has no concentration field (encounter.ts combatantSchema). The Character.concentration is set on the GM side only when a seated player casts a concentration spell through MyCharacterPanel and the diff is persisted via the playerPatched handler (wsSync.ts:87-97 includes `concentration`). So when the GM runs a PC/NPC themselves, or the player is not seated, or the spell was cast verbally at the table, the tracker never knows the creature is concentrating and never surfaces the DC after damage — defeating the documented 'detect and surface the DC' design (the very feature the comment at EncounterTracker.tsx:75-80 promises).
- **Expected:** After applying damage to any combatant the GM has flagged as concentrating, the tracker should surface 'roll DC max(10, floor(dmg/2)) Con save'. Provide a per-combatant 'concentrating on …' toggle in the row (independent of the linked Character) so the GM can mark NPC casters and PCs they run, and have noteConcentrationCheck read that combatant flag. Example: a marked caster taking 22 damage should log 'DC 11 Constitution save', per concentrationDC(22)=max(10,floor(11))=11.
- **Fix:** Add a concentration flag (and spell name) to the Combatant schema with a quick toggle in CombatantRow; key the post-damage save prompt off the combatant flag, falling back to the linked Character.

#### [HIGH] No D&D Beyond character import
*system: 5e · dimension: features-gap*

- **Where:** `src/lib/io/ (only character.ts + pathbuilder.ts exist)`
- **Issue:** The only external importer is Pathbuilder 2e (src/lib/io/pathbuilder.ts). A repo-wide grep for dndbeyond/ddb/'D&D Beyond' finds no importer. 5e players, the app's primary audience, have no way to bring an existing D&D Beyond character in — they must hand-rebuild it in the wizard.
- **Expected:** A 5e import path: accept a D&D Beyond JSON export / character-service payload (or at minimum a documented .json shape) and map abilities, class/level, race, proficiencies, AC, HP, spells, and inventory into the Character schema, mirroring parseCharacterImport's Pathbuilder branch. Concrete: importing a level-5 D&D Beyond Wizard should populate abilities, 6 levels of slots, prepared spells, and inventory with one file pick.
- **Fix:** Add a ddb importer module paralleling pathbuilder.ts and wire it into parseCharacterImport's auto-detect. Even a best-effort mapping of the common fields (abilities/HP/AC/class/level/skills/spells) would remove the single biggest 5e onboarding wall.

#### [HIGH] Pathbuilder import drops spells, feats, inventory, and attacks
*system: pf2e · dimension: features-gap*

- **Where:** `src/lib/io/pathbuilder.ts:21-52`
- **Issue:** pathbuilderToCharacterFields maps only name/class/level/ancestry, abilities, HP, save ranks, skill ranks, and perception. Pathbuilder build JSON also carries spells, feats, formula/equipment, weapons, and focus points — none are read. The imported pf2e character arrives with an empty spell list, no feats, no inventory, no attacks, and background/heritage stuffed into a notes string.
- **Expected:** Map Pathbuilder's spellCasters[]/focus, feats[], equipment[]/weapons[] into spellcasting.spells (with slots), feats[], inventory[], and attacks[]. Concrete: importing a Pathbuilder Wizard should yield its prepared spell list and slot counts, not an empty Spellcasting section the player must re-enter by hand.
- **Fix:** Extend the importer to consume spellCasters, feats, weapons, and equipment arrays from the build. Surface in the UI which sections were/weren't imported so the player knows what to finish.

#### [HIGH] No loot / treasure builder (roadmap item absent)
*system: both · dimension: features-gap*

- **Where:** `whole src tree (no loot/treasure module)`
- **Issue:** README's roadmap explicitly lists 'encounter/loot builders'. The encounter builder exists (src/lib/assistant/encounter.ts), but a grep for loot/treasure across src finds nothing — there is no treasure generator, hoard table, or per-encounter reward tracking. A GM cannot roll or assign loot, and there is no party currency/treasure ledger beyond a single character's currency field.
- **Expected:** A loot builder: 5e DMG treasure-hoard/individual tables by CR, or PF2e GMG treasure-by-level. Concrete: 'generate treasure for a level-5 PF2e party' should propose gp + a couple of level-appropriate items the GM can hand out (and optionally write to a character's inventory/currency).
- **Fix:** Add a loot module mirroring budget.ts/encounter.ts: data-driven treasure tables per system, a 'Generate loot' action on an encounter, and a one-click 'award to party' that writes currency/items to selected characters.

#### [HIGH] No XP tracking or award flow; leveling is fully manual
*system: both · dimension: features-gap*

- **Where:** `src/lib/schemas/character.ts:135-191; src/features/combat/EncounterTracker.tsx:168`
- **Issue:** characterSchema has no xp/experience field. The combat tracker computes and displays totalXp and awardPerCharacter (EncounterTracker.tsx:168) but there is no button to grant it, and nowhere to store it. Leveling (LevelUpModal) is a manual 'Apply level N' the GM clicks; it never checks an XP threshold. A campaign run on XP advancement has to track XP entirely outside the app.
- **Expected:** Add an xp field to Character, an 'Award XP' action that distributes an encounter's awardPerCharacter to party members, and a level-up prompt when XP crosses the threshold (5e XP-by-level table; PF2e 1000-XP-per-level). Milestone groups can simply ignore XP. Concrete: defeating a 1100-XP encounter for 4 PCs should offer to add 275 XP each and flag anyone who can now level.
- **Fix:** Persist XP on the character, wire the already-computed encounter award into a one-click grant, and gate/suggest level-up on threshold. Milestone mode stays the current manual button.

#### [HIGH→MEDIUM] Multiple Attack Penalty (MAP) not implemented or surfaced
*system: pf2e · dimension: calc-pf2e-weapons-misc · _verified: confirmed_*

- **Where:** `src/lib/rules/pf2e/index.ts:101 (and src/features/characters/sheet/AttacksSection.tsx:57-88)`
- **Issue:** weaponAttack returns only the first-Strike to-hit. There is no Multiple Attack Penalty: PF2e applies -5 on the second attack action in a turn and -10 on the third+ (reduced to -4/-8 for agile weapons). The WeaponInput type has no 'agile' trait and the attack UI shows a single to-hit number with no MAP variants, so a GM/player cannot see the second/third Strike bonuses that the PF2e action economy is built around.
- **Expected:** PF2e CRB/Player Core: the second attack action on your turn takes a -5 penalty and the third and later take -10; agile weapons reduce these to -4 and -8. Example: a +10 first Strike with a non-agile weapon should show +10 / +5 / +0; with an agile weapon +10 / +6 / +2. The tool should expose these (e.g. return mapSecond/mapThird, or accept an 'agile' flag on WeaponInput).
- **Fix:** Add MAP to the PF2e weaponAttack output (second/third to-hit) and an 'agile' flag on WeaponInput so the -4/-8 case is handled; surface all three values in AttacksSection.

#### [MEDIUM] Striking runes (extra weapon damage dice) not modeled
*system: pf2e · dimension: calc-pf2e-weapons-misc · _verified: confirmed_*

- **Where:** `src/lib/rules/types.ts:25-36, src/lib/rules/pf2e/index.ts:105-106`
- **Issue:** The weapon model has only damageDice (free-text string) and a flat itemBonus. There is no representation of striking runes, which in PF2e increase the NUMBER of weapon damage dice (striking = 2x, greater striking = 3x, major striking = 4x base dice). As a result a striking weapon cannot be represented correctly, and (per the related defect) magic-weapon damage is instead mis-modeled as a flat item bonus.
- **Expected:** A 1d8 longsword with a striking rune deals 2d8 (greater striking 3d8, major 4d8) plus the ability modifier — extra dice, not a flat bonus. Add a striking tier that multiplies the base damage dice count.
- **Fix:** Introduce an optional striking/diceCount field on WeaponInput and have the PF2e weaponAttack expand damageDice accordingly (e.g. '1d8' + striking -> '2d8').

#### [HIGH→MEDIUM] Taking damage while at 0 HP does not cause a death-save failure (crit not 2)
*system: 5e · dimension: calc-combat-engine · _verified: confirmed_*

- **Where:** `src/lib/combat/engine.ts:216-240; src/features/combat/EncounterTracker.tsx:237-244`
- **Issue:** applyDamage only adjusts hp.temp/hp.current; it never records a death-save failure when a PC at 0 HP is hit. EncounterTracker onDamage likewise writes only hp and never increments defenses.deathSaves.failures. rollDeathSave only processes a rolled d20 with no entry point for damage-induced failures. No path adds a failure for damage at 0 HP, and none adds two for a crit.
- **Expected:** 5e (Damage at 0 Hit Points): any damage taken while at 0 HP causes one death-save failure; a critical hit causes two. Example: downed PC at successes 1 / failures 1 takes a non-crit hit -> failures 2; if that hit were a crit -> failures 3 (dead). The engine should bump the linked character failures by 1 (or 2 on a crit) when damage lands on current<=0.
- **Fix:** Add a failure transition when applyDamage/onDamage hits a PC already at hp.current<=0 (1 normally, 2 on crit), threading a crit flag through the damage UI into defenses.deathSaves; add tests.

#### [MEDIUM] PF2e Frightened does not decrease by 1 at end of turn
*system: pf2e · dimension: calc-conditions-damage · _verified: confirmed_*

- **Where:** `src/lib/mechanics/conditionEffects.ts:56; src/features/combat/EncounterTracker.tsx`
- **Issue:** PF2e Frightened reduces by 1 at the end of each of the affected creature's turns ('at the end of each of your turns, the value of your frightened condition decreases by 1'). The codebase models frightened only as a static `statusPenalty` and there is no turn-advance hook that decrements valued conditions. Grep for the condition tick/decrement logic across combat finds none.
- **Expected:** After a frightened creature ends its turn, Frightened 2 should become Frightened 1 automatically. Worked example: a creature hit by a Frightened 3 effect should be at −3 on turn 1, −2 after its first turn, etc.; the app keeps it at −3 indefinitely until manually edited.
- **Fix:** On end-of-turn advancement for a PF2e combatant, decrement its frightened value by 1 (floor at 0, removing at 0), matching the auto-expiry already implied by the `duration` field.

#### [MEDIUM] PF2e Drained does not reduce HP or maximum HP
*system: pf2e · dimension: calc-conditions-damage · _verified: confirmed_*

- **Where:** `src/lib/mechanics/conditionEffects.ts:52`
- **Issue:** Drained is modeled only as a `statusPenalty` (Constitution/Fortitude). The rule also reduces current and maximum HP: 'you lose a number of Hit Points equal to your level (minimum 1) times the drained value, and your maximum Hit Points are reduced by the same amount.' This HP/max-HP reduction is not applied anywhere.
- **Expected:** A level 5 creature gaining Drained 2 should immediately lose 10 HP and have max HP reduced by 10 until the condition is removed. The app applies neither, only a −2 Con/Fortitude (and even that is merged into the generic statusPenalty).
- **Fix:** When Drained is added/changed for a PF2e creature, reduce max HP by level×value and clamp current HP. This needs creature level available to the combatant model.

#### [MEDIUM] No delay/ready-action and no drag reorder — only single-step up/down nudges to fix initiative order
*system: both · dimension: ux-combat-play*

- **Where:** `src/features/combat/EncounterTracker.tsx:553-560; src/lib/combat/engine.ts:195-206`
- **Issue:** Running initiative live has no 'delay' or 'ready action' concept (a core 5e/PF2e table action where a creature moves later in the order). Reordering is only moveCombatant(±1) via two arrow buttons, so moving a combatant several slots requires repeated clicks, and there is no way to insert a delayed combatant at the current position or hold their turn. grep for delay/ready/hold across src/features/combat and src/lib/combat returns nothing.
- **Expected:** GMs routinely need to delay a creature to a later initiative or drop them into the current slot. Provide a 'Delay' action that re-inserts the combatant just after the current turn (or lets the GM type a new initiative that re-sorts — which updateCombatant already supports), and ideally drag-to-reorder. Even a numeric initiative edit re-sorts live (engine.ts:187-191), but there is no one-click 'act now/act later'.
- **Fix:** Add a Delay button per combatant that, during an active fight, moves them to just-after the current turn index (or sets their initiative to current-1) and re-anchors the pointer; optionally add drag reordering.

#### [MEDIUM] PF2e focus points, spontaneous & prepared casting unmodeled
*system: pf2e · dimension: features-gap*

- **Where:** `src/features/characters/sheet/SpellcastingSection.tsx; src/lib/schemas/character.ts:84-106`
- **Issue:** Spellcasting uses one generic slot grid for both systems. PF2e's focus pool (1-3 Focus Points refreshed by a 10-min Refocus) has no representation — the rest engine defines a 'refocus' option (pf2e/index.ts:15) but there is no focus-point resource it can refresh. Spontaneous casters (signature spells, casting from a shared rank pool) and prepared-per-slot casting are not distinguished; everything is a manual count grid.
- **Expected:** Model focus points as a first-class short-recovery resource that Refocus restores, and let pf2e casters mark spell type (prepared vs spontaneous) and focus spells. Concrete: a Sorcerer should track a focus pool that Refocus refills, and cast spontaneously from rank slots rather than per-prepared-spell.
- **Fix:** Add a focusPool (current/max) to the character, hook Refocus to refill it, and add a casting-tradition/type flag so the UI can present spontaneous vs prepared correctly. This is the main remaining 5e-vs-pf2e casting parity gap.

#### [MEDIUM] Multiclass is a slot calculator, not a real character model
*system: 5e · dimension: features-gap*

- **Where:** `src/lib/schemas/character.ts:147-148; src/features/characters/sheet/SpellcastingSection.tsx:196-212`
- **Issue:** Character has a single free-text className and one level; there is no per-class level breakdown. The only multiclass support is MulticlassSlots, a manual calculator where the player types full/half/third caster levels to fill the slot grid. Multiclass proficiencies, per-class HP, and class features are not derived; saves/skills assume one class.
- **Expected:** A classes[] array with {name, level, subclass}, from which proficiency bonus, HP, and combined caster level are computed. Concrete: a Fighter 3 / Wizard 2 should auto-derive PB +3, combined caster level 2, and the union of class proficiencies — not require the player to hand-fill slots and remember which saves they have.
- **Fix:** Promote className/level to a classes[] model and derive the dependent stats. This is documented as out of scope in the roadmap; flagging it as the depth ceiling a multiclassing player will hit.

#### [MEDIUM] Class/subclass features and feat effects are not surfaced or automated
*system: both · dimension: features-gap*

- **Where:** `src/features/characters/sheet/FeatsSection.tsx:24-46; src/features/characters/sheet/LevelUpModal.tsx:128-131`
- **Issue:** Feats are free-text records the player types a name+description into (FeatsSection); subclasses are only picker labels in the wizard. The sheet never tells a player what features they gain at a level — LevelUpModal says 'Add your chosen feat on the sheet after leveling' and lists plan.notes, but does not enumerate class/subclass features. A leveling player must consult an external book to know what they got.
- **Expected:** At level-up, surface the class/subclass features gained at that level (data exists in src/data/srd/classes.json and public/data/pf2e/classes.json), and ideally pre-fill a feat/feature record. Concrete: leveling a Champion to 3 should show 'Divine Ally', not a blank feats box.
- **Fix:** Join the ruleset class data into the level-up flow to list features-by-level and auto-create feat/feature stubs. The data is already bundled; it just isn't read at level-up.

#### [MEDIUM] Live sync hardwired to same-origin /ws — no relay for self-hosters
*system: both · dimension: features-gap*

- **Where:** `src/lib/sync/wsSync.ts:32-35`
- **Issue:** wsUrl() builds the WebSocket URL from location.host + '/ws', and cloud client uses the same origin. A user running the PWA from a static host, file://, or a different origin than the bundled Node server has no live multiplayer or cloud sync and no setting to point at a relay. The 'local-first, no server' framing collides with the fact that the headline shared-table features require co-located server infrastructure.
- **Expected:** A configurable sync/relay endpoint (env or Settings) so self-hosters can run sessions, plus a clear offline-vs-online capability split in the UI. Concrete: a GM self-hosting the static site should be able to set a relay URL and host a session.
- **Fix:** Add a configurable WS/cloud base URL (Settings + VITE_ env), defaulting to same-origin. Document that shared play needs the bundled server.

#### [LOW] No Class DC computation exposed
*system: pf2e · dimension: calc-pf2e-core*

- **Where:** `src/lib/rules/pf2e/index.ts:38-116`
- **Issue:** The PF2e rules object exposes spellSaveDc (used as the spell DC at SpellcastingSection.tsx:33) and spellAttackBonus, but there is no Class DC. PF2e characters have a Class DC (10 + key-ability mod + class proficiency + item) that gates abilities like Athletics maneuvers' counterparts, alchemist DCs, kineticist, monk stances, etc. A grep for classDc/classDC/class_dc across src/ returns nothing.
- **Expected:** Class DC = 10 + key ability modifier + proficiency(level + rank bonus) + item. Worked example: level-5 fighter, Str 18 (+4), trained class DC (+2): 10 + 4 + (5 + 2) = 21. The codebase provides no function to compute this; it can be approximated via spellSaveDc only if the class happens to be a caster, which most martials are not.
- **Fix:** Add a classDc(input, ability, rank) method to the PF2e RulesSystem returning 10 + abilityModifier(ability) + pf2eProficiency(level, rank), analogous to the existing spellSaveDc. Plumb the class's key ability and class DC rank (from PF2E_CLASSES) into it.

#### [LOW] rng.ts has no dedicated tests; inclusive range unverified
*system: n/a · dimension: calc-dice-rng*

- **Where:** `src/lib/rng.ts`
- **Issue:** No rng.test.ts exists. The int() inclusive range and seeding are tested only indirectly. Empirically int(1,20) is inclusive and unbiased over 5M samples, so correct, but an off-by-one regression never rolling the max would go uncaught.
- **Expected:** A rng.test.ts asserting int(1,6) yields only 1 through 6, createRng of a seed is deterministic and reproducible, and a coarse uniformity check.
- **Fix:** Add src/lib/rng.test.ts for inclusive bounds, determinism, and distribution.

#### [LOW] SpellMechanics has no at-higher-levels / cantrip-scaling field, so snapshot can't represent upcast damage
*system: 5e · dimension: data-spells*

- **Where:** `src/lib/mechanics/types.ts:36-48 (SpellMechanics); src/lib/mechanics/normalize/spell.ts:46-59`
- **Issue:** The normalized SpellMechanics model only stores level, school, concentration, ritual, save, and a flat damage array. The rich source field higher_level (present and accurate for scaling spells like Fireball '+1d6 per slot above 3rd', Acid Splash '5th/11th/17th', Magic Missile '+1 dart') is dropped entirely during normalization, so the sheet snapshot cannot represent cantrip scaling or upcast damage.
- **Expected:** For a level-5 caster, Acid Splash should be 2d6 and Eldritch Blast should be two beams; for Fireball upcast to a 5th-level slot, 10d6. The snapshot only ever stores the base (1d6 / 1d10 / 8d6) with no scaling rule, so any damage display is base-only.
- **Fix:** Add an optional scaling descriptor (per-slot dice delta and/or cantrip break levels) to SpellMechanics and parse higher_level into it, so combat can compute upcast/cantrip damage rather than always showing base dice.

#### [LOW] Heritage (mandatory PF2e level-1 choice) is absent from the character builder
*system: pf2e · dimension: data-pf2e*

- **Where:** `src/features/characters/builder/CreationWizard.tsx:377-394 (Origin step picks only ancestry + background)`
- **Issue:** The builder's Origin step offers Ancestry and Background pickers but no Heritage picker, even though 432 heritages are bundled (public/data/pf2e/heritages.json) and exposed in the compendium. In PF2e every character must choose a heritage at level 1 (e.g. Dwarf -> Ancient-Blooded / Rock Dwarf / ...), which grants concrete mechanical benefits. grep for 'heritage' across src/features/characters returns nothing.
- **Expected:** After choosing an ancestry, the PF2e builder should let the player choose a heritage (the data is present and could be filtered to the chosen ancestry). Currently heritage must be added manually to notes post-creation.
- **Fix:** Add a Heritage sub-picker to the Origin step for pf2e (loadPf2e('heritages')), ideally filtered by the selected ancestry.

## Per-dimension summaries

- **calc-5e-core** — The 5e core derived-stat engine in src/lib/rules is correct and well-tested. Ability modifiers, the proficiency-by-level table, skill/save modifiers (including expertise doubling), AC (unarmored and all three armor categories with correct Dex caps), initiative, passive perception, spell save DC / attack, and the PHB fixed-average HP formula all match the SRD; I verified the non-trivial cases (proficiency table at every breakpoint, expertise, dex-cap handling, HP) numerically and against the existing rules.test.ts. No defects found in this dimension.
- **calc-5e-weapons-hp** — ok
- **calc-5e-slots-multiclass** — The 5e spellcasting progression in src/lib/rules/dnd5e/progression.ts is fully correct against the PHB. I verified all 20 rows of the full-caster slot table (including the subtle high-level rows 17-20: L18 gains a 5th-level slot for [4,3,3,3,3,...], L19 a 6th, L20 a second 7th, ending [4,3,3,3,3,2,2,1,1]), all rows of the half-caster table (Paladin/Ranger, spells from L2, L1 correctly absent so a lone half-caster gets zero slots), and all 20 rows of the warlock Pact Magic table ([count, slotLevel]). The multiclass combined-caster-level formula (full×1 + floor(half/2) + floor(third/3)) is correct, reads the full-caster table at the combined level, caps at 20, returns no slots at combined level 0, and structurally excludes warlock pact magic. All 16 unit tests pass and my independent cell-by-cell recomputation found no discrepancies. No defects to report.
- **calc-pf2e-core** — The PF2e proficiency engine is correct and well-tested. pf2eProficiency (src/lib/rules/pf2e/index.ts:32-36) correctly returns 0 for untrained (no level added — the classic "adds level while untrained" bug is absent) and level + rank bonus (+2/+4/+6/+8 for trained/expert/master/legendary) otherwise. A level-5 expert yields 5+4=9, matching the unit test at rules.test.ts:79. Ability modifier (floor((score-10)/2)), Fort/Ref/Will saves (con/dex/wis only), skills, Perception-driven initiative, spell DC, and spell attack all compute ability + (level + rank bonus) correctly. The two real gaps are AC, which entirely omits the level-scaling armor proficiency from its default formula, and the absence of any Class DC computation.
- **calc-pf2e-weapons-misc** — The PF2e proficiency math (level + rank bonus, untrained = 0), single-Strike to-hit, Bulk thresholds (encumbered at 5+Str mod, max at 10+Str mod), and the 5e carry comparison (Str×5 / Str×15) are all correct, and the encumbrance UI uses the right strict-greater-than comparisons. The most significant defect is that the PF2e weaponAttack adds the flat item bonus to damage, which is wrong for PF2e (a potency rune adds only to the attack roll; damage bonuses come from striking runes as extra dice). Multiple Attack Penalty and striking runes are not implemented or surfaced at all, so the action economy that defines PF2e combat is absent from the attack tool.
- **calc-combat-engine** — Combat tracker engine audit.
- **calc-dice-rng** — three findings
- **calc-mechanics-resources** — The mechanics kernel itself (cast.ts, resources.ts, rest.ts) is largely correct: cantrips never consume slots, leveled casting clamps to at least the spell's own level, upcasting consumes the chosen higher slot, pact slots are tracked separately with no double-spend, failures are non-mutating, and spend/regain floor/cap correctly with no negative or NaN balances reachable in practice. Two real defects exist. (1) Critical UI integration gap: both castSpell callers invoke it without an atLevel argument, so no upcast level can ever be chosen — and because a warlock's pact slot sits at a fixed pact level (e.g. level 3 at character level 5) while a known spell entry carries its own base level (e.g. Hex level 1), the kernel's pact-slot path is unreachable and warlocks cannot cast their leveled spells at all from the app. (2) Pf2e Refocus over-restores Focus Points: it restores the entire pool instead of 1, contradicting both the PF2e rule and the code's own comment. Slot/resource refill on rest is otherwise tagged to the correct rest type.
- **calc-conditions-damage** — The 5e damage-type math is correct: immune zeroes, resistance is Math.floor(amount/2), vulnerability doubles, and PF2e flat weakness-then-resistance ordering is right (engine.ts:216-240). The 5e advantage/disadvantage condition table is largely accurate, but two real defects exist there (prone ranged attacks overstated; exhaustion's six-level effects are never enforced at all). The biggest problems are in PF2e: every valued condition (clumsy/enfeebled/drained/stupefied/frightened/sickened/slowed) is collapsed into a single undifferentiated "worst value wins" status penalty, so penalties tied to different statistics are wrongly compared and merged, slowed (an action-economy condition) is wrongly turned into a check penalty, frightened never auto-decrements, and drained's HP/max-HP loss is missing.
- **calc-encounter-budget** — The encounter-budget math in src/lib/combat/budget.ts is overwhelmingly correct. The 5e CR-to-XP table (all 34 entries), the 5e XP thresholds per character level (all 80 cells across 20 levels, DMG p.82), and the 5e encounter multiplier (1/1.5/2/2.5/3/4 with correct count boundaries) all match the canonical DMG values exactly. The PF2e creature-XP-by-level-difference table, the per-party-level XP budget (40/60/80/120/160), and the party-size adjustment (10/15/20/30/40 per extra/fewer member) are all correct. XP award division (5e split per PC, PF2e awarded per character) is correct, and the UI correctly shows raw award XP while rating difficulty off the multiplier-adjusted XP. The only deviation found is that 5e omits the optional DMG party-size multiplier-column shift, which is a low-impact, partially-mitigated simplification rather than a wrong value.
- **calc-map-geometry** — The distance metrics are correct: 5e default is Chebyshev (every diagonal = 5 ft) and the optional alternating 5-10-5 diagonal rule is implemented under the "pf2e" mode key, both matching their canonical rules (worked examples verified). Coordinate/projection transforms (worldToCell↔cellCenter, zoomToPoint↔worldFromScreen, fitViewport) round-trip exactly. The main real defect is in line-of-sight: collinear/endpoint "touching" is deliberately treated as non-blocking, which leaks vision through wall corners and wall endpoints — the classic room-corner fog leak — and the existing tests don't cover that case. AoE templates are advisory-only overlays and are largely correct, with minor center-anchored over/under-coverage that is a UX/snapping nuance rather than a rules bug.
- **data-loaders** — The four files flagged as "0 bytes critical" (races/feats/classes/backgrounds.json) are now populated (12-46 KB, modified Jun 8) and load without runtime breakage; the stale-0-bytes claim no longer holds. Loader TS types match the real JSON shapes for monsters, spells, items, weapons, armor, races, backgrounds and conditions, and all 14 PF2e public/data files exist. The real defects are in class skill-choice normalization (corrupted entries that silently drop valid skills from the 5e character builder) and a large amount of dead, unimported JSON bundled in src/data/srd. Monster defenses, armor dex-cap, and spell save/damage parsing are best-effort but correct enough to be low-risk; there is no monster "+9 to hit"/"DC 14" text-parsing path (the SRD data already carries structured attack_bonus/damage_dice fields and the prose is shown raw).
- **data-monsters** — The active monster dataset is src/data/srd/monsters-srd.json (322 Open5e-format monsters), loaded by src/lib/compendium/index.ts:25. Core combat numbers are highly accurate: I spot-checked ~20 iconic SRD monsters (Goblin, Orc, Ogre, Wolf, Skeleton, Zombie, Bandit, Commoner, Giant Spider, Troll, Owlbear, Hill Giant, Vampire, Gelatinous Cube, Adult Red Dragon, Aboleth, Lich, Young Red Dragon, etc.) and every AC, HP, hit dice, CR, ability score, to-hit bonus, and save DC matched the canonical 5e SRD; all 708 'Hit: X (NdM+B)' damage averages are internally consistent (0 mismatches), and high-CR blocks retain full legendary actions, immunities, and spellcasting. The one genuine data-accuracy defect is a cluster of 5 monsters whose passive Perception value (embedded in the senses string and rendered verbatim to the GM) is wrong. Separately there are surfacing gaps: MonsterDetail.tsx renders only walk speed and never shows saving throws, skills, or perception (data is present in JSON but hidden), and 137/322 monsters carry an empty skills object.
- **data-spells** — The loaded source file src/data/srd/spells-srd.json is highly accurate and reasonably complete: 321 SRD spells (319 wotc-srd + 2 o5e), matching the published 5e SRD count. Across 17 spot-checked well-known spells, level, range, components, casting time, concentration flag and ritual flag are all correct in the source; the only source-data error found is Acid Arrow's school (Evocation; should be Conjuration). The real defects live in the normalize layer (src/lib/mechanics/normalize/spell.ts), whose crude regex parsing of `save` and `damage` is snapshotted onto character sheets (CompendiumPage.tsx AddToCharacter) and used for combat enforcement: it invents damage for Wish, drops Magic Missile's damage, and picks the wrong save ability for spells that mention multiple save abilities (e.g. Irresistible Dance). Completeness is otherwise good (Witch Bolt/Tsunami are correctly absent — they are not in the open SRD), and concentration flags are reliable because normalize reads the authoritative requires_concentration field rather than the duration prose.
- **data-equipment** — Weapon, armor, and magic-item DATA is highly accurate against the 5e SRD. All 36 core weapons in weapons-srd.json and weapons-full.json have correct damage dice, damage types, and properties (Longsword 1d8 slashing versatile 1d10, Greataxe 1d12, Shortbow 1d6 piercing 80/320, Rapier 1d8 finesse, Dagger 1d4 finesse thrown — all verified). All 13 armor entries in equipment.json have correct base AC, dex caps, stealth flags, and STR requirements (Leather 11+Dex, Chain Mail 16/str13, Plate 18, Shield +2), and normalize/armor.ts plus the AC formula correctly yield light=no cap, medium=+2, heavy=0. Magic-item rarity and attunement values spot-checked against canonical SRD values are correct. The only real defect is a presentation bug that duplicates the attunement phrase in the item subtitle; remaining items are minor data-hygiene notes, not wrong stats.
- **data-classes-races-feats** — A user can pick a class, race, background, subclass, skills, and spells in the wizard and get a character with correct HP, save proficiencies, trained skills, and spell slots — but almost none of the chosen build's mechanical content is actually applied. Racial ability score increases are never added (abilities pass through raw), racial traits are never applied, background skill/tool/language proficiencies are dropped (saved as a plain note), subclasses are names-only blurbs with zero features, and feats are manual free-text with no link to the feat data and no mechanical effect. There is no per-level class-feature progression data for 5e at all (no Extra Attack, Sneak Attack, Rage, Channel Divinity, etc.). Compounding this, the data files driving the wizard (races.json, classes.json, feats.json) are open5e / Kobold-Press content with non-SRD entries, missing subraces, and at least one wrong ASI (Drow), while the correct SRD data sitting in mpmb-*.json is either name-only or used only by the read-only compendium browser.
- **data-pf2e** — Pathfinder 2e content genuinely ships and is populated at runtime: 14 large JSON datasets live in public/data/pf2e/ (creatures 4702, feats 8402, spells 2455, equipment 8605, ancestries 94, heritages 432, backgrounds 612, archetypes 335, deities 716, weapons 614, armor 75, actions 646, conditions 98, plus 25 classes), fetched lazily by src/lib/compendium/index.ts and wired into 14 compendium categories in src/features/compendium/registry.tsx. In raw breadth the pf2e compendium actually exceeds the 5e side (5e SRD ships only 322 monsters). The character builder's pf2e branch is data-backed for ancestries, backgrounds, classes and spells (ancestry HP/speed are correct and feed HP math). However there are real accuracy/quality defects: every pf2e dataset contains large numbers of duplicate (remaster vs legacy) entries that surface unfiltered in the compendium; the per-class trained-skill count used by the builder over-counts free skill choices for ~13 of 25 classes; the builder uses the 5e ability-array/point-buy system instead of PF2e's native ancestry/background/class boost system, so no attribute boosts are ever applied; full-caster spell slots are over-granted at level 1; and heritage (a mandatory PF2e level-1 choice) is missing from the builder.
- **ux-character** — Character creation and the sheet are broadly functional and thoughtfully built (NaN-guarded NumberField, focus-trapped modals, autosave with flush-on-unmount, good empty-state copy, class/ability/skill hints). But there are real flow defects. The single most damaging: the pf2e "ready-made hero" beginner templates load ability scores (e.g. STR 18) into a 27-point point-buy model whose inputs are hard-capped at 15, producing "-Infinity / 27" and a disabled Next — the exact users the templates target (beginners) get softlocked. Other friction: editing Level on the sheet silently does nothing to HP/slots; NumberField clears to min on every keystroke so min-bounded fields are painful to retype; pool-mode ability assignment has no swap; the creation wizard discards all progress on a stray backdrop/Escape with no confirm; and there is no prepared-spell limit or guidance.
- **ux-combat-play** — The combat engine itself is solid: identity-tracked turn pointer, transactional read-modify-write mutate, correct HP/temp/resistance math, and an undo stack make running initiative live (add/remove/reorder/re-roll) genuinely robust, and the player projection correctly masks enemy HP. The highest-impact problem is in the live session layer: a player who suffers any transient WebSocket drop is silently de-seated server-side (new playerId per connection, seat deleted on disconnect) while their client still shows the seated panel, so their HP edits and dice rolls quietly stop reaching the GM. Secondary friction: there is no delay/ready-action or drag reorder (only one-step up/down nudges), the GM's damage tool never prompts for or tracks concentration unless a seated player happens to have cast through their own panel, and the ambient "turn" signal plus broad signal list are noisy. The LLM advisor degrades gracefully to deterministic picks and the JSON-coercion layer is well hardened, but its failure messaging is thin and the disconnect/staleness cues for players are minimal.
- **ux-global** — probe
- **features-gap** — The app is feature-broad and most roadmap items genuinely ship (notes/wiki, fog-of-war maps + player view, encounter difficulty math, conditions read-model, cloud sync, live sessions, pf2e compendium). The biggest product-level gaps are character onboarding and progression: no D&D Beyond import at all and a thin Pathbuilder importer (abilities/HP/skill ranks only — no spells, feats, inventory, or attacks); no loot/treasure builder (an explicit roadmap promise); and no XP economy (encounter XP is display-only, characters have no XP field, leveling is a manual button). PF2e has real parity holes vs 5e in the kernel: AC omits the level+armor-proficiency term (forced into a manual fudge field), and focus points / spontaneous / prepared casting are unmodeled. Multiclass and class/subclass features are tracking-only by design. The AI assistant is BYO-key and off by default, so its proactive features silently do nothing for most GMs.

## Appendix — excluded (refuted by verification)

### ~~Healing a downed combatant in the tracker does not reset its death saves~~ (calc-combat-engine)
- **Original claim:** applyHealing only raises hp.current toward max and onHeal patches only hp. It never clears defenses.deathSaves, so healing a PC from 0 HP back to positive in the tracker leaves stale successes/failures. rest.ts:52 resets death saves on HP-restoring rest, but in-combat healing does not.
- **Why excluded:** The finding is a category error. It claims applyHealing (engine.ts:243-246) and the onHeal handler (EncounterTracker.tsx:246) fail to clear `defenses.deathSaves`, leaving stale failures when a downed combatant is healed in the tracker. But `defenses.deathSaves` does not exist on the Combatant model at all.

Verified facts:
1. The combat tracker operates on `Combatant` objects. The Combatant schema (src/lib/schemas/encounter.ts:22-44) has fields id, name, kind, characterId, monsterRef, initiative, initBonus, ac, hp, conditions, notes, damageDefenses, cr, level. There is NO `deathSaves` and NO `defenses` field. So applyHealing has nothing of the sort to reset.
2. Death saves live exclusively o

### ~~Feats compendium is sourced from MPMB homebrew dump, not the SRD feats.json~~ (data-loaders)
- **Original claim:** loadFeats5e imports mpmb-feats.json (a MakePlayerMyBeerd character-sheet feat dump, 104 entries keyed by id with terse mechanical blurbs and source codes like {"source":"P","page":165}) instead of the curated src/data/srd/feats.json that was added Jun 8. The MPMB descriptions are abbreviated sheet-text (e.g. Actor: "Advantage on Charisma (Deception) and (Performance) if trying to pass as another…"), not full rules text, and include non-SRD homebrew sources. The Feat type's source field (types.ts:106) matches the MPMB shape, so it loads, but the displayed content is the lower-quality homebrew set rather than the intended SRD data.
- **Why excluded:** The analyst's premise is backwards. I opened both files. CURRENTLY LOADED src/data/srd/mpmb-feats.json (index.ts:68) is a dict of 104 entries whose names are the REAL published D&D 5e feats: Actor, Alert, Great Weapon Master, Lucky, Mage Slayer, Polearm Master, Sentinel, Sharpshooter, Shield Master, Tough, War Caster, etc., source "P" = Player's Handbook, with accurate (if terse) mechanical text (e.g. Sharpshooter, Great Weapon Master verified correct). The file the analyst calls "curated SRD" (src/data/srd/feats.json, 74 entries) actually contains NON-SRD/third-party-homebrew names: Ace Driver (vehicle feat), Boundless Reserves, Brutal Attack, Crossbow Expertise, Deadeye, Floriographer (flo

