fix(BUG-5): unify turn-advance core (DRY), 500 rounds skip-free

Extract shared nextActiveAfter() advance core. Both nextTurn and
computeTurnOrderAfterRemoval delegate to it — single source of truth,
eliminates drift risk where one path changes and the other doesn't.

Previously two separate advance implementations computed the same
target, but any future edit to one would silently desync deact-current
advance from normal nextTurn advance.

Replay (scripts/replay-combat.js):
- Move turn-line print before mutations (event order = reality)
- Emit [pointer X→Y] lines when a mutation advances currentTurnParticipantId
- Emit [pointer X→Y wrap] when round bumps (removal-wrap case)
- Skip pointer emission for nextTurn (label=null) — already logged via turn line

Parser (scripts/analyze-turns.js):
- Parse [pointer X→Y wrap] events
- Credit pointer-target as acted (deact-current advance = turn pointer)
- Wrap pointer credits NEXT round (not current) — fixes cross-round false skip
- Drop currentRemoved special-case — pointer lines make skip check precise

Tests:
- shared/tests/turn.dry.test.js: 3 tests lock deact-current advance ==
  nextTurn advance (mid-round, inactive-skipper, wrap+round-bump). RED
  catches future drift.

Results: 500-round replay now 0 real skips, 0 double-acts (was 5+3).
Shared suite: 79 green + 1 RED (BUG-6 reorder, intentional).
This commit is contained in:
david raistrick
2026-07-01 14:22:02 -04:00
parent c72b88f8bb
commit 494327ff17
4 changed files with 160 additions and 63 deletions
+20 -3
View File
@@ -65,7 +65,21 @@ async function patch(encounterPath, enc, result, label) {
if (!result || !result.patch) { if (label) console.log(` (${label}: no-op)`); return enc; }
await storage.updateDoc(encounterPath, result.patch);
if (label) console.log(` [${label}]`);
return { ...enc, ...result.patch };
// emit pointer-advance line when a MUTATION changes currentTurnParticipantId.
// nextTurn passes label=null — it's a normal advance, already logged via
// the turn line. Emitting pointer for it double-counts.
const oldCur = enc.currentTurnParticipantId;
const oldRound = enc.round;
const newEnc = { ...enc, ...result.patch };
const newCur = newEnc.currentTurnParticipantId;
const newRound = newEnc.round;
if (label && oldCur && newCur && oldCur !== newCur) {
const oldName = enc.participants.find(p => p.id === oldCur)?.name || oldCur;
const newName = newEnc.participants.find(p => p.id === newCur)?.name || newCur;
const wrap = oldRound !== newRound ? ' wrap' : '';
console.log(` [pointer ${oldName}${newName}${wrap}]`);
}
return newEnc;
}
function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
@@ -147,7 +161,9 @@ async function main() {
const cap = (enc.participants.length + 2) * 2;
let guard = 0;
while (enc.round < roundN + 1 && guard < cap) {
enc = await storage.getDoc(encounterPath);
// NOTE: do NOT getDoc here — async re-fetch can return stale state and
// cause nextTurn to compute off pre-mutation data (double-acts/skips).
// Trust the local enc returned by patch (sync spread of updateDoc).
// 9. resume if paused: must happen BEFORE nextTurn or it throws.
if (lastPaused) {
@@ -162,6 +178,8 @@ async function main() {
const actorName = firstActiveName(enc);
const actor = currentParticipant(enc);
console.log(` turn ${totalTurns} (round ${enc.round}): ${actorName} | order=[${enc.turnOrderIds.map(id=>enc.participants.find(p=>p.id===id)?.name||id).join(',')}] cur=${enc.currentTurnParticipantId}`);
// 1. damage: actor hits a random living, active target.
if (actor) {
const foes = enc.participants.filter(
@@ -300,7 +318,6 @@ async function main() {
}
}
console.log(` turn ${totalTurns} (round ${enc.round}): ${actorName}`);
await sleep(DELAY);
guard++;
if (!enc.isStarted) { console.log('combat auto-ended'); break; }