Files
ttrpg-initiative-tracker/shared/tests/turn.characterization.test.js
T
david raistrick 2569cc4497 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.
2026-07-06 10:33:28 -04:00

394 lines
15 KiB
JavaScript

// Characterization tests for shared/turn.js.
// Lock CURRENT behavior (bugs included). M3 will extend, M4 will fix.
// These tests assert what the code does NOW, not what it SHOULD do.
//
// New API: every mutating func is async, takes ctx last, writes encounter +
// log internally, returns newEnc. We assert against newEnc (merged state) or
// the raw persisted patch (storage.calls) for "field not written" checks.
const shared = require('@ttrpg/shared');
const {
sortParticipantsByInitiative,
computeTurnOrderAfterRemoval,
startEncounter,
nextTurn,
togglePause,
addParticipant,
removeParticipant,
toggleParticipantActive,
applyHpChange,
deathSave,
toggleCondition,
reorderParticipants,
endEncounter,
makeParticipant,
} = shared;
const { mockCtx } = require('./_helpers');
// Helper: minimal encounter with given participants.
function enc(participants = [], extra = {}) {
return {
name: 'Test Encounter',
participants,
isStarted: false,
isPaused: false,
round: 0,
currentTurnParticipantId: null,
turnOrderIds: [],
...extra,
};
}
function p(id, initiative, extra = {}) {
return makeParticipant({
id, name: id, type: 'monster',
initiative, maxHp: 20, currentHp: 20,
...extra,
});
}
// Last updateDoc patch written to storage — for "field absent from patch"
// assertions (field === undefined means func didn't write it).
const lastPatch = (storage) => {
const u = storage.calls.filter(c => c.fn === 'updateDoc').pop();
return u ? u.data : {};
};
describe('sortParticipantsByInitiative', () => {
test('higher initiative first', () => {
const ps = [p('a', 5), p('b', 15), p('c', 10)];
const sorted = sortParticipantsByInitiative(ps, ps);
expect(sorted.map(x => x.id)).toEqual(['b', 'c', 'a']);
});
test('ties broken by original order', () => {
const ps = [p('a', 10), p('b', 10), p('c', 10)];
const sorted = sortParticipantsByInitiative(ps, ps);
expect(sorted.map(x => x.id)).toEqual(['a', 'b', 'c']);
});
});
describe('startEncounter', () => {
test('throws if no participants', async () => {
const { ctx } = mockCtx();
await expect(startEncounter(enc([]), ctx)).rejects.toThrow('participants');
});
test('throws if no active participants', async () => {
const { ctx } = mockCtx();
const e = enc([p('a', 10, { isActive: false })]);
await expect(startEncounter(e, ctx)).rejects.toThrow('active');
});
test('sets round 1, turn order sorted, current = highest init', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 5), p('b', 15), p('c', 10)];
const e = enc(ps);
const newEnc = await startEncounter(e, ctx);
expect(newEnc.isStarted).toBe(true);
expect(newEnc.round).toBe(1);
expect(newEnc.turnOrderIds).toEqual(['b', 'c', 'a']);
expect(newEnc.currentTurnParticipantId).toBe('b');
});
test('inactive stays in turn order slot (1-list model)', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 5), p('b', 15, { isActive: false }), p('c', 10)];
const newEnc = await startEncounter(enc(ps), ctx);
// 1-list: all participants sorted by init (active+inactive), inactive stays in slot
expect(newEnc.turnOrderIds).toEqual(['b', 'c', 'a']);
expect(newEnc.currentTurnParticipantId).toBe('c'); // b inactive, skipped
});
});
describe('nextTurn', () => {
test('throws if not started', async () => {
const { ctx } = mockCtx();
await expect(nextTurn(enc([p('a', 10)], { isStarted: false }), ctx)).rejects.toThrow();
});
test('throws if paused', async () => {
const { ctx } = mockCtx();
await expect(nextTurn(enc([p('a', 10)], { isStarted: true, isPaused: true, currentTurnParticipantId: 'a', turnOrderIds: ['a'] }), ctx)).rejects.toThrow();
});
test('advances to next in order, no round bump', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 5), p('b', 15), p('c', 10)];
const e = enc(ps, {
isStarted: true,
round: 1,
currentTurnParticipantId: 'b',
turnOrderIds: ['b', 'c', 'a'],
});
const newEnc = await nextTurn(e, ctx);
expect(newEnc.currentTurnParticipantId).toBe('c');
expect(newEnc.round).toBe(1);
});
test('wraps round when last in order', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 5), p('b', 15), p('c', 10)];
const e = enc(ps, {
isStarted: true,
round: 1,
currentTurnParticipantId: 'a',
turnOrderIds: ['b', 'c', 'a'],
});
const newEnc = await nextTurn(e, ctx);
expect(newEnc.currentTurnParticipantId).toBe('b');
expect(newEnc.round).toBe(2);
});
test('ends encounter if no active participants', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10, { isActive: false })];
const e = enc(ps, {
isStarted: true,
round: 1,
currentTurnParticipantId: 'a',
turnOrderIds: ['a'],
});
const newEnc = await nextTurn(e, ctx);
expect(newEnc.isStarted).toBe(false);
expect(newEnc.currentTurnParticipantId).toBe(null);
});
});
describe('togglePause', () => {
test('pauses started encounter', async () => {
const { ctx } = mockCtx();
const e = enc([p('a', 10)], { isStarted: true, isPaused: false });
const newEnc = await togglePause(e, ctx);
expect(newEnc.isPaused).toBe(true);
});
test('resume preserves turn order (no re-sort)', async () => {
// BUG-5 fix: resume no longer re-sorts. Re-sort displaced current pointer
// and caused skips. Order frozen at startEncounter, patched incrementally.
const { ctx } = mockCtx();
const ps = [p('a', 5), p('b', 15)];
const e = enc(ps, { isStarted: true, isPaused: true, turnOrderIds: ['a', 'b'] });
const newEnc = await togglePause(e, ctx);
expect(newEnc.isPaused).toBe(false);
expect(newEnc.turnOrderIds).toEqual(['a', 'b']);
});
});
describe('removeParticipant', () => {
test('removes from participants array', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10), p('b', 5)];
const newEnc = await removeParticipant(enc(ps), 'a', ctx);
expect(newEnc.participants.map(x => x.id)).toEqual(['b']);
});
test('not started: no turn order mutation', async () => {
const { storage, ctx } = mockCtx();
const ps = [p('a', 10), p('b', 5)];
await removeParticipant(enc(ps), 'a', ctx);
expect(lastPatch(storage).turnOrderIds).toBeUndefined();
});
test('started: removes from turnOrderIds', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10), p('b', 5)];
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'b' });
const newEnc = await removeParticipant(e, 'a', ctx);
expect(newEnc.turnOrderIds).toEqual(['b']);
});
test('started: removing current picks next active', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10), p('b', 5), p('c', 3)];
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b', 'c'], currentTurnParticipantId: 'a' });
const newEnc = await removeParticipant(e, 'a', ctx);
expect(newEnc.currentTurnParticipantId).toBe('b');
});
});
describe('toggleParticipantActive', () => {
test('deactivates participant', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10, { isActive: true })];
const newEnc = await toggleParticipantActive(enc(ps), 'a', ctx);
expect(newEnc.participants[0].isActive).toBe(false);
});
test('started: deactivating current does not advance turn or round', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10), p('b', 5)];
const e = enc(ps, { isStarted: true, round: 1, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' });
const newEnc = await toggleParticipantActive(e, 'a', ctx);
expect(newEnc.currentTurnParticipantId).toBe('a');
expect(newEnc.round).toBe(1);
expect(newEnc.participants.find(p => p.id === 'a').isActive).toBe(false);
});
test('started: reactivating inserts by initiative', async () => {
// BUG-5 fix: reactivated participant slots by initiative (not appended
// to end). Preserves correct rotation order.
const { ctx } = mockCtx();
const ps = [p('a', 10, { isActive: false }), p('b', 5)];
const e = enc(ps, { isStarted: true, turnOrderIds: ['b'], currentTurnParticipantId: 'b' });
const newEnc = await toggleParticipantActive(e, 'a', ctx);
// a init=10 > b init=5 → a slots before b
expect(newEnc.turnOrderIds).toEqual(['a', 'b']);
});
});
describe('applyHpChange', () => {
test('damage reduces hp, clamps 0', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10, { currentHp: 15, maxHp: 20 })];
const newEnc = await applyHpChange(enc(ps), 'a', 'damage', 5, ctx);
expect(newEnc.participants[0].currentHp).toBe(10);
});
test('damage to 0 deactivates + keeps turn order (unified)', async () => {
// Unified: death flips isActive=false (removed from active rotation).
// turnOrderIds unchanged (no turn-order patch on death).
const { storage, ctx } = mockCtx();
const ps = [p('a', 10, { currentHp: 3 }), p('b', 5)];
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' });
const newEnc = await applyHpChange(e, 'a', 'damage', 5, ctx);
expect(newEnc.participants[0].currentHp).toBe(0);
expect(newEnc.participants[0].isActive).toBe(false);
expect(lastPatch(storage).turnOrderIds).toBeUndefined();
expect(lastPatch(storage).currentTurnParticipantId).toBeUndefined();
});
test('heal above 0 reactivates + resets death saves (unified)', async () => {
// Unified: revive from 0 flips isActive=true, deathSaves reset.
const { ctx } = mockCtx();
const ps = [p('a', 10, { currentHp: 0, isActive: false, deathSaves: 2 })];
const newEnc = await applyHpChange(enc(ps), 'a', 'heal', 5, ctx);
expect(newEnc.participants[0].currentHp).toBe(5);
expect(newEnc.participants[0].isActive).toBe(true);
expect(newEnc.participants[0].deathSaves).toBe(0);
});
test('heal clamps to maxHp', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10, { currentHp: 18, maxHp: 20 })];
const newEnc = await applyHpChange(enc(ps), 'a', 'heal', 10, ctx);
expect(newEnc.participants[0].currentHp).toBe(20);
});
test('zero amount = no-op', async () => {
const { storage, ctx } = mockCtx();
const e = enc([p('a', 10, { currentHp: 10 })]);
const newEnc = await applyHpChange(e, 'a', 'damage', 0, ctx);
expect(newEnc).toBe(e); // same ref = no write
expect(storage.calls.filter(c => c.fn === 'updateDoc')).toHaveLength(0);
});
});
describe('deathSave', () => {
test('increments fails', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10, { currentHp: 0, deathFails: 0 })];
const { enc: newEnc } = await deathSave(enc(ps), 'a', 'fail', 1, ctx);
expect(newEnc.participants[0].deathFails).toBe(1);
});
test('clicking same fail decrements (toggle)', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10, { currentHp: 0, deathFails: 2 })];
const { enc: newEnc } = await deathSave(enc(ps), 'a', 'fail', 2, ctx);
expect(newEnc.participants[0].deathFails).toBe(1);
});
test('third fail sets isDying', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10, { currentHp: 0, deathFails: 2 })];
const { enc: newEnc, isDying } = await deathSave(enc(ps), 'a', 'fail', 3, ctx);
expect(newEnc.participants[0].deathFails).toBe(3);
expect(newEnc.participants[0].isDying).toBe(true);
expect(isDying).toBe(true);
});
});
describe('toggleCondition', () => {
test('adds condition', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10, { conditions: [] })];
const newEnc = await toggleCondition(enc(ps), 'a', 'poisoned', ctx);
expect(newEnc.participants[0].conditions).toEqual(['poisoned']);
});
test('removes condition', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10, { conditions: ['poisoned', 'blinded'] })];
const newEnc = await toggleCondition(enc(ps), 'a', 'poisoned', ctx);
expect(newEnc.participants[0].conditions).toEqual(['blinded']);
});
});
describe('reorderParticipants', () => {
test('drag before target (same-init tie)', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10), p('b', 10), p('c', 10)];
const newEnc = await reorderParticipants(enc(ps), 'a', 'c', ctx);
// drag a before c: remove a → [b,c], insert before c → [b,a,c]
expect(newEnc.participants.map(x => x.id)).toEqual(['b', 'a', 'c']);
});
test('cross-init drag blocked (no-op)', async () => {
const { storage, ctx } = mockCtx();
const e = enc([p('a', 10), p('b', 5)]);
const newEnc = await reorderParticipants(e, 'a', 'b', ctx);
expect(newEnc).toBe(e); // same ref = no write
expect(storage.calls.filter(c => c.fn === 'updateDoc')).toHaveLength(0);
});
});
describe('endEncounter', () => {
test('resets all combat state', async () => {
const { ctx } = mockCtx();
const e = enc([p('a', 10)], {
isStarted: true, round: 5, currentTurnParticipantId: 'a', turnOrderIds: ['a'],
});
const newEnc = await endEncounter(e, ctx);
expect(newEnc.isStarted).toBe(false);
expect(newEnc.round).toBe(0);
expect(newEnc.currentTurnParticipantId).toBe(null);
expect(newEnc.turnOrderIds).toEqual([]);
});
});
describe('computeTurnOrderAfterRemoval', () => {
test('not started = empty', () => {
const out = computeTurnOrderAfterRemoval(enc([]), 'a', []);
expect(out).toEqual({});
});
test('removing non-current: no turnOrderIds patch (1-list syncs at call site)', () => {
const e = enc([], { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'b' });
const out = computeTurnOrderAfterRemoval(e, 'a', []);
// 1-list: removal syncs turnOrderIds via participants[] at call site.
// Helper only handles current-advance. Non-current = empty patch.
expect(out).toEqual({});
});
});
describe('addParticipant', () => {
test('appends participant', async () => {
const { ctx } = mockCtx();
const np = p('z', 7);
const newEnc = await addParticipant(enc([p('a', 10)]), np, ctx);
expect(newEnc.participants.map(x => x.id)).toEqual(['a', 'z']);
});
test('rejects duplicate id (skip-bug root cause)', async () => {
// Two participants with same id → togglePause resume rebuilds order with
// dup id twice → nextTurn gets stuck repeating that id forever.
// Audit found this in 100-round replay (addParticipant fired while paused
// because nextTurn threw, loop spun, same totalTurns %10 → re-added).
const { ctx } = mockCtx();
const existing = p('x', 5);
const dup = makeParticipant({ id: 'x', name: 'x2', type: 'monster', initiative: 10, maxHp: 100, currentHp: 100 });
await expect(addParticipant(enc([p('a', 10), existing]), dup, ctx)).rejects.toThrow();
});
});