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,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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user