feat(replay+parser): log order+init, detect unexplained order shifts
replay-combat.js: - Turn line now dumps order=[Name:init,...] (both, not names only) - reorderParticipants call fixed: real drag (dragged→before target), correct signature (ids not array). Was broken (passed array, func wants ids, swallowed by try/catch silent no-op). analyze-turns.js: - Parse order=[Name:init,...] from turn lines - detectOrderShifts: compare order+init between consecutive turns. Flag shifts NOT explained by logged reorder, roster change (add/remove), or init change. Catches display/rotation divergence (invariant: display===turnOrderIds===nextTurn). - Report order shifts count + sample. CLEAN requires 0 shifts. Result: 100-round replay CLEAN (0 skips, 0 doubles, 0 shifts). Note: shift detector reads turnOrderIds dump. reorder still leaves turnOrderIds unchanged (BUG-6) — Path A (step 3) aligns display+rotation, then shift detector catches true divergence.
This commit is contained in:
@@ -20,7 +20,7 @@ const fs = require('fs');
|
|||||||
|
|
||||||
// ---------- parsing ----------
|
// ---------- 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 DEACTIVATE_RE = /^\s*\[(?:deactivate)\s+(.+?)\]\s*$/;
|
||||||
const REACTIVATE_RE = /^\s*\[(?:revive-reactivate|reactivate)\s+(.+?)\]\s*$/;
|
const REACTIVATE_RE = /^\s*\[(?:revive-reactivate|reactivate)\s+(.+?)\]\s*$/;
|
||||||
const ADD_RE = /^\s*\[(?:add)\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 RESUME_RE = /^\s*\[resume\]\s*$/;
|
||||||
const ROUND_COMPLETE_RE = /^\s*---\s*round\s+(\d+)\s+complete/;
|
const ROUND_COMPLETE_RE = /^\s*---\s*round\s+(\d+)\s+complete/;
|
||||||
const FIRST_RE = /^combat started:\s+round\s+\d+,\s+first=(.+?)\s*$/;
|
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*$/;
|
const POINTER_RE = /^\s*\[pointer\s+(.+?)→(.+?)( wrap)?\]\s*$/;
|
||||||
|
|
||||||
function parseLine(line) {
|
function parseLine(line) {
|
||||||
if (TURN_RE.test(line)) {
|
if (TURN_RE.test(line)) {
|
||||||
const m = line.match(TURN_RE);
|
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)) {
|
if (FIRST_RE.test(line)) {
|
||||||
const m = line.match(FIRST_RE);
|
const m = line.match(FIRST_RE);
|
||||||
@@ -50,6 +57,10 @@ function parseLine(line) {
|
|||||||
const m = line.match(POINTER_RE);
|
const m = line.match(POINTER_RE);
|
||||||
return { kind: 'pointer', from: m[1].trim(), to: m[2].trim(), wrap: m[3] === ' wrap' };
|
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] };
|
if (ROUND_COMPLETE_RE.test(line)) return { kind: 'round-complete', round: +line.match(ROUND_COMPLETE_RE)[1] };
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -91,6 +102,9 @@ function reconstruct(events) {
|
|||||||
if (ev.wrap) curRound += 1;
|
if (ev.wrap) curRound += 1;
|
||||||
const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound);
|
const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound);
|
||||||
r.events.push({ ...ev, idx: r.events.length });
|
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') {
|
} else if (ev.kind === 'round-complete') {
|
||||||
if (rounds.has(ev.round)) rounds.get(ev.round).complete = true;
|
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 ----------
|
// ---------- CLI ----------
|
||||||
|
|
||||||
function readInput() {
|
function readInput() {
|
||||||
@@ -232,12 +292,24 @@ function main() {
|
|||||||
console.log(` sequence: ${rep.turns.join(' -> ')}`);
|
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(`\n=== ${reports.length} rounds analyzed ===`);
|
||||||
console.log(`real skips: ${totalSkips}`);
|
console.log(`real skips: ${totalSkips}`);
|
||||||
console.log(`double-acts: ${totalDoubles}`);
|
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();
|
main();
|
||||||
|
|||||||
+22
-10
@@ -178,7 +178,13 @@ async function main() {
|
|||||||
const actorName = firstActiveName(enc);
|
const actorName = firstActiveName(enc);
|
||||||
const actor = currentParticipant(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.
|
// 1. damage: actor hits a random living, active target.
|
||||||
if (actor) {
|
if (actor) {
|
||||||
@@ -302,19 +308,25 @@ async function main() {
|
|||||||
lastPaused = true;
|
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) {
|
if (totalTurns % 8 === 0 && lastReorder !== totalTurns) {
|
||||||
const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
||||||
if (living.length >= 2) {
|
if (living.length >= 3) {
|
||||||
// bump first participant's initiative by +1 (deterministic reorder).
|
// drag first past second (same-or-cross init, exercises reorder).
|
||||||
const tgt = living[0];
|
const dragged = living[0];
|
||||||
const newInit = (tgt.initiative || 0) + 1;
|
const target = living[1];
|
||||||
try {
|
try {
|
||||||
const reordered = [...enc.participants].map(p => p.id === tgt.id ? { ...p, initiative: newInit } : p);
|
const r = reorderParticipants(enc, dragged.id, target.id);
|
||||||
const r = reorderParticipants(enc, reordered);
|
enc = await patch(encounterPath, enc, r, `reorder ${dragged.name}→before ${target.name}`);
|
||||||
enc = await patch(encounterPath, enc, r, `reorder (${tgt.name} init→${newInit})`);
|
|
||||||
lastReorder = totalTurns;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user