From da3dbbedbaf72a9eac002bb102f1a9d9369ae131 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Tue, 9 Jun 2026 17:11:00 +0200 Subject: [PATCH] Fix PF2e rules math: AC proficiency, weapon damage, MAP, striking, slots, Refocus Confirmed accuracy defects from the audit: - AC now scales with level + armor proficiency (10 + Dex + (level+rank) + item), defaulting to trained; was 10 + Dex only, wrong by +3..+28. - Weapon potency (itemBonus) applies to the attack roll ONLY, not flat damage. - weaponAttack now reports the Multiple Attack Penalty (-5/-10, agile -4/-8) and expands striking runes into extra weapon dice; AttacksSection surfaces MAP. - Full-caster spell slots: 2 at the rank's unlock level, 3 thereafter (was a flat 3, giving a level-1 caster 3 first-rank slots instead of 2). - PF2e Refocus restores one Focus Point (recoverStep), not the whole pool. Adds 6 regression tests. types.ts gains WeaponInput.agile/striking, WeaponResult.map, CharacterRulesInput.acRank, RestOption.recoverStep. Co-Authored-By: Claude Opus 4.8 --- .claude/scheduled_tasks.lock | 1 + docs/CODE_AUDIT_2026-06-09.md | 832 ++++++++++++++++++ .../characters/sheet/AttacksSection.tsx | 5 + src/lib/rules/pf2e/index.ts | 39 +- src/lib/rules/pf2e/progression.ts | 10 +- src/lib/rules/rest.ts | 12 +- src/lib/rules/rules.test.ts | 57 ++ src/lib/rules/types.ts | 27 +- 8 files changed, 966 insertions(+), 17 deletions(-) create mode 100644 .claude/scheduled_tasks.lock create mode 100644 docs/CODE_AUDIT_2026-06-09.md diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..4e53cb4 --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"fc2b3245-b978-4e8f-9dd5-3af100feb30d","pid":792630,"procStart":"19284485","acquiredAt":1781014150559} \ No newline at end of file diff --git a/docs/CODE_AUDIT_2026-06-09.md b/docs/CODE_AUDIT_2026-06-09.md new file mode 100644 index 0000000..bf77252 --- /dev/null +++ b/docs/CODE_AUDIT_2026-06-09.md @@ -0,0 +1,832 @@ +# 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. + +## 🎲 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 ' 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 '/'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 ' 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 ' + N 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 " 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 ' 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