From 35728fab3d563430816e9d8d1c4fd027bad663c3 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Tue, 9 Jun 2026 17:17:47 +0200 Subject: [PATCH] Fix spell data normalization: Wish backlash, Magic Missile dice, multi-save spells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parseDamage skips self-inflicted backlash ('you take …'), so Wish no longer snapshots a phantom 1d10 necrotic onto the spell. - parseDamage tolerates a flat modifier on the dice, so Magic Missile captures '1d4+1 force' instead of nothing. - parseSave scores candidate saves and prefers the operative one (introduced by 'must succeed on a' / 'makes a') over incidental 'disadvantage on X saving throws' mentions — Irresistible Dance now resolves to Wis, not Dex. Adds 3 regression tests. Co-Authored-By: Claude Opus 4.8 --- src/lib/mechanics/normalize/normalize.test.ts | 24 +++++++++++ src/lib/mechanics/normalize/spell.ts | 41 +++++++++++++++---- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/src/lib/mechanics/normalize/normalize.test.ts b/src/lib/mechanics/normalize/normalize.test.ts index d96e9d5..4c27e9e 100644 --- a/src/lib/mechanics/normalize/normalize.test.ts +++ b/src/lib/mechanics/normalize/normalize.test.ts @@ -33,6 +33,30 @@ describe('normalizeSpell5e', () => { expect(normalizeSpell5e({ name: 'x' } as never)).toBeNull(); }); + it('does not snapshot self-inflicted backlash damage (Wish)', () => { + const m = normalizeSpell5e({ + slug: 'wish', name: 'Wish', level_int: 9, school: 'Conjuration', desc: + 'The stress of casting this spell weakens you. You take 1d10 necrotic damage per level of the spell you wished to duplicate.', + } as never); + expect(m!.damage).toEqual([]); // 1d10 necrotic is the caster's cost, not the spell's effect + }); + + it('captures dice with a flat modifier (Magic Missile 1d4 + 1 force)', () => { + const m = normalizeSpell5e({ + slug: 'magic-missile', name: 'Magic Missile', level_int: 1, school: 'Evocation', desc: + 'Each dart hits a creature of your choice and deals 1d4 + 1 force damage to its target.', + } as never); + expect(m!.damage).toContainEqual({ dice: '1d4+1', type: 'force' }); + }); + + it('picks the operative save, not an incidental mention (Irresistible Dance → Wis)', () => { + const m = normalizeSpell5e({ + slug: 'irresistible-dance', name: 'Irresistible Dance', level_int: 6, school: 'Enchantment', desc: + 'The target has disadvantage on dexterity saving throws. The creature can make a wisdom saving throw to regain control of its body.', + } as never); + expect(m!.save).toEqual({ ability: 'wis', basis: 'negates' }); + }); + it('normalizes a pf2e Foundry spell with explicit save + concentrate trait', () => { const m = normalizeSpellPf2e({ name: 'Fear', slug: 'fear', diff --git a/src/lib/mechanics/normalize/spell.ts b/src/lib/mechanics/normalize/spell.ts index 9ff158c..e614b9a 100644 --- a/src/lib/mechanics/normalize/spell.ts +++ b/src/lib/mechanics/normalize/spell.ts @@ -14,24 +14,47 @@ function flag(v: unknown): boolean { return false; } -/** Pull the save ability + basis out of a spell's prose description. */ +/** + * Pull the operative save ability + basis out of a spell's prose. A spell can + * mention several abilities (e.g. "disadvantage on Dexterity saving throws … makes + * a Wisdom saving throw to regain control") — prefer the save attached to the + * spell's primary effect over an incidental mention. + */ function parseSave(desc: string): SpellSave | null { - const m = /(strength|dexterity|constitution|intelligence|wisdom|charisma)\s+saving throw/i.exec(desc); - if (!m) return null; - const ability = ABILITY_WORD[m[1]!.toLowerCase()]; - if (!ability) return null; + const re = /(strength|dexterity|constitution|intelligence|wisdom|charisma)\s+saving throw/gi; + let m: RegExpExecArray | null; + let best: { ability: AbilityKey; score: number; idx: number } | null = null; + while ((m = re.exec(desc)) !== null) { + const ability = ABILITY_WORD[m[1]!.toLowerCase()]; + if (!ability) continue; + const before = desc.slice(Math.max(0, m.index - 32), m.index).toLowerCase(); + // Incidental mentions ("has disadvantage on X saving throws") aren't the save you roll. + let score = 0; + if (/(?:dis)?advantage on\s*$/.test(before) || /(?:dis)?advantage on [a-z ]*$/.test(before)) score -= 2; + // The save tied to the effect is usually introduced by these. + if (/(?:succeed on|must (?:make|succeed)|makes?)\s*(?:a|an)?\s*$/.test(before)) score += 2; + if (best === null || score > best.score) best = { ability, score, idx: m.index }; + } + if (!best) return null; const half = /half as much|half the damage|half damage|halve the damage/i.test(desc); - return { ability, basis: half ? 'half' : 'negates' }; + return { ability: best.ability, basis: half ? 'half' : 'negates' }; } -/** Best-effort dice/type pairs, e.g. "12d6 fire damage" or "takes 1d8 cold damage". */ +/** + * Best-effort dice/type pairs, e.g. "12d6 fire damage", "1d4 + 1 force damage", or + * "takes 1d8 cold damage". Skips self-inflicted backlash ("you take …", e.g. Wish's + * 1d10 necrotic), which is the caster's cost, not damage the spell deals to a target. + */ function parseDamage(desc: string): { dice: string; type: DamageType }[] { const out: { dice: string; type: DamageType }[] = []; const seen = new Set(); - const re = /(\d+d\d+)\s+(\w+)\s+damage/gi; + // Allow an optional flat modifier on the dice ("1d4 + 1 force damage"). + const re = /(\d+d\d+(?:\s*[+-]\s*\d+)?)\s+(\w+)\s+damage/gi; let m: RegExpExecArray | null; while ((m = re.exec(desc)) !== null) { - const dice = m[1]!.toLowerCase(); + const before = desc.slice(Math.max(0, m.index - 16), m.index).toLowerCase(); + if (/\byou take\s*$/.test(before)) continue; // self-damage backlash, not spell output + const dice = m[1]!.replace(/\s+/g, '').toLowerCase(); // "1d4 + 1" -> "1d4+1" const type = asDamageType(m[2]!); if (!type) continue; const key = `${dice}:${type}`;