WIP: BUG-5 slot-array fix + FEAT-1 dead-not-skipped + skip parser

WORK IN PROGRESS — fix not complete. analyze-turns.js on 500-round
replay still finds 46 real skips + 64 double-acts.

turn.js changes:
- computeTurnOrderAfterAddition: insert by initiative (not append end)
- nextTurn wrap: no re-sort, cycle pointer
- togglePause resume: no re-sort, order stable
- addParticipant: patches turnOrderIds when started
- applyHpChange: death no longer flips isActive or touches turnOrderIds
  (FEAT-1 dead-not-skipped)

Tests:
- shared/tests/turn.skip.test.js (NEW): deterministic skip invariants
  pure 100 rounds + 540 rounds w/ mutations, both green
- shared/tests/turn.dead-skip.test.js: 4 green (FEAT-1)
- turn.characterization.test.js: 3 sites updated to new behavior
- turn.combat.test.js: boundary count fixed (wrap-turn attributed to
  new round), debug dump removed

scripts/analyze-turns.js (NEW): deterministic replay-stdout parser.
Reconstructs rounds, reports real skips + double-acts. Exit 1 on issue.
Catches bugs unit tests miss (46 skips/64 double-acts in 500 rounds).

TODO: FEAT-1 marked done, FEAT-2 added (upgrade app logs parseable).
This commit is contained in:
david raistrick
2026-07-01 11:42:43 -04:00
parent c6d3b7e1a6
commit 0473eacc1d
6 changed files with 423 additions and 45 deletions
+19 -9
View File
@@ -141,12 +141,14 @@ describe('togglePause', () => {
expect(patch.isPaused).toBe(true);
});
test('resume recomputes turn order from active', () => {
test('resume preserves turn order (no re-sort)', () => {
// BUG-5 fix: resume no longer re-sorts. Re-sort displaced current pointer
// and caused skips. Order frozen at startEncounter, patched incrementally.
const ps = [p('a', 5), p('b', 15)];
const e = enc(ps, { isStarted: true, isPaused: true, turnOrderIds: ['a', 'b'] });
const { patch } = togglePause(e);
expect(patch.isPaused).toBe(false);
expect(patch.turnOrderIds).toEqual(['b', 'a']);
expect(patch.turnOrderIds).toEqual(['a', 'b']);
});
});
@@ -192,11 +194,14 @@ describe('toggleParticipantActive', () => {
expect(patch.currentTurnParticipantId).toBe('b');
});
test('started: reactivating appends to turn order', () => {
test('started: reactivating inserts by initiative', () => {
// BUG-5 fix: reactivated participant slots by initiative (not appended
// to end). Preserves correct rotation order.
const ps = [p('a', 10, { isActive: false }), p('b', 5)];
const e = enc(ps, { isStarted: true, turnOrderIds: ['b'], currentTurnParticipantId: 'b' });
const { patch } = toggleParticipantActive(e, 'a');
expect(patch.turnOrderIds).toEqual(['b', 'a']);
// a init=10 > b init=5 → a slots before b
expect(patch.turnOrderIds).toEqual(['a', 'b']);
});
});
@@ -207,17 +212,22 @@ describe('applyHpChange', () => {
expect(patch.participants[0].currentHp).toBe(10);
});
test('damage to 0 deactivates + removes from turn order', () => {
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.
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(false);
expect(patch.currentTurnParticipantId).toBe('b');
expect(patch.participants[0].isActive).toBe(true);
expect(patch.turnOrderIds).toBeUndefined();
expect(patch.currentTurnParticipantId).toBeUndefined();
});
test('heal above 0 revives + reactivates + resets death saves', () => {
const ps = [p('a', 10, { currentHp: 0, isActive: false, deathSaves: 2 })];
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 })];
const { patch } = applyHpChange(enc(ps), 'a', 'heal', 5);
expect(patch.participants[0].currentHp).toBe(5);
expect(patch.participants[0].isActive).toBe(true);