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