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>
126 lines
5.6 KiB
TypeScript
126 lines
5.6 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { dnd5e } from './dnd5e';
|
|
import { pf2e } from './pf2e';
|
|
import { applyRest } from './rest';
|
|
import type { CharacterRulesInput } from './types';
|
|
import type { Character } from '@/lib/schemas/character';
|
|
import { characterDefaults } from '@/lib/schemas/character';
|
|
|
|
const fighter5e: CharacterRulesInput = {
|
|
level: 5,
|
|
abilities: { str: 18, dex: 14, con: 16, int: 10, wis: 12, cha: 8 },
|
|
skillRanks: { perception: 'trained' },
|
|
};
|
|
|
|
describe('5e derived combat math', () => {
|
|
it('weapon attack: ability + proficiency + item, ability added to damage', () => {
|
|
const r = dnd5e.weaponAttack(fighter5e, { ability: 'str', rank: 'trained', damageDice: '1d8', itemBonus: 1 });
|
|
expect(r.toHit).toBe(4 + 3 + 1); // STR +4, PB +3, item +1
|
|
expect(r.damage).toBe('1d8+5'); // STR +4 + item +1
|
|
});
|
|
|
|
it('off-hand damage omits the ability modifier', () => {
|
|
const r = dnd5e.weaponAttack(fighter5e, { ability: 'str', rank: 'trained', damageDice: '1d6', addAbilityToDamage: false });
|
|
expect(r.damage).toBe('1d6');
|
|
});
|
|
|
|
it('spell attack and passive perception', () => {
|
|
expect(dnd5e.spellAttackBonus(fighter5e, 'wis')).toBe(3 + 1);
|
|
expect(dnd5e.passivePerception(fighter5e)).toBe(10 + 1 + 3); // 10 + WIS + PB
|
|
});
|
|
|
|
it('carrying capacity is STR*15', () => {
|
|
expect(dnd5e.carryingCapacity(fighter5e)).toEqual({ encumbered: 90, max: 270, unit: 'lb' });
|
|
});
|
|
});
|
|
|
|
describe('pf2e derived combat math', () => {
|
|
const pcg: CharacterRulesInput = {
|
|
level: 3,
|
|
abilities: { str: 18, dex: 12, con: 14, int: 10, wis: 14, cha: 10 },
|
|
perceptionRank: 'expert',
|
|
spellcastingRank: 'trained',
|
|
};
|
|
it('weapon attack adds level + rank bonus', () => {
|
|
const r = pf2e.weaponAttack(pcg, { ability: 'str', rank: 'trained', damageDice: '1d8' });
|
|
expect(r.toHit).toBe(4 + (3 + 2)); // STR +4, trained = level3 + 2
|
|
expect(r.damage).toBe('1d8+4');
|
|
});
|
|
it('passive perception uses perception proficiency', () => {
|
|
expect(pf2e.passivePerception(pcg)).toBe(10 + 2 + (3 + 4)); // 10 + WIS + (level + expert)
|
|
});
|
|
it('carrying capacity is Bulk', () => {
|
|
expect(pf2e.carryingCapacity(pcg)).toEqual({ encumbered: 5 + 4, max: 10 + 4, unit: 'Bulk' });
|
|
});
|
|
});
|
|
|
|
function makeCharacter(): Character {
|
|
return {
|
|
id: 'c', campaignId: 'camp', system: '5e', kind: 'pc',
|
|
name: 'Test', ancestry: '', className: 'Wizard', level: 5,
|
|
abilities: { str: 8, dex: 14, con: 12, int: 16, wis: 10, cha: 10 },
|
|
hp: { current: 4, max: 30, temp: 0 }, speed: 30, armorBonus: 0,
|
|
skillRanks: {}, saveRanks: {}, perceptionRank: 'trained',
|
|
...characterDefaults(),
|
|
resources: [
|
|
{ id: 'r1', name: 'Sorcery Points', current: 0, max: 5, recovery: 'long' },
|
|
{ id: 'r2', name: 'Channel Divinity', current: 0, max: 1, recovery: 'short' },
|
|
],
|
|
spellcasting: {
|
|
slots: [{ level: 1, max: 4, current: 0 }, { level: 2, max: 3, current: 1 }],
|
|
pact: { level: 1, max: 2, current: 0 },
|
|
spells: [],
|
|
},
|
|
defenses: { ...characterDefaults().defenses, exhaustion: 2 },
|
|
notes: '', createdAt: '', updatedAt: '',
|
|
};
|
|
}
|
|
|
|
describe('applyRest', () => {
|
|
it('short rest restores short resources + pact, not HP/slots/long resources', () => {
|
|
const c = makeCharacter();
|
|
const short = dnd5e.restOptions.find((o) => o.id === 'short')!;
|
|
const patch = applyRest(c, short);
|
|
expect(patch.hp).toBeUndefined();
|
|
expect(patch.resources!.find((r) => r.id === 'r2')!.current).toBe(1); // short resource restored
|
|
expect(patch.resources!.find((r) => r.id === 'r1')!.current).toBe(0); // long resource untouched
|
|
expect(patch.spellcasting!.pact!.current).toBe(2); // pact restored
|
|
expect(patch.spellcasting!.slots[0]!.current).toBe(0); // slots NOT restored on short rest
|
|
});
|
|
|
|
it('long rest restores HP, all slots, all resources, steps exhaustion down, clears death saves', () => {
|
|
const c = makeCharacter();
|
|
const long = dnd5e.restOptions.find((o) => o.id === 'long')!;
|
|
const patch = applyRest(c, long);
|
|
expect(patch.hp!.current).toBe(30);
|
|
expect(patch.resources!.every((r) => r.current === r.max)).toBe(true);
|
|
expect(patch.spellcasting!.slots.every((s) => s.current === s.max)).toBe(true);
|
|
expect(patch.defenses!.exhaustion).toBe(1);
|
|
expect(patch.defenses!.deathSaves).toEqual({ successes: 0, failures: 0 });
|
|
});
|
|
|
|
it('any rest ends ongoing concentration', () => {
|
|
const c = { ...makeCharacter(), concentration: { spellId: 's', spellName: 'Bless' } };
|
|
const short = dnd5e.restOptions.find((o) => o.id === 'short')!;
|
|
expect(applyRest(c, short).concentration).toBeNull();
|
|
// no concentration → no concentration key in the patch
|
|
expect('concentration' in applyRest(makeCharacter(), short)).toBe(false);
|
|
});
|
|
|
|
it('long rest caps restored HP at the effective max while exhaustion stays 4+', () => {
|
|
const c = { ...makeCharacter(), defenses: { ...makeCharacter().defenses, exhaustion: 5 } };
|
|
const long = dnd5e.restOptions.find((o) => o.id === 'long')!;
|
|
const patch = applyRest(c, long);
|
|
// exhaustion 5 → 4 after the rest; max HP still halved (30 → 15)
|
|
expect(patch.defenses!.exhaustion).toBe(4);
|
|
expect(patch.hp!.current).toBe(15);
|
|
});
|
|
|
|
it('a per-resource recoverStep restores by that amount, not to max (Hit Dice)', () => {
|
|
const base = makeCharacter();
|
|
const c = { ...base, resources: [{ id: 'hd', name: 'Hit Dice', current: 0, max: 5, recovery: 'long' as const, recoverStep: 3 }] };
|
|
const long = dnd5e.restOptions.find((o) => o.id === 'long')!;
|
|
expect(applyRest(c, long).resources![0]!.current).toBe(3); // 0 + ceil(5/2)=3, not 5
|
|
});
|
|
});
|