Fix spell data normalization: Wish backlash, Magic Missile dice, multi-save spells

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 17:17:47 +02:00
parent f602c832e6
commit 35728fab3d
2 changed files with 56 additions and 9 deletions
@@ -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',
+31 -8
View File
@@ -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 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) return null;
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<string>();
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}`;