Phase 3: combat depth

- Condition durations + auto-expiry: timed conditions tick down on the affected
  combatant's turn and drop at 0 (engine tickConditions; UI rounds field + (Nr) chip)
- Initiative: Roll-all (d20 + per-combatant bonus, re-sort anchored) + group/quantity add
- Combat log: per-round event feed (turns, round changes, damage/heal, expiries,
  removals) + in-memory Undo of the last change
- Encounter difficulty budget: 5e XP thresholds + PF2e level budget (pure lib +
  tests); live difficulty/XP-award readout from monster combatants vs the party
- Combatants carry initBonus + cr/level; compendium add-to-combat passes them

18 new unit tests (durations, applyInitiatives, budget), new combat-depth e2e.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 01:23:13 +02:00
parent 1c87b0e3aa
commit 3e5bdc06e2
13 changed files with 472 additions and 35 deletions
+31 -1
View File
@@ -4,6 +4,7 @@ import {
addCombatant,
applyDamage,
applyHealing,
applyInitiatives,
currentCombatant,
endEncounter,
moveCombatant,
@@ -12,6 +13,7 @@ import {
removeCombatant,
setTempHp,
startEncounter,
tickConditions,
updateCombatant,
} from './engine';
@@ -21,6 +23,7 @@ function mk(id: string, initiative: number, hp = 10): Combatant {
name: id,
kind: 'monster',
initiative,
initBonus: 0,
ac: 12,
hp: { current: hp, max: hp, temp: 0 },
conditions: [],
@@ -31,7 +34,7 @@ function mk(id: string, initiative: number, hp = 10): Combatant {
function encounter(combatants: Combatant[]): Encounter {
return {
id: 'e1', campaignId: 'c1', name: 'Test', status: 'planning',
round: 0, turnIndex: 0, combatants,
round: 0, turnIndex: 0, combatants, log: [],
createdAt: '', updatedAt: '',
};
}
@@ -148,3 +151,30 @@ describe('endEncounter', () => {
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');
});
});