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:
+42
-24
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user