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);
+2 -1
View File
@@ -108,7 +108,8 @@ describe('combat integrity (100 rounds, full op coverage)', () => {
}
e = apply(e, t);
totalTurns++;
seenThisRound.push(e.currentTurnParticipantId);
// only count if turn belongs to THIS round (no wrap)
if (e.round === startRound) seenThisRound.push(e.currentTurnParticipantId);
const actor = currentParticipant(e);
+122
View File
@@ -0,0 +1,122 @@
// Invariant: no real skip. Every active participant at round start (still
// active at round end) gets a turn. Tracks per ACTUAL round (e.round), so
// rounds spanning pause/resume across loop iterations count correctly.
//
// Guards BUG-5 fix (slot-array turn order, no re-sort on wrap/resume).
// If this goes RED, turn order rotation is skipping participants again.
'use strict';
const shared = require('@ttrpg/shared');
const {
buildCharacterParticipant, buildMonsterParticipant,
startEncounter, nextTurn, togglePause, addParticipant, removeParticipant,
toggleParticipantActive,
} = shared;
const apply = (e, r) => (r && r.patch) ? { ...e, ...r.patch } : e;
const nm = (enc) => (id) => {
const f = enc.participants.find(p => p.id === id);
return f ? f.name : id;
};
function setup() {
const ps = [
buildCharacterParticipant({ id: 'c1', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 }).participant,
buildCharacterParticipant({ id: 'c2', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 }).participant,
buildCharacterParticipant({ id: 'c3', name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 }).participant,
buildMonsterParticipant({ name: 'Goblin1', maxHp: 100, initMod: 2 }).participant,
buildMonsterParticipant({ name: 'Goblin2', maxHp: 100, initMod: 2 }).participant,
buildMonsterParticipant({ name: 'OrcBoss', maxHp: 500, initMod: 1 }).participant,
buildMonsterParticipant({ name: 'Wolf', maxHp: 120, initMod: 3 }).participant,
buildMonsterParticipant({ name: 'Merchant', maxHp: 150, initMod: 0, isNpc: true }).participant,
];
let e = {
name: 't', participants: ps, isStarted: false, isPaused: false,
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
};
return apply(e, startEncounter(e));
}
describe('BUG-5: turn-order rotation never skips (deterministic)', () => {
jest.setTimeout(15000);
test('pure nextTurn: 0 skips across 100 rounds', () => {
let e = setup();
let totalSkips = 0;
for (let roundN = 1; roundN <= 100; roundN++) {
const startRound = e.round;
const activeAtStart = new Set(e.participants.filter(p => p.isActive).map(p => p.id));
const acted = new Set();
acted.add(e.currentTurnParticipantId);
let guard = 0;
const cap = e.participants.length + 1;
while (e.round === startRound && guard < cap) {
e = apply(e, nextTurn(e));
if (e.round === startRound) acted.add(e.currentTurnParticipantId);
guard++;
}
const skipped = [...activeAtStart].filter(id => {
const p = e.participants.find(x => x.id === id);
return p && p.isActive && !acted.has(id);
});
totalSkips += skipped.length;
}
expect(totalSkips).toBe(0);
});
test('with pause/resume + add/remove/toggle: 0 skips across ~540 rounds', () => {
let e = setup();
const N = nm(e);
let curRound = null;
let activeAtRoundStart = new Set();
let actedThisRound = new Set();
const onRoundStart = (enc) => {
curRound = enc.round;
activeAtRoundStart = new Set(enc.participants.filter(p => p.isActive).map(p => p.id));
actedThisRound = new Set();
if (enc.currentTurnParticipantId) actedThisRound.add(enc.currentTurnParticipantId);
};
onRoundStart(e);
let totalRealSkips = 0;
let added = 0;
let turns = 0;
const MAX_TURNS = 2000;
while (turns < MAX_TURNS && e.isStarted) {
turns++;
if (e.isPaused) e = apply(e, togglePause(e));
if (turns % 7 === 0 && !e.isPaused) { e = apply(e, togglePause(e)); continue; }
const prevRound = e.round;
e = apply(e, nextTurn(e));
if (e.round !== prevRound) {
const skipped = [...activeAtRoundStart].filter(id => {
const p = e.participants.find(x => x.id === id);
return p && p.isActive && !actedThisRound.has(id);
});
totalRealSkips += skipped.length;
onRoundStart(e);
} else {
actedThisRound.add(e.currentTurnParticipantId);
}
if (turns % 9 === 0 && added < 8) {
const b = buildMonsterParticipant({ name: `R${added + 1}`, maxHp: 120, initMod: 3 }).participant;
b.id = `reinforce${added + 1}`;
e = apply(e, addParticipant(e, b)); added++;
}
if (turns % 13 === 0) {
const cand = e.participants.filter(p => p.type === 'monster' && p.isActive && p.id !== e.currentTurnParticipantId);
if (cand.length) e = apply(e, removeParticipant(e, cand[0].id));
}
if (turns % 17 === 0) {
const cand = e.participants.filter(p => p.isActive && p.id !== e.currentTurnParticipantId);
if (cand.length) {
const t = cand[0];
e = apply(e, toggleParticipantActive(e, t.id));
e = apply(e, toggleParticipantActive(e, t.id));
}
}
}
expect(totalRealSkips).toBe(0);
});
});
+42 -24
View File
@@ -59,13 +59,28 @@ const computeTurnOrderAfterRemoval = (encounter, removedId, updatedParticipants)
return updates;
};
// Verbatim from src/App.js. Returns turnOrderIds update when a participant
// re-enters active combat mid-encounter.
// Insert addedId into turnOrderIds by initiative. New participant slots into
// correct initiative position at add time (not appended to end). Preserves
// current pointer — no re-sort anywhere except startEncounter.
// Tie rule: insert AFTER existing same-init (preserves creation order).
const computeTurnOrderAfterAddition = (encounter, addedId) => {
if (!encounter.isStarted) return {};
const currentIds = encounter.turnOrderIds || [];
if (currentIds.includes(addedId)) return {};
return { turnOrderIds: [...currentIds, addedId] };
const added = (encounter.participants || []).find(p => p.id === addedId);
if (!added) return { turnOrderIds: [...currentIds, addedId] };
// find first id with strictly lower initiative; insert before it (== after all >= )
const initOf = id => {
const p = (encounter.participants || []).find(x => x.id === id);
return p ? (p.initiative || 0) : 0;
};
const addedInit = added.initiative || 0;
let insertAt = currentIds.length;
for (let i = 0; i < currentIds.length; i++) {
if (initOf(currentIds[i]) < addedInit) { insertAt = i; break; }
}
const newIds = [...currentIds.slice(0, insertAt), addedId, ...currentIds.slice(insertAt)];
return { turnOrderIds: newIds };
};
// ----------------------------------------------------------------------------
@@ -209,18 +224,14 @@ function nextTurn(encounter) {
let nextIndex = (currentIndex + 1) % activePsInOrder.length;
let newTurnOrderIds = encounter.turnOrderIds;
// Round wrap: initiative is cyclic. Order is frozen at startEncounter and
// patched incrementally by add/remove/toggle. NO re-sort here — re-sorting
// displaces the current pointer and causes skips.
if (nextIndex === 0 && currentIndex !== -1) {
nextRound += 1;
// Rebuild turn order by initiative at start of new round so participants
// activated mid-round (appended to end) slot into proper initiative position next round.
const activePs = encounter.participants.filter(p => p.isActive);
const sorted = sortParticipantsByInitiative(activePs, encounter.participants);
newTurnOrderIds = sorted.map(p => p.id);
}
const nextParticipant = (nextIndex === 0 && currentIndex !== -1)
? encounter.participants.find(p => p.id === newTurnOrderIds[0])
: activePsInOrder[nextIndex];
const nextParticipant = activePsInOrder[nextIndex];
if (!nextParticipant) {
throw new Error('Could not determine next participant.');
@@ -251,10 +262,10 @@ function togglePause(encounter) {
const newPausedState = !encounter.isPaused;
let newTurnOrderIds = encounter.turnOrderIds;
if (!newPausedState && encounter.isPaused) {
// Resuming — recompute turn order from active participants.
const activeParticipants = encounter.participants.filter(p => p.isActive);
const sortedParticipants = sortParticipantsByInitiative(activeParticipants, encounter.participants);
newTurnOrderIds = sortedParticipants.map(p => p.id);
// Resume: do NOT re-sort. Re-sorting displaces the current pointer —
// participants who already acted move earlier in order and nextTurn
// revisits them (whole round replays). Order is frozen at startEncounter
// and patched incrementally; resume keeps it stable.
}
return {
patch: { isPaused: newPausedState, turnOrderIds: newTurnOrderIds },
@@ -269,16 +280,25 @@ function togglePause(encounter) {
}
// ADD_PARTICIPANT — appends participant. (Initiative rolled by caller via build*.)
// If encounter already started, also slot participant into turnOrderIds by
// initiative (via computeTurnOrderAfterAddition).
function addParticipant(encounter, participant) {
if ((encounter.participants || []).some(p => p.id === participant.id)) {
throw new Error(`Participant with id "${participant.id}" already exists in encounter.`);
}
const updatedParticipants = [...(encounter.participants || []), participant];
const intermediate = { ...encounter, participants: updatedParticipants };
const turnUpdates = computeTurnOrderAfterAddition(intermediate, participant.id);
return {
patch: { participants: updatedParticipants },
patch: { participants: updatedParticipants, ...turnUpdates },
log: {
message: `${participant.name} added to encounter (Initiative: ${participant.initiative})`,
undo: { participants: [...(encounter.participants || [])] },
undo: {
participants: [...(encounter.participants || [])],
...(encounter.isStarted ? {
turnOrderIds: [...(encounter.turnOrderIds || [])],
} : {}),
},
},
};
}
@@ -359,27 +379,25 @@ function applyHpChange(encounter, participantId, changeType, amount) {
const isDead = newHp === 0;
const wasResurrected = wasDead && newHp > 0;
// FEAT-1: death no longer flips isActive or touches turnOrderIds.
// Dead participants stay in turn order, nextTurn still visits them, PCs
// get their death-save turn. isActive = DM-controlled combatant toggle only.
const updatedParticipants = (encounter.participants || []).map(p => {
if (p.id !== participantId) return p;
const updates = { ...p, currentHp: newHp };
if (isDead && !wasDead) {
updates.isActive = false;
updates.deathSaves = p.deathSaves || 0;
updates.isDying = false;
}
if (wasResurrected) {
updates.isActive = true;
updates.deathSaves = 0;
updates.isDying = false;
}
return updates;
});
const turnUpdates = (isDead && !wasDead)
? computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants)
: wasResurrected
? computeTurnOrderAfterAddition(encounter, participantId)
: {};
// No turn-order updates on death/revive (FEAT-1).
const turnUpdates = {};
const hpLine = `${participant.currentHp}${newHp} HP`;
const deathSuffix = (isDead && !wasDead)