Build MVP: campaigns, characters, combat, dice, compendium
- Rules abstraction (5e + pf2e) behind one interface, fully tested - Pure combat engine: turn-order safe across add/remove/reorder/init-change - Dexie storage with real cascade deletes + Zod validation on write - Seedable dice engine with notation parser - Lazy SRD compendium (code-split), bestiary -> combat - Strict TS, 54 unit tests, Playwright e2e smoke (all green) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { abilityModifier } from './abilities';
|
||||
import { dnd5e, proficiencyBonus } from './dnd5e';
|
||||
import { pf2e, pf2eProficiency } from './pf2e';
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user