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 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 17:11:00 +02:00
parent 45d74ce5d7
commit da3dbbedba
8 changed files with 966 additions and 17 deletions
+57
View File
@@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest';
import { abilityModifier } from './abilities';
import { dnd5e, proficiencyBonus } from './dnd5e';
import { pf2e, pf2eProficiency } from './pf2e';
import { pf2eSlots } from './pf2e/progression';
import { applyRest } from './rest';
import type { CharacterRulesInput } from './types';
describe('abilityModifier', () => {
@@ -100,4 +102,59 @@ describe('pf2e proficiency', () => {
expect(saves.dex).toBe(2 + (4 + 2));
expect(saves.str).toBe(0); // not a save: just ability mod
});
it('AC scales with level + armor proficiency (not just 10 + Dex)', () => {
// level-5 fighter, Dex +1, trained in defense, unarmored: 10 + 1 + (5 + 2) = 18
const ac = pf2e.baseArmorClass({
level: 5,
abilities: { str: 18, dex: 12, con: 14, int: 10, wis: 12, cha: 10 },
acRank: 'trained',
});
expect(ac).toBe(18);
});
it('weapon attack: potency adds to-hit only, never flat damage', () => {
// level-3, STR 18 (+4), trained (3+2=+5), +1 potency, 1d8
const r = pf2e.weaponAttack(
{ level: 3, abilities: { str: 18, dex: 10, con: 10, int: 10, wis: 10, cha: 10 } },
{ ability: 'str', rank: 'trained', itemBonus: 1, damageDice: '1d8' },
);
expect(r.toHit).toBe(4 + 5 + 1); // +10
expect(r.damage).toBe('1d8+4'); // STR only — no +item on damage
});
it('weapon attack reports MAP (-5/-10, agile -4/-8) and striking dice', () => {
const base = { level: 3, abilities: { str: 18, dex: 10, con: 10, int: 10, wis: 10, cha: 10 } };
const sword = pf2e.weaponAttack(base, { ability: 'str', rank: 'trained', damageDice: '1d8', striking: 2 });
expect(sword.toHit).toBe(9);
expect(sword.map).toEqual({ second: 4, third: -1 }); // -5 / -10
expect(sword.damage).toBe('2d8+4'); // striking doubles the dice
const agile = pf2e.weaponAttack(base, { ability: 'str', rank: 'trained', damageDice: '1d6', agile: true });
expect(agile.map).toEqual({ second: 5, third: 1 }); // -4 / -8
});
it('full caster has 2 first-rank slots at level 1, 3 from level 2', () => {
expect(pf2eSlots('full', 1)).toEqual([{ level: 1, max: 2, current: 2 }]);
expect(pf2eSlots('full', 2)).toEqual([{ level: 1, max: 3, current: 3 }]);
// level 3: 3 first-rank + 2 (newly unlocked) second-rank
expect(pf2eSlots('full', 3)).toEqual([
{ level: 1, max: 3, current: 3 },
{ level: 2, max: 2, current: 2 },
]);
});
});
describe('pf2e Refocus', () => {
it('restores one Focus Point, not the whole pool', () => {
const refocus = pf2e.restOptions.find((o) => o.id === 'refocus')!;
const c = {
resources: [{ id: 'f', name: 'Focus Points', current: 0, max: 3, recovery: 'short' as const }],
hp: { current: 10, max: 10, temp: 0 },
concentration: null,
spellcasting: { slots: [], spells: [] },
defenses: { exhaustion: 0, deathSaves: { successes: 0, failures: 0 } },
} as unknown as Parameters<typeof applyRest>[0];
const patch = applyRest(c, refocus);
expect(patch.resources?.[0]?.current).toBe(1); // +1, clamped to max — not 3
});
});