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:
2026-06-08 00:09:42 +02:00
parent fe84dc365d
commit 1a9e5e2c18
93 changed files with 412052 additions and 9 deletions
+103
View File
@@ -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
});
});