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();
});
});
+32 -15
View File
@@ -1,6 +1,9 @@
// M4 desired behavior: dead PC stays in turn order, turn still comes up,
// deathSave fires. Current code filters isActive (set false on death) so
// dead participants are SKIPPED. Test asserts desired state = RED on current.
// Unified behavior (App main): death flips isActive=false, dead participant
// removed from active rotation, skipped by nextTurn. deathSave is a manual
// DM action (button), not tied to rotation. turnOrderIds unchanged on death
// (only isActive flag flips). Revive (heal from 0) reactivates.
//
// Previous "M4: dead stays in rotation" concept reversed by unification.
const shared = require('@ttrpg/shared');
const { makeParticipant, startEncounter, nextTurn, applyHpChange, deathSave } = shared;
@@ -24,8 +27,8 @@ function enc(ps) {
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
}
describe('M4: dead participants stay in turn order', () => {
test('dead PC not removed from turnOrderIds', () => {
describe('unified: death deactivates, dead skipped in rotation', () => {
test('dead PC not removed from turnOrderIds (stays in list, inactive)', () => {
const ps = [pc('a', 20), pc('b', 15), pc('c', 10)];
let e = enc(ps);
e = { ...e, ...startEncounter(e).patch };
@@ -35,39 +38,53 @@ describe('M4: dead participants stay in turn order', () => {
expect(e.turnOrderIds).toEqual(orderBefore);
});
test('dead PC turn still comes up (nextTurn visits them)', () => {
test('dead PC skipped by nextTurn (isActive=false)', () => {
const ps = [pc('a', 20), pc('b', 15), pc('c', 10)];
let e = enc(ps);
e = { ...e, ...startEncounter(e).patch };
// kill b
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
// advance: a→b→c. b's turn should come up.
// advance: a→c (b skipped, inactive)
e = { ...e, ...nextTurn(e).patch };
expect(e.currentTurnParticipantId).toBe('b');
expect(e.currentTurnParticipantId).toBe('c');
});
test('dead PC on their turn can deathSave', () => {
test('dead PC deathSave fires on manual call (not via rotation)', () => {
const ps = [pc('a', 20), pc('b', 15)];
let e = enc(ps);
e = { ...e, ...startEncounter(e).patch };
// kill b (current = a)
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
// advance to b's turn
e = { ...e, ...nextTurn(e).patch };
expect(e.currentTurnParticipantId).toBe('b');
// b is dead, on their turn: deathSave should not throw
// b is dead: DM can still fire deathSave (manual action)
const r = deathSave(e, 'b', 1);
expect(r.patch).toBeTruthy();
const b = r.patch.participants.find(x => x.id === 'b');
expect(b.deathSaves).toBe(1);
});
test('dead PC not auto-set isActive=false by applyHpChange', () => {
// D1 unification: shared now matches App main — death flips isActive=false.
// Dead participant removed from active rotation (skipped by nextTurn).
test('dead PC auto-set isActive=false by applyHpChange', () => {
const ps = [pc('a', 20), pc('b', 15)];
let e = enc(ps);
e = { ...e, ...startEncounter(e).patch };
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
const b = e.participants.find(x => x.id === 'b');
expect(b.isActive).toBe(true);
expect(b.isActive).toBe(false);
});
test('revive (heal from 0) reactivates participant', () => {
const ps = [pc('a', 20), pc('b', 15)];
let e = enc(ps);
e = { ...e, ...startEncounter(e).patch };
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
const dead = e.participants.find(x => x.id === 'b');
expect(dead.isActive).toBe(false);
// heal b back up
e = { ...e, ...applyHpChange(e, 'b', 'heal', 50).patch };
const revived = e.participants.find(x => x.id === 'b');
expect(revived.isActive).toBe(true);
expect(revived.currentHp).toBe(50);
expect(revived.deathSaves).toBe(0);
});
});
+6 -6
View File
@@ -82,16 +82,16 @@ describe('1-list model: turnOrderIds === participants.map(id), no re-sort', () =
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
});
test('reorder cross-init: allowed, list + rotation reflect new order', () => {
test('reorder cross-init: blocked (no-op)', () => {
let e = enc([p('a',10),p('b',7),p('c',3)]);
e = apply(e, startEncounter(e)); // [a,b,c]
e = apply(e, reorderParticipants(e, 'c', 'a')); // drag c before a
expect(e.turnOrderIds).toEqual(['c','a','b']);
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
const r = reorderParticipants(e, 'c', 'a'); // cross-init
expect(r.patch).toBeNull();
expect(e.turnOrderIds).toEqual(['a','b','c']); // unchanged
});
test('reorder: rotation follows new list order', () => {
let e = enc([p('a',10),p('b',7),p('c',3)]);
test('reorder same-init: rotation follows new list order', () => {
let e = enc([p('a',10),p('b',10),p('c',3)]); // a,b tie
e = apply(e, startEncounter(e)); // [a,b,c], cur=a
e = apply(e, reorderParticipants(e, 'b', 'a')); // [b,a,c], cur still a
const rot = walkRotation(e); // start a, next c (wrap), next b, back a
+2 -2
View File
@@ -29,12 +29,12 @@ describe('reorderParticipants', () => {
expect(r.patch.participants.map(p => p.id)).toEqual(['c', 'b', 'a']);
});
test('cross-init drag allowed (1-list, DM override)', () => {
test('cross-init drag blocked (no-op)', () => {
const ps = [p('a', 10), p('b', 20)];
let e = enc(ps);
e = { ...e, ...startEncounter(e).patch }; // [b,a]
const r = reorderParticipants(e, 'a', 'b');
expect(r.patch.participants.map(p => p.id)).toEqual(['a', 'b']);
expect(r.patch).toBeNull();
});
test('throws if id not found', () => {