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 }); });