turn.reorder.test.js: 4 green (swaps, throws-diff-init, throws-missing-id,
documents current no-turnOrderIds-touch) + 1 RED (BUG-6: should update
turnOrderIds to reflect new order).
Found: reorderParticipants changes participants[] array but not turnOrderIds.
nextTurn rotates via turnOrderIds only → mid-combat drag-drop = no effect.
replay-combat.js calls with wrong signature (swallowed by try/catch), so
real path never exercised either.
TODO: BUG-6 added.
REAL test audit should have been. jest, seeded RNG, mirrors replay-combat.js
op sequence exactly. Asserts per-round invariants: rotation-dupe, turnOrder
dup-id, currentTurn valid+active, HP bounds.
Result: 13 rotation-dupes / 100 rounds. First at round 4 (Cleric twice).
Deterministic, reproducible every run. BUG-5 locked.
Deprecate tests/audit/*.js: random sim gave false 0-violations while
this exact test reproduces bug. Commented early-return. Kept for reference,
delete later when log analyzer + unit tests cover ground.
TODO: BUG-5 added (mid-round addParticipant/revive corrupts rotation).
Root cause hypothesis: computeTurnOrderAfterAddition appends id to
turnOrderIds end. Round wrap re-sorts by initiative. currentTurn pointer
stale after sort → drifts → nextTurn revisits.
Test RED by design (documents live bug). Pre-push will block on push.
Audit tools are test code (bug-finders), not scripts. Move to tests/audit/.
scripts/ now only replay-combat (live demo tool).
scratch/ = gitignored throwaway. Repro scripts, exploration, debug.
Update DEVELOPMENT.md + scripts/README to match new layout.
Audit bug: pause fired turn%12, no resume in same iter. nextTurn then
called on paused encounter → threw 'Encounter not running'. Throw is
correct feature behavior (nextTurn refuses when paused); audit misuse.
Fix: togglePause twice (pause+resume) in one iteration, plus guard
'advance-while-paused' check before nextTurn call.
Result: 6 audit artifacts → 0 violations / 100 rounds.
Confirms BUG-1 resolved as side effect of BUG-2 dup-id fix.
Replay verify: 10 rounds, 103 turns, no skip/dupe.
TODO: BUG-1 + BUG-2 marked RESOLVED/FIXED.
Root cause: addParticipant appended participant to participants[] without
checking id uniqueness. Two participants with same id in array. On
togglePause resume, turnOrderIds rebuilt via sort → dup id appears twice.
nextTurn then stuck repeating that id (rotation breaks).
This was the enabling step for BUG-1's full corruption (audit chain):
pause blocks advance → totalTurns frozen → addParticipant re-adds
same r${totalTurns} id → resume dup → nextTurn stuck.
Fix: throw on duplicate id in addParticipant. Caller must use fresh id
(crypto.randomUUID in App, replay already does).
Evidence:
- Test: 'addParticipant rejects duplicate id' (was test.skip, now live).
- Pre-fix: 1 RED (Received function did not throw).
- Post-fix: 50 green (shared), 23 green (server), 62 green (FE).
- Reachability in normal app: low (App uses crypto.randomUUID) but no
guard existed before. Defensive + unblocks BUG-1 isolation.
No other behavior changed.
Runs pure turn.js combat, audits per round:
- rotation integrity, HP bounds, isActive consistency, turnOrder dup,
currentTurn valid, deathSave range, removeParticipant orphans,
conditions, undo support
100-round run: 128 violations all BUG-1/BUG-2 family (4 symptoms).
Clean: HP, isActive, deathSave, conditions, removal.
Exploratory (Math.random), not unit test. Unit tests lock known bugs.
Move all test files out of source dirs into per-workspace tests/:
- shared/tests/ (3 unit test files)
- server/tests/ (1 integration test)
- src/tests/ (8 characterization + scenario tests + testHelpers)
Fix all relative import paths (App, storage, __mocks__, testHelpers).
Fix jest.config testMatch globs in shared/ and server/ (rootDir +
<rootDir>/tests pattern).
Delete scripts/repro-pause-bug.js (debug scratch, superseded by
turn.pause-add.test.js).
Keep scripts/replay-combat.js + scripts/audit-rotation.js as manual
demo/exploratory tools (NOT unit tests, not deterministic).
No logic changes. All green: shared 49 + 1 validated RED, server 23,
FE 62. Scenario test unchanged (240s timeout, pre-existing slow).
- turn.round-rotation.test.js: 7 tests, full round visits each active
participant once (pure nextTurn clean). Green.
- turn.characterization.test.js: RED 'addParticipant rejects duplicate id'.
Validates current behavior allows dup ids (self-inflicted in audit via
loop spin-while-paused re-adding same id; unreachable in app via
crypto.randomUUID, but documents gap).
- audit-rotation.js: pure turn.js simulation of replay op sequence.
Detects rotation violations (skip/dupe per round). Pause disabled = 0
violations across 100 rounds. Pause enabled = 56-77 violations starting
round 20. Pinpoints addParticipant+pause interaction.
- repro-pause-bug.js: minimal repro scripts.
- replay-combat.js: rewritten for real rounds (full initiative cycles),
visible damage each turn, all conditions, toggleActive, remove,
reinforce, edit, pause/resume, reorder, endEncounter. HP bumped for
100-round sustain + revive dead each round.
No feature code changed.
- ROUNDS now = full initiative cycles (not turns). Each round advances
initiative until round counter ticks (all participants act).
- Visible damage: current actor hits random living target for 3-10 dmg.
Player view sees HP bars change live.
- Default delay 200ms (was 800ms).
- Reproduces M4 skip bug: rounds shrink as participants die (8→7→2→1).
- Label accuracy: 'turn N (round X)'.
Root cause (HAR-diagnosed): replay script wrote firebase-prefixed paths via
raw REST, bypassing adapter norm(). Two path roots coexisted in db:
bare 'campaigns/X' (adapter writes, from App)
prefixed 'artifacts/.../campaigns/X' (replay raw writes)
Adapter read bare, missed prefixed. UI showed stale test1 (legit manual UI
write, not wiped) but replay campaigns invisible.
A. replay-combat.js: use createWsStorage adapter instead of raw fetch. Same
contract boundary as App. norm() runs on all paths. Can't drift.
Mirror App.js getPath locally for path construction.
B. contract.js: 4 new identity tests (setDoc prefixed -> getCollection bare,
setDoc prefixed -> getDoc bare, setDoc prefixed -> getDoc prefixed,
setDoc bare -> getCollection prefixed). Run against every impl (memory,
ws). memory.js lacked norm() -> RED first, now GREEN after adding norm.
C. db moved out of /tmp to ./data/tracker.sqlite (gitignored). Never tmp.
Tests: 124 green (39 shared + 23 ws-contract + 62 FE).
scripts/replay-combat.js: drives full combat via live backend REST, computes
turns through shared/turn.js. Player display (subscribed via WS) live-updates.
Usage: node scripts/replay-combat.js [rounds] [delayMs]
TODO.md: tracks M4 work.
- Dead participants must NOT be skipped (still occupy initiative slot,
death saves resolve on their turn). Saw in game Saturday.
- JUMP_TURN_TO manual turn override.
Drives same UI buttons a DM clicks. Exercises:
- campaign + character roster (3 PCs)
- monsters, NPC, add-all, hidden hp toggle
- start combat, 100 rounds of nextTurn
- damage/heal, conditions (stunned), toggle-active
- edit initiative, edit name, remove participant
- pause/resume with reinforcement mid-combat
- damage-to-0 + death saves (x3) + revive
- end combat + confirm
Harness: each phase wrapped in recordAsync try/catch. Failures collected,
reported at end, do NOT abort run. Found no app bugs; selector bugs in harness
fixed (char form ids vs placeholders, encounter row scope via Init: marker,
conditions panel async render, dead-participant damage skip as expected game
state, end-encounter modal title 'End Encounter?' not 'End Combat').
289/289 phases ok. 58 other frontend + 39 shared + 19 ws-contract = 116 total green.
DisplayView missed in original M2 refactor — raw onSnapshot(doc(db,path))
survived. In ws/memory mode db is a stub sentinel, so raw SDK calls crash
('Expected first argument to collection() to be a CollectionReference...').
Reported by human testing player display after start combat.
TDD:
1. RED: DisplayView.characterization.test.js asserts adapter.subscribeDoc
called for campaign + encounter + activeDisplay paths. Adapter recorder
(getAdapterCalls/resetAdapterCalls in firebase.js) instruments subscribe
calls — catches raw-SDK bypass that firebase mock alone cannot (mock db
satisfies raw onSnapshot, hiding the bug).
2. Fix: 2 raw onSnapshot sites in DisplayView -> storage.subscribeDoc.
3. GREEN: 2 new tests pass, 116 total green.
Audit confirmed DisplayView was the ONLY remaining raw SDK site in App.js.
ws/memory storage mode never set the module-level `db` variable. 24 handlers
guarded with `if (!db) return` early-exited, silently dropping all writes
(create campaign, add encounter, participant CRUD, combat, logs).
db stays a truthy sentinel object { __localStub: true } in non-firebase mode.
All real reads/writes route through storage.*; db only used by guards.
56 frontend tests green. Verified via headed browser: create campaign flow
works end-to-end (modal closes, campaign appears via WS realtime push).
Firestore rejects writes containing undefined values. The pause/resume
and end-combat undo snapshots read encounter fields that may not yet
exist in Firestore, so add ?? false / ?? null / ?? 0 fallbacks to
match the pattern already used in the start-combat undo path.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>