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
+31 -8
View File
@@ -11,9 +11,9 @@ import { ABILITY_LABELS, abilityModifier, defaultAbilityScores, formatDamage } f
import { PF2E_SKILLS } from './skills';
const PF2E_RESTS: readonly RestOption[] = [
// Refocus restores 1 Focus Point (short-recovery resources).
{ id: 'refocus', label: 'Refocus', restoresHp: false, restoresSlots: false, restoresPact: false, recovers: ['short'] },
// A full night's rest restores HP, spell slots, and daily resources.
// Refocus restores exactly ONE Focus Point (recoverStep: 1), not the whole pool.
{ id: 'refocus', label: 'Refocus', restoresHp: false, restoresSlots: false, restoresPact: false, recovers: ['short'], recoverStep: 1 },
// A full night's rest restores HP, spell slots, and daily resources (to max).
{ id: 'rest', label: 'Rest for the Night', restoresHp: true, restoresSlots: true, restoresPact: true, recovers: ['long', 'daily'] },
];
@@ -35,6 +35,17 @@ export function pf2eProficiency(level: number, rank: ProficiencyRank): number {
return lvl + RANK_BONUS[rank];
}
/**
* Apply a striking rune to a weapon's damage dice by multiplying the dice COUNT
* (striking = 2×, greater = 3×, major = 4×). "1d8" + striking(2) -> "2d8". Leaves
* non-"NdM" expressions and the no-rune case untouched.
*/
export function expandStriking(dice: string | undefined, striking?: number): string | undefined {
const mult = striking && striking > 1 ? Math.floor(striking) : 1;
if (mult === 1 || !dice) return dice;
return dice.replace(/(\d+)d(\d+)/gi, (_m, n: string, faces: string) => `${Number(n) * mult}d${faces}`);
}
export const pf2e: RulesSystem = {
id: 'pf2e',
label: 'Pathfinder 2e',
@@ -78,12 +89,15 @@ export const pf2e: RulesSystem = {
baseArmorClass(input) {
const dex = abilityModifier(input.abilities.dex);
const misc = input.armorBonus ?? 0;
// PF2e AC scales with level: 10 + Dex(capped) + proficiency(level + rank) + item.
// `armorBonus` now carries only true item/circumstance bonuses (e.g. a shield).
const prof = pf2eProficiency(input.level, input.acRank ?? 'trained');
if (input.equippedArmor) {
const { baseAc, dexCap } = input.equippedArmor;
return baseAc + (dexCap === null ? dex : Math.min(dex, dexCap)) + misc;
// baseAc embeds the armor's flat AC (10 + armor item bonus); add Dex + prof + misc.
return baseAc + (dexCap === null ? dex : Math.min(dex, dexCap)) + prof + misc;
}
// 10 + dex + (item/proficiency folded into armorBonus for the MVP sheet).
return 10 + dex + misc;
return 10 + dex + prof + misc;
},
spellSaveDc(input, ability) {
@@ -101,9 +115,18 @@ export const pf2e: RulesSystem = {
weaponAttack(input, weapon) {
const abilityMod = abilityModifier(input.abilities[weapon.ability]);
const item = weapon.itemBonus ?? 0;
// Potency (itemBonus) applies to the attack roll only — never a flat damage bonus.
const toHit = abilityMod + pf2eProficiency(input.level, weapon.rank) + item;
const dmgMod = (weapon.addAbilityToDamage !== false ? abilityMod : 0) + item;
return { toHit, damage: formatDamage(weapon.damageDice, dmgMod) };
const dmgMod = weapon.addAbilityToDamage !== false ? abilityMod : 0;
// Striking runes add weapon dice (2/3/4×), they do not add a flat bonus.
const dice = expandStriking(weapon.damageDice, weapon.striking);
// Multiple Attack Penalty: -5/-10, or -4/-8 with an agile weapon.
const penalty = weapon.agile ? 4 : 5;
return {
toHit,
damage: formatDamage(dice, dmgMod),
map: { second: toHit - penalty, third: toHit - penalty * 2 },
};
},
carryingCapacity(input) {
+6 -4
View File
@@ -53,9 +53,10 @@ export function pf2eHp(classHp: number, level: number, conMod: number, ancestryH
}
/**
* Spell slots per rank for a full caster. A new rank unlocks at each odd level;
* each available rank gets 3 slots (the standard for prepared/spontaneous casters).
* Approximate and editable on the sheet.
* Spell slots per rank for a full caster. A new rank unlocks at each odd level
* (rank r at level 2r-1); a rank holds 2 slots on the level it unlocks and 3 from
* the next level on (e.g. a level-1 caster has 2 first-rank slots, not 3). The
* 10th-rank slot is a single capstone slot. Approximate and editable on the sheet.
*/
export function pf2eSlots(caster: ClassDef['caster'], level: number): { level: number; max: number; current: number }[] {
if (caster !== 'full') return [];
@@ -63,7 +64,8 @@ export function pf2eSlots(caster: ClassDef['caster'], level: number): { level: n
const topRank = Math.min(10, Math.ceil(lvl / 2));
const slots: { level: number; max: number; current: number }[] = [];
for (let r = 1; r <= topRank; r++) {
const max = r === 10 ? 1 : 3;
const unlockLevel = 2 * r - 1;
const max = r === 10 ? 1 : lvl <= unlockLevel ? 2 : 3;
slots.push({ level: r, max, current: max });
}
return slots;
+8 -4
View File
@@ -18,10 +18,14 @@ export function applyRest(c: Character, opt: RestOption): Partial<Character> {
// Any rest ends ongoing concentration (you stop holding the spell to rest).
if (c.concentration) patch.concentration = null;
// Resources refresh when their recovery tag is covered by this rest.
patch.resources = c.resources.map((r) =>
opt.recovers.includes(r.recovery) ? { ...r, current: r.max } : r,
);
// Resources refresh when their recovery tag is covered by this rest. A rest with
// `recoverStep` (PF2e Refocus) restores by that step, clamped to max, rather than
// refilling the whole pool — so Refocus returns one Focus Point, not all of them.
patch.resources = c.resources.map((r) => {
if (!opt.recovers.includes(r.recovery)) return r;
const next = opt.recoverStep !== undefined ? Math.min(r.max, r.current + opt.recoverStep) : r.max;
return { ...r, current: next };
});
if (opt.restoresSlots || opt.restoresPact) {
patch.spellcasting = {
+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
});
});
+26 -1
View File
@@ -27,18 +27,31 @@ export interface WeaponInput {
ability: AbilityKey;
/** proficiency in the weapon */
rank: ProficiencyRank;
/** flat magic/enhancement bonus to hit and damage */
/**
* Flat magic/enhancement bonus. In 5e this applies to BOTH to-hit and damage.
* In PF2e this is a weapon potency rune — it applies to the attack roll ONLY,
* never to a flat damage bonus (extra magic-weapon damage comes from `striking`).
*/
itemBonus?: number;
/** damage dice, e.g. "1d8" */
damageDice?: string;
/** add the ability modifier to damage (off-hand/some ranged turn this off) */
addAbilityToDamage?: boolean;
/** PF2e: the agile trait lowers the multiple-attack penalty to -4/-8. */
agile?: boolean;
/** PF2e striking-rune tier as a damage-dice multiplier: 2=striking, 3=greater, 4=major. 1/undefined = none. */
striking?: number;
}
export interface WeaponResult {
toHit: number;
/** ready-to-roll damage expression, e.g. "1d8+3" */
damage: string;
/**
* PF2e multiple-attack penalty: the to-hit for the 2nd and 3rd+ attack actions
* this turn (-5/-10, or -4/-8 with an agile weapon). Absent for 5e.
*/
map?: { second: number; third: number };
}
export interface CarryingCapacity {
@@ -59,6 +72,12 @@ export interface RestOption {
/** resource.recovery tags that refresh */
recovers: readonly Recovery[];
reduceExhaustion?: boolean;
/**
* How much of a matched resource this rest restores. Omitted/undefined = restore
* to max (5e short/long rest). A number restores by that step, clamped to max —
* PF2e Refocus uses `1` (one Focus Point), not the whole pool.
*/
recoverStep?: number;
}
export type Recovery = 'short' | 'long' | 'daily' | 'none';
@@ -134,4 +153,10 @@ export interface CharacterRulesInput {
perceptionRank?: ProficiencyRank;
/** PF2e: spellcasting proficiency. */
spellcastingRank?: ProficiencyRank;
/**
* PF2e: armor/unarmored-defense proficiency. PF2e AC scales with level via this
* rank (10 + Dex + (level + rankBonus) + item). Missing = trained (the common
* level-1 case). Ignored by 5e, whose AC has no proficiency term.
*/
acRank?: ProficiencyRank;
}