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:
@@ -77,6 +77,11 @@ export function AttacksSection({ c, update }: SectionProps) {
|
||||
>
|
||||
{formatModifier(result.toHit)}
|
||||
</button>
|
||||
{result.map && (
|
||||
<span className="text-[11px] text-muted" title="Multiple Attack Penalty (2nd / 3rd attack this turn)">
|
||||
/{formatModifier(result.map.second)}/{formatModifier(result.map.third)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted">to hit ·</span>
|
||||
<button
|
||||
onClick={() => rollAndShow({ expression: result.damage, label: `${a.name} — damage` })}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user