Files
david raistrick bf1fccfd3b Exclude pause/resume from undo; fix verify round-wrap false positive
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).
2026-07-06 18:30:51 -04:00

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;
};