togglePause no longer writes log entry (shared/turn.js). Lifecycle op, not player action. Undo stack now targets last real action, not flag flip. Removed dead pause/resume cases from expandUndo. verify.js false positive: round wrap via non-nextTurn event (removeParticipant) not detected — cycleActed never reset, actors flagged as acted_twice. Fix: detect round change on ANY event, drain removed/inactive before finalize (avoids false skipped when actor removed mid-round). Tests updated: togglePause logging test asserts no log; undo test asserts excluded. Repro confirmed via minimal verify case (removeParticipant r1->r2).
354 lines
12 KiB
JavaScript
354 lines
12 KiB
JavaScript
// 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;
|
|
};
|