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[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[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); }); });