diff --git a/scripts/analyze-turns.js b/scripts/analyze-turns.js index a3195c5..df2e454 100644 --- a/scripts/analyze-turns.js +++ b/scripts/analyze-turns.js @@ -20,7 +20,7 @@ const fs = require('fs'); // ---------- parsing ---------- -const TURN_RE = /^\s*turn\s+(\d+)\s+\(round\s+(\d+)\):\s+(.+?)(?:\s*\|.*)?\s*$/; +const TURN_RE = /^\s*turn\s+(\d+)\s+\(round\s+(\d+)\):\s+(.+?)(?:\s*\|\s*order=\[(.*)\](?:\s*cur=.*)?)?\s*$/; const DEACTIVATE_RE = /^\s*\[(?:deactivate)\s+(.+?)\]\s*$/; const REACTIVATE_RE = /^\s*\[(?:revive-reactivate|reactivate)\s+(.+?)\]\s*$/; const ADD_RE = /^\s*\[(?:add)\s+(.+?)\]\s*$/; @@ -29,12 +29,19 @@ const PAUSE_RE = /^\s*\[pause\]\s*$/; const RESUME_RE = /^\s*\[resume\]\s*$/; const ROUND_COMPLETE_RE = /^\s*---\s*round\s+(\d+)\s+complete/; const FIRST_RE = /^combat started:\s+round\s+\d+,\s+first=(.+?)\s*$/; +const REORDER_RE = /^\s*\[reorder\s+(.+?)→before\s+(.+?)\]\s*$/; const POINTER_RE = /^\s*\[pointer\s+(.+?)→(.+?)( wrap)?\]\s*$/; function parseLine(line) { if (TURN_RE.test(line)) { const m = line.match(TURN_RE); - return { kind: 'turn', turn: +m[1], round: +m[2], actor: m[3].trim() }; + const orderStr = m[4] || ''; + // parse Name:init pairs + const order = orderStr.split(',').map(s => s.trim()).filter(Boolean).map(pair => { + const [name, init] = pair.split(':'); + return { name: name.trim(), init: init !== undefined ? +init : null }; + }); + return { kind: 'turn', turn: +m[1], round: +m[2], actor: m[3].trim(), order }; } if (FIRST_RE.test(line)) { const m = line.match(FIRST_RE); @@ -50,6 +57,10 @@ function parseLine(line) { const m = line.match(POINTER_RE); return { kind: 'pointer', from: m[1].trim(), to: m[2].trim(), wrap: m[3] === ' wrap' }; } + if (REORDER_RE.test(line)) { + const m = line.match(REORDER_RE); + return { kind: 'reorder', dragged: m[1].trim(), target: m[2].trim() }; + } if (ROUND_COMPLETE_RE.test(line)) return { kind: 'round-complete', round: +line.match(ROUND_COMPLETE_RE)[1] }; return null; } @@ -91,6 +102,9 @@ function reconstruct(events) { if (ev.wrap) curRound += 1; const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound); r.events.push({ ...ev, idx: r.events.length }); + } else if (ev.kind === 'reorder') { + const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound); + r.events.push({ ...ev, idx: r.events.length }); } else if (ev.kind === 'round-complete') { if (rounds.has(ev.round)) rounds.get(ev.round).complete = true; } @@ -198,6 +212,52 @@ function analyzeRoundWithCarry(roundN, r, activeAtStart, currentAtStart) { }; } +// ---------- order-shift detection ---------- +// Compare order+init between consecutive turn lines. Flag shifts NOT explained +// by: logged reorder, add/remove (roster change), or initiative change. +// DM drag-reorder = legit (logged reorder line). Phantom shifts = display/rotation +// divergence bug (invariant: display === turnOrderIds === nextTurn). +function detectOrderShifts(events) { + const shifts = []; + let prev = null; + let prevTurnNo = null; + // mutations since last turn (reorder/add/remove/reactivate/pointer) + let pending = []; + let initMap = {}; // name -> last known initiative + + for (const ev of events) { + if (ev.kind === 'turn' && ev.order && ev.order.length) { + const curNames = ev.order.map(o => o.name); + const curInits = {}; + ev.order.forEach(o => { curInits[o.name] = o.init; }); + + if (prev) { + const sameRoster = prev.length === curNames.length && + prev.every((n, i) => n === curNames[i]); + if (!sameRoster) { + // roster change (add/remove) — skip, expected order shift + } else { + // same roster, different order → explainable by reorder OR init change? + const orderChanged = JSON.stringify(prev) !== JSON.stringify(curNames); + const initChanged = ev.order.some(o => initMap[o.name] !== null && initMap[o.name] !== undefined && initMap[o.name] !== o.init); + const hasReorder = pending.some(p => p.kind === 'reorder'); + if (orderChanged && !hasReorder && !initChanged) { + shifts.push({ turn: ev.turn, from: prev, to: curNames, reason: 'no logged reorder/init change' }); + } + } + } + prev = curNames; + curInits && Object.keys(curInits).forEach(k => { initMap[k] = curInits[k]; }); + pending = []; + prevTurnNo = ev.turn; + } else if (ev.kind === 'reorder' || ev.kind === 'add' || ev.kind === 'remove' || + ev.kind === 'reactivate' || ev.kind === 'pointer') { + pending.push(ev); + } + } + return shifts; +} + // ---------- CLI ---------- function readInput() { @@ -232,12 +292,24 @@ function main() { console.log(` sequence: ${rep.turns.join(' -> ')}`); } + // order-shift detection: flag unexplained display/rotation divergence + const shifts = detectOrderShifts(events); + if (shifts.length) { + console.log(`\n--- order shifts (${shifts.length}) ---`); + for (const s of shifts.slice(0, 10)) { + console.log(` turn ${s.turn}: [${s.from.join(',')}] → [${s.to.join(',')}] (${s.reason})`); + } + if (shifts.length > 10) console.log(` ... +${shifts.length - 10} more`); + } + console.log(`\n=== ${reports.length} rounds analyzed ===`); console.log(`real skips: ${totalSkips}`); console.log(`double-acts: ${totalDoubles}`); - console.log(totalSkips === 0 && totalDoubles === 0 ? 'CLEAN — no rotation bugs' : 'ISSUES FOUND'); + console.log(`order shifts: ${shifts.length}`); + const clean = totalSkips === 0 && totalDoubles === 0 && shifts.length === 0; + console.log(clean ? 'CLEAN — no rotation bugs' : 'ISSUES FOUND'); - process.exit(totalSkips === 0 && totalDoubles === 0 ? 0 : 1); + process.exit(clean ? 0 : 1); } main(); diff --git a/scripts/replay-combat.js b/scripts/replay-combat.js index f2855da..c2c0e1a 100644 --- a/scripts/replay-combat.js +++ b/scripts/replay-combat.js @@ -178,7 +178,13 @@ 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}`); + // Dump turn line with order AND initiative (DM drag may reorder without + // changing init — log both so parser can flag unexplained shifts). + const ordStr = enc.turnOrderIds.map(id => { + const p = enc.participants.find(x => x.id === id); + return p ? `${p.name}:${p.initiative}` : id; + }).join(','); + console.log(` turn ${totalTurns} (round ${enc.round}): ${actorName} | order=[${ordStr}] cur=${enc.currentTurnParticipantId}`); // 1. damage: actor hits a random living, active target. if (actor) { @@ -302,19 +308,25 @@ async function main() { lastPaused = true; } - // 10. reorderParticipants: every 8 turns, shuffle initiative slightly. + // 10. reorderParticipants: every 8 turns, drag one past next (DM reorder). if (totalTurns % 8 === 0 && lastReorder !== totalTurns) { const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false); - if (living.length >= 2) { - // bump first participant's initiative by +1 (deterministic reorder). - const tgt = living[0]; - const newInit = (tgt.initiative || 0) + 1; + if (living.length >= 3) { + // drag first past second (same-or-cross init, exercises reorder). + const dragged = living[0]; + const target = living[1]; try { - const reordered = [...enc.participants].map(p => p.id === tgt.id ? { ...p, initiative: newInit } : p); - const r = reorderParticipants(enc, reordered); - enc = await patch(encounterPath, enc, r, `reorder (${tgt.name} init→${newInit})`); + const r = reorderParticipants(enc, dragged.id, target.id); + enc = await patch(encounterPath, enc, r, `reorder ${dragged.name}→before ${target.name}`); lastReorder = totalTurns; - } catch (e) { /* ignore */ } + } catch (e) { /* same-init only — try same-init pair */ + const sameInit = living.find(p => p !== dragged && p.initiative === dragged.initiative); + if (sameInit) { + const r = reorderParticipants(enc, dragged.id, sameInit.id); + enc = await patch(encounterPath, enc, r, `reorder ${dragged.name}→before ${sameInit.name}`); + lastReorder = totalTurns; + } + } } }