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:
@@ -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',
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
Reference in New Issue
Block a user