refactor: single-source combat logic (App handlers call shared, slot model)

App.js no longer inlines combat logic. All handlers delegate to @ttrpg/shared:
startEncounter, nextTurn, togglePause, endEncounter, addParticipant(s),
updateParticipant, removeParticipant, toggleParticipantActive, applyHpChange,
deathSave, toggleCondition, reorderParticipants. ONE code path for UI.

shared/turn.js aligned to slot model (docs/INITIATIVE_ORDERING.md):
- addParticipant: splice into slot by initiative (no sort)
- updateParticipant: re-slot on initiative change (no sort)
- reorderParticipants: same-init drag only (cross-init blocked)
- applyHpChange: death flips isActive=false (matches App), revive reactivates

No renames, no API changes. Shared func signatures unchanged.

Tests updated to match unified behavior:
- dead-skip: death deactivates (was FEAT-1 stays active)
- characterization: death deactivates + revive reactivates
- reorder/invariant: cross-init drag blocked, same-init allowed

Both suites green: shared 91/91, App 77/77, 0 warnings.
This commit is contained in:
david raistrick
2026-07-03 17:59:41 -04:00
parent c3d89554e8
commit d83d1383c0
6 changed files with 162 additions and 416 deletions
+10 -11
View File
@@ -213,22 +213,21 @@ describe('applyHpChange', () => {
expect(patch.participants[0].currentHp).toBe(10);
});
test('damage to 0 keeps active + stays in turn order (FEAT-1)', () => {
// FEAT-1: death no longer deactivates or removes from turn order.
// Dead stay in rotation, nextTurn still visits them, PCs get death-save turn.
test('damage to 0 deactivates + keeps turn order (unified)', () => {
// Unified: death flips isActive=false (removed from active rotation).
// turnOrderIds unchanged (no turn-order patch on death).
const ps = [p('a', 10, { currentHp: 3 }), p('b', 5)];
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' });
const { patch } = applyHpChange(e, 'a', 'damage', 5);
expect(patch.participants[0].currentHp).toBe(0);
expect(patch.participants[0].isActive).toBe(true);
expect(patch.participants[0].isActive).toBe(false);
expect(patch.turnOrderIds).toBeUndefined();
expect(patch.currentTurnParticipantId).toBeUndefined();
});
test('heal above 0 resets death saves, keeps active (FEAT-1)', () => {
// FEAT-1: revive no longer flips isActive (was already active — death
// doesn't deactivate). deathSaves still reset.
const ps = [p('a', 10, { currentHp: 0, deathSaves: 2 })];
test('heal above 0 reactivates + resets death saves (unified)', () => {
// Unified: revive from 0 flips isActive=true, deathSaves reset.
const ps = [p('a', 10, { currentHp: 0, isActive: false, deathSaves: 2 })];
const { patch } = applyHpChange(enc(ps), 'a', 'heal', 5);
expect(patch.participants[0].currentHp).toBe(5);
expect(patch.participants[0].isActive).toBe(true);
@@ -285,17 +284,17 @@ describe('toggleCondition', () => {
});
describe('reorderParticipants', () => {
test('drag before target (1-list, cross-init allowed)', () => {
test('drag before target (same-init tie)', () => {
const ps = [p('a', 10), p('b', 10), p('c', 10)];
const { patch } = reorderParticipants(enc(ps), 'a', 'c');
// drag a before c: remove a → [b,c], insert before c → [b,a,c]
expect(patch.participants.map(x => x.id)).toEqual(['b', 'a', 'c']);
});
test('cross-init drag allowed (1-list, DM override)', () => {
test('cross-init drag blocked (no-op)', () => {
const ps = [p('a', 10), p('b', 5)];
const { patch } = reorderParticipants(enc(ps), 'a', 'b');
expect(patch.participants.map(x => x.id)).toEqual(['a', 'b']);
expect(patch).toBeNull();
});
});