8fd530df73
Second adversarial audit of the freshly-shipped code; fixed bugs + completeness gaps. - abilityBuild stays in sync with totals on level-up (appendLevelIncreases) - 5e feat picker in level-up; PF2e feat-name autocomplete; expertise sets ranks - Higher-level creation collects PF2e skill increases + 5e expertise - PF2e heritage: schema field, wizard picker, sheet, Pathbuilder import - 5e Hit Dice as a tracked resource (half-level long-rest recovery via recoverStep) - Dying flow: knockout sets HP 0, stays unconscious at 0 HP, healing wakes +Wounded, Heroic Recovery (spend hero points); damage-while-dying + recovery reminders (pf2e) - Healing caps at effective max (drained/exhaustion-4) on sheet, tracker, and rest; massive-damage death uses effective max; persistent-damage badge - UX: in-place valued-condition steppers, point-buy budget caps, prepared-vs-known spell guidance, granted skills in review, system-gated score generator Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
192 lines
7.3 KiB
TypeScript
192 lines
7.3 KiB
TypeScript
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', () => {
|
||
it.each([
|
||
[10, 0],
|
||
[11, 0],
|
||
[12, 1],
|
||
[8, -1],
|
||
[20, 5],
|
||
[1, -5],
|
||
[30, 10],
|
||
])('score %i -> %i', (score, mod) => {
|
||
expect(abilityModifier(score)).toBe(mod);
|
||
});
|
||
|
||
it('never returns NaN for garbage input', () => {
|
||
expect(abilityModifier(Number.NaN)).toBe(0);
|
||
expect(abilityModifier(Infinity)).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('5e proficiency bonus', () => {
|
||
it.each([
|
||
[1, 2],
|
||
[4, 2],
|
||
[5, 3],
|
||
[9, 4],
|
||
[13, 5],
|
||
[17, 6],
|
||
[20, 6],
|
||
])('level %i -> +%i', (lvl, pb) => {
|
||
expect(proficiencyBonus(lvl)).toBe(pb);
|
||
});
|
||
|
||
it('clamps out-of-range levels (old app gave +251 at level 1000)', () => {
|
||
expect(proficiencyBonus(1000)).toBe(6);
|
||
expect(proficiencyBonus(0)).toBe(2);
|
||
expect(proficiencyBonus(-5)).toBe(2);
|
||
});
|
||
});
|
||
|
||
describe('5e derived stats', () => {
|
||
const input: CharacterRulesInput = {
|
||
level: 5,
|
||
abilities: { str: 16, dex: 14, con: 14, int: 10, wis: 12, cha: 8 },
|
||
skillRanks: { athletics: 'trained', stealth: 'expert' },
|
||
saveRanks: { str: 'trained', con: 'trained' },
|
||
};
|
||
|
||
it('adds proficiency to trained skills and double for expertise', () => {
|
||
const skills = dnd5e.skillModifiers(input);
|
||
const athletics = skills.find((s) => s.key === 'athletics')!;
|
||
const stealth = skills.find((s) => s.key === 'stealth')!;
|
||
const arcana = skills.find((s) => s.key === 'arcana')!;
|
||
expect(athletics.modifier).toBe(3 + 3); // str mod + PB(5)=3
|
||
expect(stealth.modifier).toBe(2 + 6); // dex mod + 2*PB
|
||
expect(arcana.modifier).toBe(0); // untrained int
|
||
});
|
||
|
||
it('computes spell save DC', () => {
|
||
expect(dnd5e.spellSaveDc!(input, 'wis')).toBe(8 + 3 + 1);
|
||
});
|
||
|
||
it('unarmored AC does not penalise below 10 + dex', () => {
|
||
// regression: old app applied a phantom -1 from heavy armor DEX cap
|
||
const ac = dnd5e.baseArmorClass(input);
|
||
expect(ac).toBe(10 + 2);
|
||
});
|
||
|
||
it('heavy armor (dexCap 0) ignores a negative DEX rather than penalising AC', () => {
|
||
// regression: Math.min(dexMod, 0) wrongly applied a negative DEX to heavy armor.
|
||
const lowDex: CharacterRulesInput = {
|
||
level: 1,
|
||
abilities: { str: 16, dex: 8, con: 14, int: 10, wis: 12, cha: 8 }, // DEX 8 = -1
|
||
equippedArmor: { baseAc: 18, dexCap: 0 }, // plate
|
||
};
|
||
expect(dnd5e.baseArmorClass(lowDex)).toBe(18); // not 17
|
||
});
|
||
});
|
||
|
||
describe('pf2e proficiency', () => {
|
||
it('adds level + rank bonus when trained, 0 when untrained', () => {
|
||
expect(pf2eProficiency(5, 'untrained')).toBe(0);
|
||
expect(pf2eProficiency(5, 'trained')).toBe(5 + 2);
|
||
expect(pf2eProficiency(5, 'expert')).toBe(5 + 4);
|
||
expect(pf2eProficiency(10, 'legendary')).toBe(10 + 8);
|
||
});
|
||
|
||
it('skill modifier folds in ability + proficiency', () => {
|
||
const skills = pf2e.skillModifiers({
|
||
level: 3,
|
||
abilities: { str: 18, dex: 10, con: 12, int: 10, wis: 14, cha: 10 },
|
||
skillRanks: { athletics: 'expert' },
|
||
});
|
||
const athletics = skills.find((s) => s.key === 'athletics')!;
|
||
expect(athletics.modifier).toBe(4 + (3 + 4)); // str mod + (level + expert bonus)
|
||
});
|
||
|
||
it('only con/dex/wis are real saves', () => {
|
||
const saves = pf2e.saveModifiers({
|
||
level: 4,
|
||
abilities: { str: 10, dex: 14, con: 16, int: 10, wis: 12, cha: 10 },
|
||
saveRanks: { con: 'expert', dex: 'trained', wis: 'trained' },
|
||
});
|
||
expect(saves.con).toBe(3 + (4 + 4));
|
||
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
|
||
});
|
||
});
|
||
|
||
describe('pf2e Rest for the Night', () => {
|
||
it('decays drained/doomed, clears wounded/fatigued, and caps HP at the post-decay effective max', () => {
|
||
const rest = pf2e.restOptions.find((o) => o.id === 'rest')!;
|
||
const c = {
|
||
system: 'pf2e', level: 6,
|
||
resources: [],
|
||
hp: { current: 5, max: 50, temp: 0 },
|
||
concentration: null,
|
||
spellcasting: { slots: [], spells: [] },
|
||
conditions: [{ name: 'Drained', value: 2 }, { name: 'Fatigued' }],
|
||
defenses: { exhaustion: 0, deathSaves: { successes: 0, failures: 0 }, dying: 0, wounded: 2, doomed: 1, heroPoints: 1, inspiration: false },
|
||
} as unknown as Parameters<typeof applyRest>[0];
|
||
const patch = applyRest(c, rest);
|
||
expect(patch.conditions).toEqual([{ name: 'Drained', value: 1 }]); // drained −1, fatigued gone
|
||
expect(patch.defenses!.wounded).toBe(0);
|
||
expect(patch.defenses!.doomed).toBe(0);
|
||
// drained 1 remains after decay → effective max 50 − 6 = 44, not 50
|
||
expect(patch.hp!.current).toBe(44);
|
||
});
|
||
});
|