Files
ttrpg_manager/src/lib/combat/engine.test.ts
T
NilsBriggen 8fd530df73 Perfection pass: complete level-up/build flows + dying polish
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>
2026-06-10 20:37:01 +02:00

243 lines
9.8 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import type { Combatant, Encounter } from '@/lib/schemas/encounter';
import {
addCombatant,
applyDamage,
applyHealing,
applyInitiatives,
currentCombatant,
endEncounter,
isMassiveDamageDeath,
moveCombatant,
nextTurn,
previousTurn,
removeCombatant,
setTempHp,
startEncounter,
tickConditions,
updateCombatant,
} from './engine';
function mk(id: string, initiative: number, hp = 10): Combatant {
return {
id,
name: id,
kind: 'monster',
initiative,
initBonus: 0,
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, log: [],
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('auto-numbers duplicate names (Goblin -> Goblin 1, Goblin 2, …)', () => {
let e = encounter([]);
e = addCombatant(e, mk('g1', 10));
e = { ...e, combatants: e.combatants.map((c) => ({ ...c, name: 'Goblin' })) }; // first is "Goblin"
e = addCombatant(e, { ...mk('g2', 10), name: 'Goblin' });
expect(e.combatants.map((c) => c.name).sort()).toEqual(['Goblin 1', 'Goblin 2']);
e = addCombatant(e, { ...mk('g3', 10), name: 'Goblin' });
expect(e.combatants.find((c) => c.id === 'g3')?.name).toBe('Goblin 3');
});
it('does not number unique names', () => {
let e = encounter([]);
e = addCombatant(e, { ...mk('a', 10), name: 'Aragorn' });
e = addCombatant(e, { ...mk('b', 10), name: 'Legolas' });
expect(e.combatants.map((c) => c.name)).toEqual(['Aragorn', 'Legolas']);
});
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);
});
it('applies typed-damage resistances when defenses are present', () => {
const base = { ...mk('a', 0, 20), damageDefenses: { resist: ['fire'], immune: ['poison'], vulnerable: ['cold'], conditionImmune: [], notes: [], resistFlat: [], weakness: [] } };
expect(applyDamage(base, 10, 'fire').hp.current).toBe(15); // resisted → 5
expect(applyDamage(base, 10, 'poison').hp.current).toBe(20); // immune → 0
expect(applyDamage(base, 10, 'cold').hp.current).toBe(0); // vulnerable → 20
expect(applyDamage(base, 10, 'acid').hp.current).toBe(10); // untracked type → full
expect(applyDamage(base, 10).hp.current).toBe(10); // no type → full (back-compat)
});
it('typeless damage is unchanged when no defenses are set', () => {
expect(applyDamage(mk('a', 0, 10), 4, 'fire').hp.current).toBe(6);
});
it('applies pf2e flat weakness (adds) and resistance (subtracts)', () => {
const wk = { ...mk('a', 0, 30), damageDefenses: { resist: [], immune: [], vulnerable: [], conditionImmune: [], notes: [], resistFlat: [], weakness: [{ type: 'fire', amount: 5 }] } };
expect(applyDamage(wk, 10, 'fire').hp.current).toBe(15); // 10 + 5 weakness
const rs = { ...mk('a', 0, 30), damageDefenses: { resist: [], immune: [], vulnerable: [], conditionImmune: [], notes: [], resistFlat: [{ type: 'all', amount: 5 }], weakness: [] } };
expect(applyDamage(rs, 8, 'cold').hp.current).toBe(27); // 30 - (8 - 5 'all') = 27
expect(applyDamage(rs, 4, 'cold').hp.current).toBe(30); // 4 - 5 → 0, no change
});
it('pf2e immunity "all" zeroes every damage type', () => {
const im = { ...mk('a', 0, 30), damageDefenses: { resist: [], immune: ['all'], vulnerable: [], conditionImmune: [], notes: [], resistFlat: [], weakness: [] } };
expect(applyDamage(im, 10, 'fire').hp.current).toBe(30); // immune to all → no change
expect(applyDamage(im, 10, 'cold').hp.current).toBe(30);
});
it('healing honors a reduced effective-max cap (drained / exhaustion 4)', () => {
const c = { ...mk('a', 0, 30), hp: { current: 10, max: 30, temp: 0 } };
expect(applyHealing(c, 25, 20).hp.current).toBe(20); // capped at effective max
expect(applyHealing(c, 25).hp.current).toBe(30); // no cap → stored max
expect(applyHealing(c, 5, 40).hp.current).toBe(15); // cap never exceeds stored max
});
});
describe('endEncounter', () => {
it('marks the encounter ended', () => {
expect(endEncounter(encounter([])).status).toBe('ended');
});
});
describe('condition durations', () => {
it('ticks duration down and removes expired conditions', () => {
const c = { ...mk('a', 0), conditions: [{ name: 'Frightened', value: 2, duration: 1 }, { name: 'Prone' }] };
const { combatant, expired } = tickConditions(c);
expect(expired).toEqual(['Frightened']);
expect(combatant.conditions.map((x) => x.name)).toEqual(['Prone']); // indefinite stays
});
it('nextTurn ticks the newly-active combatant and logs expiry', () => {
let e = startEncounter(encounter([mk('a', 20), mk('b', 10)]));
e = updateCombatant(e, 'b', { conditions: [{ name: 'Dazed', duration: 1 }] });
e = nextTurn(e); // b's turn begins -> Dazed expires
expect(currentCombatant(e)?.conditions).toHaveLength(0);
expect(e.log.some((l) => /Dazed wore off/.test(l.text))).toBe(true);
});
});
describe('applyInitiatives', () => {
it('reassigns initiative, re-sorts, and keeps the current combatant anchored', () => {
let e = startEncounter(encounter([mk('a', 20), mk('b', 10), mk('c', 5)]));
e = nextTurn(e); // current = b
e = applyInitiatives(e, { a: 1, b: 2, c: 30 });
expect(e.combatants.map((x) => x.id)).toEqual(['c', 'b', 'a']);
expect(currentCombatant(e)?.id).toBe('b');
});
});
describe('isMassiveDamageDeath', () => {
it('kills when leftover damage past 0 is >= max HP', () => {
// 12-max PC at 4 HP takes 17 → current -13; 13 >= 12 → instant death
expect(isMassiveDamageDeath(12, -13)).toBe(true);
expect(isMassiveDamageDeath(12, -12)).toBe(true); // exactly max → dead
expect(isMassiveDamageDeath(12, -11)).toBe(false); // one short → dying, not dead
expect(isMassiveDamageDeath(12, 0)).toBe(false); // dropped to 0, no overflow
});
});