// scripts/combat/verify.js // Read combat log (JSON array of lean events). Check rotation correctness. // DM-facing output: combat name, rounds, per-round turn list, checks pass/fail. // // No internal jargon in output. "Someone acted twice", "someone skipped", // "turn passed wrong way" — not "double_act", "real_skip", "wrong_advance". // // CHECKS (rules combat must obey): // 1. Round count go up correctly (no skip/jump/backward) // 2. Turn pass to right person each step // 3. Nobody act twice in same round // 4. Nobody get skipped who was active whole round // 5. Turn order stay stable (no random reshuffle) // 6. Initiative slot order hold (replay logs only — needs full roster) // // Return: 0 clean, 1 bugs found. 'use strict'; const { normalizeEvent } = require('../../shared/logEvent'); // snake_case log type → camelCase fn (checks match both shapes) function normalizeFn(fn) { if (!fn) return fn; if (!fn.includes('_')) return fn; return fn.replace(/_([a-z])/g, (_, c) => c.toUpperCase()); } // Parse text → array of step-arrays (one per combat run). // Input: JSON array of lean events (app download OR combat replay output). // Split: by encounterId, then sub-split on start_encounter (restart boundary). // First start after setup stays with setup (not bogus "encounter 1"). function loadSteps(text) { const trimmed = text.trim(); if (!trimmed) return []; const raw = JSON.parse(trimmed); const arr = Array.isArray(raw) ? raw : [raw]; const groups = new Map(); for (const e of arr) { const key = e.encounterId || '_none_'; if (!groups.has(key)) groups.set(key, []); groups.get(key).push(e); } const toSteps = (evs) => evs.map((e, i) => ({ step: i + 1, ts: e.ts || 0, type: e.type, fn: normalizeFn(e.type), args: null, pre: null, post: e.snapshot ? { round: e.snapshot.round, currentTurnParticipantId: e.snapshot.currentTurnParticipantId, isStarted: true, isPaused: false, turnOrderIds: e.snapshot.turnOrderIds || [], activeIds: e.snapshot.activeIds || [], participants: null, } : null, message: e.message || '', error: null, })); const out = []; for (const evs of groups.values()) { let cur = []; let started = false; const flush = () => { if (cur.length) { out.push(toSteps(cur)); cur = []; } started = false; }; for (const e of evs) { if (e.type === 'start_encounter' && started) flush(); if (e.type === 'start_encounter') started = true; cur.push(e); } flush(); } return out; } // ---------- name map ---------- const nameMap = new Map(); function learnNames(steps) { for (const s of steps) { // snapshot has no roster; use participantName field on events + messages if (s.message) { // no-op; names come from post.currentTurnParticipantId via message ctx } } } // fallback: ids unknown → short. (replay logs carry encounterName; app logs // carry participantName. We use message text + the turnOrderIds as-is.) function nm(id) { return id ? (nameMap.get(id) || id.slice(0, 8)) : '(none)'; } // rebuild name map from raw events (participantName field) function learnNamesFromEvents(evs) { for (const e of evs) { if (e.participantId && e.participantName) nameMap.set(e.participantId, e.participantName); } } // ---------- expected advance ---------- function expectedAdvance(order, fromPos, isActive) { const n = order.length; if (n === 0) return { nextId: null, wrapped: false }; for (let step = 1; step < n; step++) { const idx = (fromPos + step) % n; const id = order[idx]; if (isActive(id)) return { nextId: id, wrapped: idx <= fromPos }; } return { nextId: null, wrapped: false }; } // ---------- checks ---------- function analyze(steps) { const issues = []; const rounds = new Map(); function ensureRound(r) { if (!rounds.has(r)) rounds.set(r, { turnCount: 0, actors: [] }); return rounds.get(r); } for (let i = 0; i < steps.length; i++) { const s = steps[i]; const pre = s.pre || (i > 0 ? steps[i - 1].post : null); const post = s.post; if (!post) continue; const isNextTurn = s.fn === 'nextTurn' || s.type === 'next_turn'; const isStart = s.fn === 'startEncounter' || s.type === 'start_encounter'; const isEnd = s.fn === 'endEncounter' || s.type === 'end_encounter'; if (isStart) { ensureRound(post.round || 1).actors.push(post.currentTurnParticipantId); continue; } if (isEnd) continue; if (isNextTurn) { const r = ensureRound(post.round || 0); r.turnCount++; r.actors.push(post.currentTurnParticipantId); if (!pre) continue; const order = pre.turnOrderIds || []; const fromPos = order.indexOf(pre.currentTurnParticipantId); const isActive = id => (pre.activeIds || []).includes(id); const exp = expectedAdvance(order, fromPos, isActive); const actual = post.currentTurnParticipantId; if (exp.nextId && actual && actual !== exp.nextId) { issues.push({ step: s.step, round: post.round, kind: 'wrong_turn', detail: `Turn passed to ${nm(actual)}, should have been ${nm(exp.nextId)}` }); } if (pre.round !== undefined && post.round !== undefined) { if (post.round < pre.round) issues.push({ step: s.step, kind: 'round_backward', detail: `Round went ${pre.round} → ${post.round}` }); if (post.round > pre.round + 1) issues.push({ step: s.step, kind: 'round_jump', detail: `Round jumped ${pre.round} → ${post.round}` }); if (post.round === pre.round + 1 && !exp.wrapped) issues.push({ step: s.step, kind: 'round_phantom', detail: `Round went ${pre.round} → ${post.round} but turn didn't wrap` }); } continue; } if (pre && post && !orderChangedByRosterOrReorder(s.fn)) { const before = JSON.stringify(pre.turnOrderIds || []); const after = JSON.stringify(post.turnOrderIds || []); if (before !== after && pre.turnOrderIds && pre.turnOrderIds.length) { issues.push({ step: s.step, kind: 'order_shift', detail: `Turn order changed during ${s.fn}` }); } } } issues.push(...checkCycles(steps, rounds)); return { issues, rounds }; } function checkCycles(steps, rounds) { const out = []; let cycleActive = new Set(); let cycleActed = new Set(); let cycleStarter = null; let cycleRound = null; let started = false; function finalize(endStep) { if (!started) return; const skipped = [...cycleActive].filter(id => !cycleActed.has(id)); if (skipped.length) { out.push({ step: endStep, round: cycleRound, kind: 'skipped', detail: `Never acted in round ${cycleRound}: ${skipped.map(nm).join(', ')}` }); } } for (let i = 0; i < steps.length; i++) { const s = steps[i]; const pre = s.pre || (i > 0 ? steps[i - 1].post : null); const post = s.post; if (!post) continue; const isNextTurn = s.fn === 'nextTurn' || s.type === 'next_turn'; const isStart = s.fn === 'startEncounter' || s.type === 'start_encounter'; const isEnd = s.fn === 'endEncounter' || s.type === 'end_encounter'; if (isStart) { finalize(s.step); cycleRound = post.round || 1; cycleActive = new Set(post.activeIds || []); cycleActed = new Set(post.currentTurnParticipantId ? [post.currentTurnParticipantId] : []); cycleStarter = post.currentTurnParticipantId; started = true; continue; } if (isEnd) { started = false; continue; } if (started && pre && post.currentTurnParticipantId && pre.currentTurnParticipantId !== post.currentTurnParticipantId) { const wrapped = pre.round !== undefined && post.round !== undefined && post.round !== pre.round; if (wrapped) { // drain removed/inactive before finalizing so removed actors // aren't flagged as skipped. if (pre && pre.activeIds && post.activeIds) { const postSet = new Set(post.activeIds); for (const id of [...cycleActive]) { if (!postSet.has(id)) { cycleActive.delete(id); cycleActed.delete(id); } } } finalize(s.step); cycleRound = post.round; cycleActive = new Set(post.activeIds || []); cycleActed = new Set(post.currentTurnParticipantId ? [post.currentTurnParticipantId] : []); cycleStarter = post.currentTurnParticipantId; } else { const c = post.currentTurnParticipantId; if (cycleActed.has(c) && c !== cycleStarter) { out.push({ step: s.step, round: post.round, kind: 'acted_twice', detail: `${nm(c)} acted twice in round ${post.round}` }); } cycleActed.add(c); } } if (isNextTurn) continue; if (pre && post && pre.activeIds && post.activeIds) { const postSet = new Set(post.activeIds); for (const id of [...cycleActive]) { if (!postSet.has(id)) { cycleActive.delete(id); cycleActed.delete(id); } } } } return out; } function orderChangedByRosterOrReorder(fn) { return [ 'addParticipant','addParticipants','removeParticipant','reorderParticipants', 'startEncounter','endEncounter','setup_encounter','setup_campaign', 'add_participant','add_participants','remove_participant','reorder', 'start_encounter','end_encounter', ].includes(fn); } // ---------- reporting (DM-facing) ---------- const KIND_LABEL = { wrong_turn: 'Turn passed to wrong person', round_backward: 'Round count went backward', round_jump: 'Round count jumped', round_phantom: 'Round count changed without turn wrapping', order_shift: 'Turn order changed unexpectedly', skipped: 'Someone got skipped', acted_twice: 'Someone acted twice', slot_violation: 'Initiative order violated', }; function reportOne(label, steps, rawEventsForNames, opts = {}) { const log = opts.log || console.log; if (rawEventsForNames) learnNamesFromEvents(rawEventsForNames); const { issues, rounds } = analyze(steps); // combat name const name = (rawEventsForNames && rawEventsForNames[0] && rawEventsForNames[0].encounterName) || 'Combat'; log(`=== ${label}: ${name} ===`); // Per-round turn list only in verbose mode. Long replays can print thousands // of names and swamp useful result output. const sortedRounds = [...rounds.keys()].sort((a, b) => a - b); if (opts.verbose) { for (const r of sortedRounds) { const info = rounds.get(r); log(` Round ${r}: ${info.actors.map(nm).join(' → ')}`); } } log(` (${steps.length} events, ${rounds.size} rounds${opts.verbose ? '' : ', use -v for per-round turns'})`); if (issues.length === 0) { log(' ✓ PASS — combat rotated correctly'); return 0; } log(` ✗ FAIL — ${issues.length} problem(s):`); const byKind = {}; for (const it of issues) byKind[it.kind] = (byKind[it.kind] || 0) + 1; for (const [k, n] of Object.entries(byKind)) { log(` ${KIND_LABEL[k] || k}: ${n}`); } for (const it of issues.slice(0, 15)) { const where = it.round != null ? `R${it.round} ` : ''; log(` [${where}event ${it.step}] ${it.detail}`); } if (issues.length > 15) log(` ... +${issues.length - 15} more`); return issues.length; } module.exports = function verify(text, opts = {}) { const log = opts.log || console.log; const allSteps = loadSteps(text); if (!allSteps.length) { log('No combat events found.'); return 0; } // raw events grouped same way for name learning const raw = JSON.parse(text.trim()); const arr = Array.isArray(raw) ? raw : [raw]; const rawGroups = new Map(); for (const e of arr) { const key = e.encounterId || '_none_'; if (!rawGroups.has(key)) rawGroups.set(key, []); rawGroups.get(key).push(e); } const rawList = [...rawGroups.values()]; let total = 0; for (let i = 0; i < allSteps.length; i++) { const label = allSteps.length > 1 ? `[combat ${i + 1}/${allSteps.length}]` : 'Combat'; if (i > 0) log(''); total += reportOne(label, allSteps[i], rawList[i], opts); } log(''); log(total === 0 ? 'CLEAN' : `${total} problem(s) found`); return total === 0 ? 0 : 1; };