WS adapter had no reconnect. WS dies (idle/error/close) → wsReady=null,
subscribers dead forever, display frozen until full reload.
Changes (src/storage/ws.js):
- onClose: schedule reconnect via setTimeout(500ms), ensureWs re-arms.
Guard: disposed flag stops reconnect after dispose.
- onOpen: resubscribe all existing doc/coll subscribers (backend state
may have changed). Re-fetch current values on RECONNECT only (skip
first connect — initial REST fetch in subscribe* already did). Added
everConnected flag to distinguish first vs reconnect.
- reconnectTimer unref'd (Node) to avoid hanging event loop.
- dispose(cb): set disposed, clear timer, close ws, then cb.
Also fixed test teardown leaks:
- server/index.js close(): terminate all wss.clients before wss.close().
Reconnect test spawned new ws to server; old close hung on live conn.
- both ws test factories: port 0 (OS picks free) instead of module-local
nextPort counter. Parallel jest workers collided on EADDRINUSE.
Tests: ws-reconnect GREEN (1.7s), ws-contract 23 GREEN. No regression.
server suite 24/24. shared 90/90.
DisplayView called sortParticipantsByInitiative() on visibleParticipants,
ignoring DM drag order. 1-list model = participants[] IS display source.
After cross-init drag, player view diverged from AdminView/turnOrderIds.
Repro: round 4 replay. [reorder Summon1(10)→before Merchant(11)] made
turnOrderIds = [...,Summon2,Summon1,Merchant,OrcBoss]. AdminView correct.
DisplayView re-sorted = Summon2,Merchant,Summon1 (init order) = visually
Merchant appeared between Summon2 and Summon1, NOT at end. DM confused.
Fix: removed sort. DisplayView now renders participants[] order directly
(filter inactive monsters only), matching AdminView line 1222.
Test: RED → GREEN (src/tests/DisplayView.drag-order.test.js). Seeds 3
monsters in drag order [High:20, Low:10, Mid:11]. Asserts DOM order =
participants[] order, not init-sorted. No DisplayView regressions.
Round-complete marker logged roundN (just completed) while turn lines
logged enc.round (post-increment, new round). Result: 'turn 8 (round 2)'
appeared BEFORE 'round 1 complete' — confusing off-by-one.
Replaced bottom 'round N complete' marker with top 'round N starting'
marker. Turn lines for round N now appear after its start marker.
Logic unchanged. 4-round smoke verified.
Selection effect had `!selectedCampaignId` guard — once any campaign
selected, new activeDisplay.activeCampaignId writes ignored. Replay tool
writes activeDisplay to new campaign each run; UI stayed on old selection
=> displayed wrong campaign data.
Removed guard. Selection now syncs when activeCampaignId differs from
current selection. Manual deselect (null) does not force-select (RED test
locks this).
Also fixed test helper bug: createCampaignViaUI/createEncounterViaUI
returned FIRST setDoc match (idA for all creates). Now filters by name +
.pop() for latest. This masked the real bug for several debug cycles.
Tests: 2 new (SelectionFollowsActiveDisplay), both green. No regressions
in full FE suite (App, Combat, DisplayView, Encounter, HideHpToggle,
Logs, Participant, storage all pass). Combat.scenario = pre-existing
BUG-11 crash, not regression.
Campaign card now shows created date/time next to char/encounter counts.
Lets DM tell newest campaign apart (replay tool creates many).
createdAt already set at campaign create (line 2174). Display renders
formatted: 'Jul 1, 2026, 16:32'.
replay-combat.js: campaign + encounter names now include timestamp
(new Date().toLocaleString) for easy identification.
WS collection push verified live (injected test campaigns appeared
without reload).
3 sites fixed to match shared 1-list model:
- line 1216 display: sortedParticipants = participants[] (no re-sort). GM
list renders participants[] directly = turnOrderIds.
- startCombat inline: sort ALL participants by init (active+inactive),
first active = current, persist participants[] reordered + turnOrderIds.
- resume inline: no re-sort on resume. turnOrderIds unchanged.
Display === rotation === turnOrderIds by construction (1-list invariant).
Build green.
Walk active rotation via repeated nextTurn, normalize to frozen[0], then
assert raw-array equality against sortParticipantsByInitiative display +
frozen turnOrderIds.
7 tests: 6 green, 1 RED (reorder = BUG-6).
- startEncounter: match
- tie drag order: match
- reorder via drag: RED — turnOrderIds not updated (BUG-6)
- add/remove/toggle/death-revive: match
RED locks divergence before refactor. Iterate to green here.
Extract shared nextActiveAfter() advance core. Both nextTurn and
computeTurnOrderAfterRemoval delegate to it — single source of truth,
eliminates drift risk where one path changes and the other doesn't.
Previously two separate advance implementations computed the same
target, but any future edit to one would silently desync deact-current
advance from normal nextTurn advance.
Replay (scripts/replay-combat.js):
- Move turn-line print before mutations (event order = reality)
- Emit [pointer X→Y] lines when a mutation advances currentTurnParticipantId
- Emit [pointer X→Y wrap] when round bumps (removal-wrap case)
- Skip pointer emission for nextTurn (label=null) — already logged via turn line
Parser (scripts/analyze-turns.js):
- Parse [pointer X→Y wrap] events
- Credit pointer-target as acted (deact-current advance = turn pointer)
- Wrap pointer credits NEXT round (not current) — fixes cross-round false skip
- Drop currentRemoved special-case — pointer lines make skip check precise
Tests:
- shared/tests/turn.dry.test.js: 3 tests lock deact-current advance ==
nextTurn advance (mid-round, inactive-skipper, wrap+round-bump). RED
catches future drift.
Results: 500-round replay now 0 real skips, 0 double-acts (was 5+3).
Shared suite: 79 green + 1 RED (BUG-6 reorder, intentional).
Removal of current participant when no active after → advance to order[0]
+ bump round. Without bump, nextTurn replays whole round (BUG-5 pattern).
Parser 500-40b: 24 skips/1 double (was 46/64). Down not zero. Remaining
skips = replay async stale read (getDoc between turns), not turn.js.
TODO = backlog from user. REWORK_PLAN = milestones/infra. M4 (dead-not-
skipped) is a milestone, stays in REWORK_PLAN. Removed false 'bugs only'
and M4 references from TODO header.
shared/tests/turn.jump.test.js: desired manual turn override behavior.
- jump sets currentTurn, future nextTurn continues
- jump to first stays same round
- jump invalid throws
2 RED (shared.jumpTurnTo not a function - feature missing).
1 green (invalid throws via TypeError).
TODO: JUMP_TURN_TO test refs added.
server/tests/ws-reconnect.test.js: subscribe, write (fires), force-drop WS,
write again (must still fire). RED on current. wsReady=null after drop,
no reconnect, subscribers dead forever. Display frozen.
src/storage/ws.js: added _test accessor (getWs, forceDrop, getReady,
docSubs, collSubs) for reconnect test. Test-only, no behavior change.
TODO: BUG-7 (reorder no undo), BUG-8 (ws reconnect) added.
Desired behavior locked:
- dead PC not removed from turnOrderIds
- dead PC turn still comes up (nextTurn visits them)
- dead PC on their turn can deathSave
- dead PC not auto-set isActive=false by applyHpChange
All 4 RED on current code. Root cause: nextTurn filters isActive,
applyHpChange sets isActive=false on death, computeTurnOrderAfterRemoval
drops dead from turnOrderIds.
TODO BUG-3/M4 updated with test refs.
turn.undo.test.js: every op with log.undo roundtrips to prior state.
startEncounter, nextTurn, togglePause, applyHpChange, toggleCondition,
toggleParticipantActive, addParticipant, removeParticipant, endEncounter.
Found: reorderParticipants returns log:null. Cannot undo. Documents as
BUG-7 candidate (test green now, asserts current behavior).
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.