feat(combat): add first-class undo and redo controls Add undo and redo controls to the combat UI so the DM can recover from recent actions without leaving the encounter view. Undo and redo operate on the current encounter's combat history. Empty stacks produce clear feedback instead of failing silently. Redo order follows normal stack behavior after multiple undos. This makes combat history actionable during play, not just visible in the log. feat(logs): make combat logs replayable Replace plain combat log messages with structured combat events that can be used by the UI, exported as JSON, replayed, and verified. Each new log entry records the action type, encounter identity, participant identity, a small action delta, undo intent, and a turn snapshot. Download and copy now export the event stream as JSON so a saved combat log is useful for offline analysis and debugging. Legacy logs remain viewable, but new logs use the structured event format. feat(logs): make undo and redo transactional Apply undo and redo as single storage operations so the encounter state and log state cannot drift apart. Server storage applies the encounter update and the log undone flag inside one SQLite transaction. Firebase storage uses a batch write for the same behavior. The storage contract now includes undo/redo semantics. This replaces fragile multi-write undo behavior where a failure could update the encounter without marking the log, or mark the log without updating the encounter. feat(combat): add unified replay and verification tool Add one combat CLI for replaying live combat and verifying combat logs. Replay drives the live backend through the same shared combat logic used by the app, writes a JSON event log to an explicit output path, and automatically verifies the result. Verification checks for DM-visible combat problems such as skipped turns, double actions, bad round changes, and unexpected turn order changes. The tool uses the same JSON event stream produced by log downloads, supports verbose turn output, and handles Ctrl-C by ending the encounter, writing the partial log, and verifying what was captured. fix(perf): keep long combat logging fast Remove the combat-time log query bottleneck that made long replays slow as log volume grew. Combat controls no longer subscribe to the log collection just to keep undo and redo state warm. Undo and redo now query the latest matching encounter log only when clicked. Server collection queries support exact filters, ordering, limits, and offsets, and SQLite indexes keep latest-log and per-encounter log lookups fast. Also fix duplicate WebSocket handler registration so realtime updates do not double-fire under write load. fix(turns): make toggle active a status change Make toggle active a roster/status edit instead of a turn advance. Deactivating the current participant no longer passes the turn or increments the round. The current turn stays where it is until the DM explicitly clicks Next Turn, and Next Turn skips inactive participants during normal rotation. This matches the initiative design: slot order is stable, toggle active does not move participants, and round changes only come from explicit turn advance. chore(ci): make warnings and hangs fail fast Tighten test and build checks so failures are visible instead of noisy or silent. Builds run with CI enabled so warnings fail production builds. The full test command runs app, shared, and server suites with hard timeouts so hangs fail quickly. Static eslint coverage fails on warnings as well as errors. Tests were updated around the new async combat logging flow, structured log events, transactional undo, replay verification, and toggle-active semantics.
343 lines
12 KiB
JavaScript
343 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 && isNextTurn) {
|
|
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;
|
|
};
|