Files
ttrpg_manager/src/lib/rules/rules.test.ts
T
NilsBriggen 8fd530df73 Perfection pass: complete level-up/build flows + dying polish
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>
2026-06-10 20:37:01 +02:00

192 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});