8fd530df73
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>
243 lines
9.8 KiB
TypeScript
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
|
|
});
|
|
});
|