Builds replayable combat logs with first-class undo/redo, unified verification tooling, fast indexed log queries, and stricter CI.

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.
This commit is contained in:
david raistrick
2026-07-06 10:33:28 -04:00
parent 9f8b09c7d9
commit 2569cc4497
51 changed files with 4465 additions and 2164 deletions
+68 -43
View File
@@ -2,8 +2,6 @@
// Passthrough: no shape translation. Backend = firebase mirror.
// Implements same interface as memory.js. Tested by storage contract vs running server.
'use strict';
// Native browser WebSocket if present, else ws pkg (Node/jest).
// Lazy load ws pkg so CRA prod build (ESM) doesn't choke on require().
let WebSocketImpl;
@@ -42,30 +40,13 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
return { id, ...data };
}
// Apply neutral {__type} query constraints client-side. Backend returns all
// docs; adapter sorts/limits. Mirrors firebase mock applyConstraints.
function applyConstraints(docs, constraints) {
let out = [...docs];
for (const c of constraints || []) {
if (!c || !c.__type) continue;
if (c.__type === 'orderBy') {
out.sort((a, b) => {
const av = a[c.field];
const bv = b[c.field];
if (av === bv) return 0;
const cmp = av > bv ? 1 : -1;
return c.dir === 'desc' ? -cmp : cmp;
});
} else if (c.__type === 'limit') {
out = out.slice(0, c.n);
}
}
return out;
}
const docSubs = new Map(); // path -> Set<cb>
const collSubs = new Map(); // collPath -> Set<cb>
const collConstraints = new Map(); // collPath -> constraints[] (per collection)
// Last data emitted per path. Dedupe identical re-emits so WS-open
// re-fetch (authoritative catch-up) doesn't double-fire when REST already
// delivered the same data. Race: REST empty → WS-open populated fires.
const lastEmitted = new Map(); // path -> serialized data (or null for empty)
let ws = null;
let wsReady = null;
@@ -74,6 +55,16 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
let everConnected = false;
const RECONNECT_DELAY = 500;
// Emit helper: dedupe identical re-emits per path. WS-open re-fetch
// (authoritative catch-up) + initial REST both may fire; skip dup.
function emit(kind, path, data, cbs) {
const key = `${kind}:${path}`;
const ser = data === undefined ? 'undef' : JSON.stringify(data);
if (lastEmitted.get(key) === ser) return; // identical, skip
lastEmitted.set(key, ser);
cbs.forEach(cb => cb(data));
}
function ensureWs() {
if (wsReady) return wsReady;
wsReady = new Promise((resolve, reject) => {
@@ -96,7 +87,7 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
for (const p of collSubs.keys()) {
ws.send(JSON.stringify({ type: 'subscribe', kind: 'collection', path: p }));
}
// On RECONNECT only: re-fetch current values — catches writes that
// Reconnect only: re-fetch current values — catches writes that
// happened while disconnected (broadcast missed). Skip on first connect
// (initial REST fetch in subscribeDoc/subscribeCollection already did).
if (isReconnect) {
@@ -104,7 +95,8 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
storage.getDoc(p).then(doc => { cbs.forEach(cb => cb(doc)); }).catch(() => {});
}
for (const [p, cbs] of collSubs) {
storage.getCollection(p).then(docs => { cbs.forEach(cb => cb(docs)); }).catch(() => {});
const cstr = collConstraints.get(p) || [];
storage.getCollection(p, cstr).then(docs => { cbs.forEach(cb => cb(docs)); }).catch(() => {});
}
}
resolve(ws);
@@ -127,16 +119,14 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
let msg; try { msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString()); } catch { return; }
handleMessage(msg);
};
// Bind ONCE. Browser WS + ws pkg are EventTargets: assigning onX AND
// addEventListener('x') registers the handler twice -> every event
// fires 2x (double onOpen -> spurious reconnect re-fetch, double
// onMessage -> emit dedupe races under write load -> UI looks stale).
ws.onopen = onOpen;
ws.onerror = onError;
ws.onclose = onClose;
ws.onmessage = onMessage;
if (typeof ws.addEventListener === 'function') {
ws.addEventListener('open', onOpen);
ws.addEventListener('error', onError);
ws.addEventListener('close', onClose);
ws.addEventListener('message', onMessage);
}
})();
});
return wsReady;
@@ -144,7 +134,9 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
// Backend pushes change notices keyed by path. Re-fetch affected subscribers.
async function handleMessage(msg) {
if (msg.type !== 'change' || !msg.change) return;
if (msg.type !== 'change' || !msg.change) {
return;
}
const c = msg.change;
// doc subscriber at exact changed path
const docCbs = docSubs.get(c.path);
@@ -157,7 +149,8 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
const collCbs = collSubs.get(c.parent);
if (collCbs) {
const constraints = collConstraints.get(c.parent) || [];
const docs = applyConstraints(await storage.getCollection(c.parent), constraints);
// Server honors orderBy + limit in SQL now.
const docs = await storage.getCollection(c.parent, constraints);
collCbs.forEach(cb => cb(docs));
}
}
@@ -217,11 +210,27 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
return { id: res.id, path: res.path };
},
async getCollection(rawCollPath) {
async getCollection(rawCollPath, queryConstraints = []) {
const p = norm(rawCollPath);
const docs = await api('GET', '/api/collection', { path: p });
// Backend returns array of { id, data } OR bare data[]; normalize to
// { id, ...data } (firebase truth).
const query = { path: p };
// Push where + orderBy + limit + offset to server SQL when present.
// Avoids client-side full-scan + re-sort on every WS change notification.
for (const c of queryConstraints || []) {
if (!c || !c.__type) continue;
if (c.__type === 'where') {
query.whereField = c.field;
query.whereOp = c.op;
query.whereValue = c.value;
} else if (c.__type === 'orderBy') {
query.orderBy = c.field;
query.dir = c.dir || 'asc';
} else if (c.__type === 'limit') {
query.limit = c.n;
} else if (c.__type === 'offset') {
query.offset = c.offset;
}
}
const docs = await api('GET', '/api/collection', query);
if (!Array.isArray(docs)) return [];
return docs.map(d => {
if (d && typeof d === 'object' && 'id' in d && 'data' in d) {
@@ -231,15 +240,31 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
});
},
async countCollection(rawCollPath) {
const p = norm(rawCollPath);
const res = await api('GET', '/api/collection/count', { path: p });
return (res && typeof res.count === 'number') ? res.count : 0;
},
async batchWrite(ops) {
const normOps = ops.map(op => ({ ...op, path: norm(op.path) }));
await api('POST', '/api/batch', null, { ops: normOps });
},
// Transactional undo (server mode only). Firebase falls back to 2-write
// in App.js (via store.txUndo check). undo = { encounterPath, updates, redo }.
// redo=true applies redo patch + flips undone:false.
async undo({ logPath, undo, redo = false }) {
const normUndo = { ...undo, encounterPath: norm(undo.encounterPath) };
const normLog = norm(logPath);
await api('POST', '/api/undo', null, { logPath: normLog, undo: normUndo, redo });
},
subscribeDoc(rawPath, cb, errCb) {
const p = norm(rawPath);
lastEmitted.delete('doc:'+p); // fresh sub → allow first emit
// Initial value via REST (independent of WS connect). getDoc injects id.
storage.getDoc(p).then(cb).catch(() => {});
storage.getDoc(p).then(doc => emit('doc', p, doc, docSubs.get(p) || new Set([cb]))).catch(() => {});
// WS only for subsequent change notifications.
ensureWs().then(() => {
ws.send(JSON.stringify({ type: 'subscribe', kind: 'doc', path: p }));
@@ -252,11 +277,11 @@ function createServerStorage({ baseUrl, realtimeUrl } = {}) {
subscribeCollection(rawCollPath, cb, queryConstraints = [], errCb) {
const p = norm(rawCollPath);
collConstraints.set(p, queryConstraints || []);
// Initial value via REST (independent of WS connect). Apply constraints
// client-side — backend returns all docs, adapter sorts/limits.
storage.getCollection(p)
.then(docs => cb(applyConstraints(docs, queryConstraints || [])))
.catch(() => {});
lastEmitted.delete('coll:'+p); // fresh sub → allow first emit
// Server honors orderBy + limit in SQL now. No client re-sort.
storage.getCollection(p, queryConstraints || [])
.then(docs => emit('coll', p, docs, collSubs.get(p) || new Set([cb])))
.catch(err => { if (errCb) errCb(err); });
// WS only for subsequent change notifications.
ensureWs().then(() => {
ws.send(JSON.stringify({ type: 'subscribe', kind: 'collection', path: p }));