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
+150
View File
@@ -0,0 +1,150 @@
import { describe, it, expect } from 'vitest';
import type { Combatant, Encounter } from '@/lib/schemas/encounter';
import {
addCombatant,
applyDamage,
applyHealing,
currentCombatant,
endEncounter,
moveCombatant,
nextTurn,
previousTurn,
removeCombatant,
setTempHp,
startEncounter,
updateCombatant,
} from './engine';
function mk(id: string, initiative: number, hp = 10): Combatant {
return {
id,
name: id,
kind: 'monster',
initiative,
ac: 12,
hp: { current: hp, max: hp, temp: 0 },
conditions: [],
notes: '',
};
}
function encounter(combatants: Combatant[]): Encounter {
return {
id: 'e1', campaignId: 'c1', name: 'Test', status: 'planning',
round: 0, turnIndex: 0, combatants,
createdAt: '', updatedAt: '',
};
}
describe('initiative & turns', () => {
it('starts sorted by initiative descending at round 1', () => {
const e = startEncounter(encounter([mk('a', 5), mk('b', 20), mk('c', 12)]));
expect(e.combatants.map((c) => c.id)).toEqual(['b', 'c', 'a']);
expect(e.round).toBe(1);
expect(currentCombatant(e)?.id).toBe('b');
});
it('nextTurn wraps and increments the round', () => {
let e = startEncounter(encounter([mk('a', 20), mk('b', 10)]));
e = nextTurn(e);
expect(currentCombatant(e)?.id).toBe('b');
expect(e.round).toBe(1);
e = nextTurn(e);
expect(currentCombatant(e)?.id).toBe('a');
expect(e.round).toBe(2);
});
it('previousTurn wraps back and decrements the round', () => {
let e = startEncounter(encounter([mk('a', 20), mk('b', 10)]));
e = previousTurn(e);
expect(currentCombatant(e)?.id).toBe('b');
expect(e.round).toBe(1); // never below 1
});
});
describe('mutating mid-combat keeps the turn anchored (C16-C19 regressions)', () => {
it('removing a combatant BEFORE the current turn keeps the same active combatant', () => {
let e = startEncounter(encounter([mk('a', 30), mk('b', 20), mk('c', 10)]));
e = nextTurn(e); // current = b
expect(currentCombatant(e)?.id).toBe('b');
e = removeCombatant(e, 'a'); // remove someone earlier in order
expect(currentCombatant(e)?.id).toBe('b'); // still b's turn
});
it('removing the CURRENT combatant passes the turn to the next', () => {
let e = startEncounter(encounter([mk('a', 30), mk('b', 20), mk('c', 10)]));
e = nextTurn(e); // current = b
e = removeCombatant(e, 'b');
expect(currentCombatant(e)?.id).toBe('c');
});
it('removing the last combatant in order wraps to top and bumps round', () => {
let e = startEncounter(encounter([mk('a', 30), mk('b', 20), mk('c', 10)]));
e = nextTurn(e); e = nextTurn(e); // current = c (last)
e = removeCombatant(e, 'c');
expect(currentCombatant(e)?.id).toBe('a');
expect(e.round).toBe(2);
});
it('adding a combatant mid-combat inserts by initiative without changing whose turn it is', () => {
let e = startEncounter(encounter([mk('a', 30), mk('b', 10)]));
e = nextTurn(e); // current = b
e = addCombatant(e, mk('c', 25)); // inserts between a and b
expect(e.combatants.map((x) => x.id)).toEqual(['a', 'c', 'b']);
expect(currentCombatant(e)?.id).toBe('b'); // unchanged
});
it('changing initiative re-sorts but keeps the turn anchored', () => {
let e = startEncounter(encounter([mk('a', 30), mk('b', 20), mk('c', 10)]));
e = nextTurn(e); // current = b
e = updateCombatant(e, 'c', { initiative: 99 });
expect(e.combatants[0]?.id).toBe('c');
expect(currentCombatant(e)?.id).toBe('b');
});
it('moveCombatant nudges order but keeps the turn anchored', () => {
let e = startEncounter(encounter([mk('a', 30), mk('b', 20), mk('c', 10)]));
e = nextTurn(e); // current = b
e = moveCombatant(e, 'a', 1); // swap a and b positions
expect(e.combatants.map((x) => x.id)).toEqual(['b', 'a', 'c']);
expect(currentCombatant(e)?.id).toBe('b');
});
});
describe('HP transitions', () => {
it('temp HP absorbs damage first, then current can go negative (overkill visible)', () => {
let c = setTempHp(mk('a', 0, 10), 5);
c = applyDamage(c, 12);
expect(c.hp.temp).toBe(0);
expect(c.hp.current).toBe(3); // 10 - (12-5)
c = applyDamage(c, 20);
expect(c.hp.current).toBe(-17); // overkill tracked, not clamped to 0
});
it('temp HP does not stack — keeps the higher value', () => {
let c = setTempHp(mk('a', 0, 10), 5);
c = setTempHp(c, 3);
expect(c.hp.temp).toBe(5);
c = setTempHp(c, 8);
expect(c.hp.temp).toBe(8);
});
it('healing never exceeds max', () => {
let c = mk('a', 0, 10);
c = applyDamage(c, 6);
c = applyHealing(c, 100);
expect(c.hp.current).toBe(10);
});
it('ignores NaN/negative inputs', () => {
const c = mk('a', 0, 10);
expect(applyDamage(c, Number.NaN).hp.current).toBe(10);
expect(applyHealing(c, -5).hp.current).toBe(10);
});
});
describe('endEncounter', () => {
it('marks the encounter ended', () => {
expect(endEncounter(encounter([])).status).toBe('ended');
});
});