Single source of truth: combat logic, storage parity, slot ordering
Highlights
Combat logic unified: all 15 App.js handlers delegate to @ttrpg/shared. ~498 lines of inline duplicates deleted. One version of every turn function.
Slot, never sort: participants[] = display order = turn order. addParticipant/updateParticipant insert by initiative via slotIndexForInit; manual drag tie-break survives. Design doc: docs/INITIATIVE_ORDERING.md. Static guard test blocks .sort( regressions.
Storage parity: neutral queryConstraints (orderBy/limit) honored by both firebase + server adapters. Shared contract test runs both identically. Memory adapter deleted; factory throws on unknown mode.
D&D 5e death saves: separate success/fail counts, stable/dead terminal states. Old single-counter model replaced (data loss accepted — feature never worked in prod).
Custom conditions: per-campaign freeform strings. Add applies to participant + persists to palette in one step. Badge render uses merged allConditions (built-ins + custom). toggleCondition accepts any string — proven in combat + replay tests.
Logging contract: every mutating op logs message + undo payload. Structural enforcement via per-op test + static source-scan guard.
UI feedback (toast + info modal): all 23 native alert() replaced. ToastStack (6s auto-dismiss + manual X) for transient failures. InfoModal (persistent OK) for validations. React context wraps all 3 App branches. Fixed: native alert vanished on browser focus loss.
Filter dup chars from add-participant dropdown: character dropdown excludes chars already in encounter. Prevents dup-add at source.
First-class undo/redo UI: ↶/↷ pills in InitiativeControls, always visible when encounter open. Undo = latest non-undone log per encounter. Redo = latest undone, replays forward patch. encounterPath added to all 14 log contexts (filter key). Uses current 2-write undo (non-tx); race safety deferred to log refactor.
Docs: dev-start.sh/dev-stop.sh are the primary dev entrypoint. Server-mode env vars (REACT_APP_BACKEND_REALTIME_URL=ws://.../ws) corrected in README, env.example, docker/Dockerfile.
Test coverage
244 tests across three runners (App 84, shared 133, server 27).
New shared suites: bug10, bug13, bug7, logging contract, death saves, static guards (no-sort, no-unlogged), slot preservation.
npm run test:all runs shared + server (fast path, no frontend).
Breaking
Death saves data model replaced (old single-counter fields dropped).
ws storage mode renamed to server (env var + adapter).
# Single source of truth: combat logic, storage parity, slot ordering
## Highlights
- **Combat logic unified**: all 15 App.js handlers delegate to `@ttrpg/shared`. ~498 lines of inline duplicates deleted. One version of every turn function.
- **Slot, never sort**: `participants[]` = display order = turn order. `addParticipant`/`updateParticipant` insert by initiative via `slotIndexForInit`; manual drag tie-break survives. Design doc: `docs/INITIATIVE_ORDERING.md`. Static guard test blocks `.sort(` regressions.
- **Storage parity**: neutral `queryConstraints` (orderBy/limit) honored by both firebase + server adapters. Shared contract test runs both identically. Memory adapter deleted; factory throws on unknown mode.
- **Bug fixes**: BUG-7 (reorder unlogged), BUG-10 (deact+reactivate double-act), BUG-13 (cross-pointer reorder skip), BUG-14 (slot-not-sort on add/edit), BUG-16/17 (subscribeCollection forwards queryConstraints; dead SDK imports removed).
- **D&D 5e death saves**: separate success/fail counts, stable/dead terminal states. Old single-counter model replaced (data loss accepted — feature never worked in prod).
- **Custom conditions**: per-campaign freeform strings. Add applies to participant + persists to palette in one step. Badge render uses merged `allConditions` (built-ins + custom). `toggleCondition` accepts any string — proven in combat + replay tests.
- **Logging contract**: every mutating op logs message + undo payload. Structural enforcement via per-op test + static source-scan guard.
- **UI feedback (toast + info modal)**: all 23 native `alert()` replaced. ToastStack (6s auto-dismiss + manual X) for transient failures. InfoModal (persistent OK) for validations. React context wraps all 3 App branches. Fixed: native alert vanished on browser focus loss.
- **Filter dup chars from add-participant dropdown**: character dropdown excludes chars already in encounter. Prevents dup-add at source.
- **First-class undo/redo UI**: ↶/↷ pills in InitiativeControls, always visible when encounter open. Undo = latest non-undone log per encounter. Redo = latest undone, replays forward patch. `encounterPath` added to all 14 log contexts (filter key). Uses current 2-write undo (non-tx); race safety deferred to log refactor.
- **Docs**: `dev-start.sh`/`dev-stop.sh` are the primary dev entrypoint. Server-mode env vars (`REACT_APP_BACKEND_REALTIME_URL=ws://.../ws`) corrected in README, env.example, docker/Dockerfile.
## Test coverage
- **244 tests** across three runners (App 84, shared 133, server 27).
- New shared suites: bug10, bug13, bug7, logging contract, death saves, static guards (no-sort, no-unlogged), slot preservation.
- `npm run test:all` runs shared + server (fast path, no frontend).
## Breaking
- Death saves data model replaced (old single-counter fields dropped).
- `ws` storage mode renamed to `server` (env var + adapter).
- setupTests.js: console.error/warn now throw. Warning = failure.
- Combat.characterization: two role=switch (player HP + NPC HP), scope
to player-HP label. getByRole('switch') was ambiguous.
Full suite: 77/77 pass, 0 warnings.
Single list = display = turn order. Slot by initiative, tie-break = add
order. Drag = same-init only (DM tie override). No re-sort on mutation.
Round wrap no rebuild. No cross-init drag.
Move activeDisplay lifecycle to shared (one path, not duplicated in App):
- activateDisplay({campaignId, encounterId})
- clearDisplay()
- toggleHidePlayerHp(current)
All 6 App sites wired (start/end/2×delete-encounter/delete-campaign/hp-toggle).
Existing {patch,log} return shape preserved. No interface changes.
Combat.scenario.test.js rewritten to call shared directly, no React:
- Same actions as old UI version (roster chars, monsters, addAll, hp-toggle×2,
start, 100 rounds of damage/heal/conditions/toggle/edit/deathsave/pause/
resume/add/remove, end). Same funcs, same order, same data.
- Models two firestore docs (encounter + activeDisplay) via shared patches.
- Run time: 72s -> 4ms.
Both suites green: shared 91/91, App 77/77.
- firebase.contract.test.js: run storage contract against createFirebaseStorage
via SDK mock. Currently 12 fail (firebase diverges from contract: no norm(),
injects id). Failures real — firebase copied from main, memory/ws invented
new requirements. Contract under review vs main prod truth.
- storage.factory.test.js: getStorage() routing per REACT_APP_STORAGE env,
singleton cache, getStorageMode() reporting.
- Combat.scenario.test.js: remove console.log (test noise).
- package.json: test:all now runs App + shared + server suites (was missing App).
- getDoc/getCollection/subscribe inject {id,...data} (main firebase shape)
- getCollection normalizes backend {id,data}[] OR bare data[] → {id,...data}
- setDoc(opts.merge) → PATCH (create-on-miss); else PUT (replace)
- ws-reconnect test asserts id now present
24/24 server green. All suites green: App 83, shared 91, server 24.
addParticipant + updateParticipant used sortParticipantsByInitiative (stable
sort, tie-break = original array index). That destroyed manually-dragged tie
order when a drag moved a same-init pair — violated the slot-not-sort design
(docs/INITIATIVE_ORDERING.md: 'Re-slotting on add/edit must preserve
drag-established tie order').
Fix: new slotIndexForInit(list, init) returns splice index into the CURRENT
list (initiative-descending). addParticipant inserts there; updateParticipant
re-inserts only when initiative changed (unrelated edits keep slot — avoids
mid-round rotation dupes surfaced by the 100-round combat test).
Tests:
- turn.slot-not-sort.test.js: drag tie order survives add/edit (4 cases).
- static.no-sort.test.js: errs if .sort( added outside allowlist
(sortParticipantsByInitiative only). Verified by injecting a stray sort —
guard caught it.
'ws' conflated storage mode with transport protocol (WebSocket). Renamed for
clarity: the adapter talks the self-hosted server (src/storage/server.js), which
happens to use WebSocket for realtime — but the name should describe what it
connects to, not how.
Renames:
- src/storage/ws.js -> server.js
- createWsStorage() -> createServerStorage()
- storage mode 'ws' -> 'server'
- REACT_APP_BACKEND_WS -> REACT_APP_BACKEND_REALTIME_URL
- factory param wsUrl -> realtimeUrl
- server/tests/ws-*.test.js -> server-*.test.js
Kept literal: 'ws' npm package, ws:// protocol URLs, /ws WebSocket endpoint.
Transport is accurate there; only storage-naming changed.
Updated all docs (DEVELOPMENT, TESTING, REWORK_PLAN, GLOSSARY, TODO), scripts
(dev-start.sh, replay-combat.js), factory + ESM tests. 204 tests green.
Hangs/regressions fail fast instead of stalling CI.
- shared/jest.config.js: testTimeout 10000
- src/setupTests.js: jest.setTimeout(10000) (CRA blocks testTimeout in
package.json — not in allowlist)
- server/jest.config.js: already had 10000
Verified: injected hanging test -> 'Exceeded timeout of 10000 ms'.
1-list model keeps slot position on toggle. Reactivate does not re-insert
by initiative, so no second turn. computeTurnOrderAfterAddition is dead code
(no call sites). Tests prove no double-act in both scenarios.
Zero call sites. Old turn-array addition logic, superseded by 1-list
slotIndexForInit model. Removed func, export, stale comment, and 3
characterization tests that only locked the dead func's behavior.
addParticipants + updateParticipant returned log:null → invisible in
combat log. Contract test caught them. Now both return
log:{ message, undo:{ participants }}.
App.js handlers wired (handleAddAllCharacters, handleUpdateParticipant,
handleInlineInitiative) — call logAction on logged mutations.
Logging contract now complete: every mutating combat op produces a log
entry. No gaps. turn.logging.test.js proves it.
238 tests green.
Per-op logging test encoded gaps as expected (asserted log:null = pass).
Missed 4 real bugs (BUG-7 + deathSave + addParticipants + updateParticipant).
Bunk. Static scan catches regressions regardless of test enumeration.
static.no-unlogged.test.js: scans turn.js source, extracts function bodies
via brace counting, finds any return with patch!=null AND log:null.
Verified: injected fake unlogged mutation in endEncounter → guard fails
with offender name + return expr. Restored → 128 green.
Logging contract now enforced structurally, not by enumeration.
Old model broken: single deathSaves counter, 3=revive. No fails tracked,
successes conflated with fails. DM couldn't model real death save outcomes.
New model (D&D 5e):
- deathSave(enc, id, type, n) — type='success'|'fail', n=1|2|3
- Participant fields: deathSaves (successes), deathFails, isStable, isDying
- 3 successes = stable (0hp unconscious, safe until healed)
- 3 failures = dead (isDying, caller animates removal)
- Toggling same pip = undo that pip
- Returns { patch, log, status } — status: 'stable'|'dead'|'pending'
isDying back-compat flag kept for App.js removal animation
App.js:
- UI split into green ✓ saves row + red ✕ fails row
- Status badges: Stabilized (emerald) / Dying (red pulse)
- handleDeathSaveChange(participantId, type, saveNumber) — new sig
- makeParticipant init: deathFails + isStable defaults
Data loss: old single-counter participants lose saves (feature didn't work,
no real state to preserve). User confirmed acceptable.
Tests:
- turn.deathsave.test.js: RED-then-GREEN, 3-success stable, 3-fail dead,
independent tracking, new signature
- Updated 6 callers across characterization/combat/dead-skip/logging/scenario
- Logs UI tests: new title selectors (Success N / Fail N)
243 tests green.
Custom conditions (per-campaign freeform) previously only persisted to the
campaign palette — Add button did NOT apply them to the targeted participant,
and badges rendered via CONDITIONS (missing custom ids) so applied custom
conditions did not display. Now:
- addCustomCondition(label, participantId) persists to palette AND toggles
onto the participant in one step. Enter + Add button both pass p.id.
- Admin badge lookup uses allConditions (built-ins + custom), not CONDITIONS.
Shared contract: toggleCondition accepts ANY string. Prove it in tests:
- turn.combat.test.js adds CUSTOM_CONDITIONS ('hexed','rager',
'marked_for_death','🛡️blessed') to the seeded queue + random pool, asserts
condition-not-string invariant, and a dedicated add/remove round-trip test.
- replay-combat.js mirrors the same CUSTOM_CONDITIONS through the live backend.
Server-mode docs/env: REACT_APP_BACKEND_WS (stale pre-rename) -> correct
REACT_APP_BACKEND_REALTIME_URL=ws://.../ws in README, env.example,
docker/Dockerfile (STORAGE=ws -> server). DEVELOPMENT.md + scripts/README.md
now point to ./scripts/dev-start.sh as the primary dev entrypoint.
133 shared tests green.
Character dropdown in add-participant picker excluded characters already
in the encounter. Prevents dup-add at the source — user can only select
chars not yet participating.
This is amazing work! I love all the changes. One thing I noted in my testing, is that if a PC dies it goes through the death and then gets removed from the DM roster; I think we would want it to be marked inactive. Thoughts @keen ?
This is amazing work! I love all the changes. One thing I noted in my testing, is that if a PC dies it goes through the death and then gets removed from the DM roster; I think we would want it to be marked inactive. Thoughts @keen ?
robert
merged commit 8354d21fc6 into main2026-07-05 00:07:57 -04:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Single source of truth: combat logic, storage parity, slot ordering
Highlights
@ttrpg/shared. ~498 lines of inline duplicates deleted. One version of every turn function.participants[]= display order = turn order.addParticipant/updateParticipantinsert by initiative viaslotIndexForInit; manual drag tie-break survives. Design doc:docs/INITIATIVE_ORDERING.md. Static guard test blocks.sort(regressions.queryConstraints(orderBy/limit) honored by both firebase + server adapters. Shared contract test runs both identically. Memory adapter deleted; factory throws on unknown mode.allConditions(built-ins + custom).toggleConditionaccepts any string — proven in combat + replay tests.alert()replaced. ToastStack (6s auto-dismiss + manual X) for transient failures. InfoModal (persistent OK) for validations. React context wraps all 3 App branches. Fixed: native alert vanished on browser focus loss.encounterPathadded to all 14 log contexts (filter key). Uses current 2-write undo (non-tx); race safety deferred to log refactor.dev-start.sh/dev-stop.share the primary dev entrypoint. Server-mode env vars (REACT_APP_BACKEND_REALTIME_URL=ws://.../ws) corrected in README, env.example, docker/Dockerfile.Test coverage
npm run test:allruns shared + server (fast path, no frontend).Breaking
wsstorage mode renamed toserver(env var + adapter).- setupTests.js: console.error/warn now throw. Warning = failure. - Combat.characterization: two role=switch (player HP + NPC HP), scope to player-HP label. getByRole('switch') was ambiguous. Full suite: 77/77 pass, 0 warnings.Move activeDisplay lifecycle to shared (one path, not duplicated in App): - activateDisplay({campaignId, encounterId}) - clearDisplay() - toggleHidePlayerHp(current) All 6 App sites wired (start/end/2×delete-encounter/delete-campaign/hp-toggle). Existing {patch,log} return shape preserved. No interface changes. Combat.scenario.test.js rewritten to call shared directly, no React: - Same actions as old UI version (roster chars, monsters, addAll, hp-toggle×2, start, 100 rounds of damage/heal/conditions/toggle/edit/deathsave/pause/ resume/add/remove, end). Same funcs, same order, same data. - Models two firestore docs (encounter + activeDisplay) via shared patches. - Run time: 72s -> 4ms. Both suites green: shared 91/91, App 77/77.Contract rewritten to match main prod truth: - require id injection in all doc/collection results - honor setDoc({merge:true}) (main L1624 + 4 activeDisplay sites) - drop invented bare<->prefixed cross-lookup tests (main never does this) - add setDoc{merge}, batch set-only/update-only contract coverage Fix mismatches vs main: - subscribeCollection hook now forwards queryConstraints (was dropped; LOG_QUERY sort+limit honored again). Mock onSnapshot honors orderBy+limit. 2 new contract tests prove the chain. - subscribeDoc/subscribeCollection adapters now forward errCb. App hooks + DisplayView propagate subscribe errors to UI (match main onSnapshot 3rd arg). - ws adapter signatures accept queryConstraints + errCb (interface match). activeDisplay writes match main: - 5 unguarded sites -> setDoc({merge:true}) (create-if-missing, not updateDoc) - 3 guarded sites stay updateDoc Delete memory adapter: third storage system added complexity, zero value. firebase-mock covers fast tests, ws covers server path. Factory throws on unknown mode now. Delete phantom 'Phase A/B' comment in firebase.js header. Tests updated to assert setDoc{merge} (not updateDoc) for activeDisplay writes. 83/83 green.- getDoc/getCollection/subscribe inject {id,...data} (main firebase shape) - getCollection normalizes backend {id,data}[] OR bare data[] → {id,...data} - setDoc(opts.merge) → PATCH (create-on-miss); else PUT (replace) - ws-reconnect test asserts id now present 24/24 server green. All suites green: App 83, shared 91, server 24.Bug: server adapter accepted queryConstraints (orderBy/limit) but ignored them. Combat log [orderBy('timestamp','desc'), limit(500)] returned ALL logs unordered in server mode — unbounded growth. No test caught it: shared contract tested subscribeCollection with 0 constraints; the firebase-only queryConstraint test never touched the server adapter. Parity gap. Fix (Plan A — neutral shape): - index.js: orderBy()/limit() now NEUTRAL builders returning {__type}. Removed SDK orderBy/limit re-export. - firebase.js: subscribeCollection translates neutral -> SDK query constraints. - server.js: applyConstraints() helper sorts/limits client-side (backend returns all). Constraints stored per collection, applied on initial fetch + WS change. - contract.js: added 3 queryConstraint tests (orderBy desc, limit, no-constraints) to SHARED contract — both adapters now run identical assertions. - firebase.contract.test.js: removed now-redundant firebase-only constraint block (covered by shared contract). Data impact: ZERO. Constraints are query-time only, never stored. Combat log docs keep timestamp field. No Firebase data migration needed. 208 tests green.testto Single source of truth: combat logic, storage parity, slot orderingreorderParticipants returned log:null → handler skipped logAction → drag invisible in combat log, no undo payload. Now returns log:{ message, undo:{ participants, turnOrderIds, currentTurnParticipantId }}. App.js handleDrop calls logAction on logged reorders. Found second gap: deathSave also returned null log (both branches). Fixed — message + undo (participants snapshot). Added logging contract test (turn.logging.test.js): - all mutating ops logged (start/next/pause/add/remove/toggle/hp/deathsave/ condition/reorder/end) - no-ops return null log (same-id, cross-init, cross-pointer blocks) - undo payloads valid + restore prior state - documents addParticipants + updateParticipant gaps (null log, intentional) 235 tests green.addParticipants + updateParticipant returned log:null → invisible in combat log. Contract test caught them. Now both return log:{ message, undo:{ participants }}. App.js handlers wired (handleAddAllCharacters, handleUpdateParticipant, handleInlineInitiative) — call logAction on logged mutations. Logging contract now complete: every mutating combat op produces a log entry. No gaps. turn.logging.test.js proves it. 238 tests green.Old model broken: single deathSaves counter, 3=revive. No fails tracked, successes conflated with fails. DM couldn't model real death save outcomes. New model (D&D 5e): - deathSave(enc, id, type, n) — type='success'|'fail', n=1|2|3 - Participant fields: deathSaves (successes), deathFails, isStable, isDying - 3 successes = stable (0hp unconscious, safe until healed) - 3 failures = dead (isDying, caller animates removal) - Toggling same pip = undo that pip - Returns { patch, log, status } — status: 'stable'|'dead'|'pending' isDying back-compat flag kept for App.js removal animation App.js: - UI split into green ✓ saves row + red ✕ fails row - Status badges: Stabilized (emerald) / Dying (red pulse) - handleDeathSaveChange(participantId, type, saveNumber) — new sig - makeParticipant init: deathFails + isStable defaults Data loss: old single-counter participants lose saves (feature didn't work, no real state to preserve). User confirmed acceptable. Tests: - turn.deathsave.test.js: RED-then-GREEN, 3-success stable, 3-fail dead, independent tracking, new signature - Updated 6 callers across characterization/combat/dead-skip/logging/scenario - Logs UI tests: new title selectors (Success N / Fail N) 243 tests green.DM can add freeform custom conditions alongside built-ins. Persists to campaign doc, survives across encounters, shared with display view. Implementation: - ParticipantManager subscribes campaign doc via useFirestoreDocument - customConditions[] merged with built-in CONDITIONS at render - Input field + Add button in conditions picker (Enter or click) - Dedup case-insensitive vs built-ins + existing custom - maxLength 40 - addCustomCondition writes to campaigns/{id}.customConditions - Badge render: custom conditions show raw label (no emoji) - DisplayView: same fallback render for custom ids - toggleCondition unchanged (already accepts any string id) - campaignId prop passed from EncounterManager -> ParticipantManager 243 tests green. Build clean.Custom conditions (per-campaign freeform) previously only persisted to the campaign palette — Add button did NOT apply them to the targeted participant, and badges rendered via CONDITIONS (missing custom ids) so applied custom conditions did not display. Now: - addCustomCondition(label, participantId) persists to palette AND toggles onto the participant in one step. Enter + Add button both pass p.id. - Admin badge lookup uses allConditions (built-ins + custom), not CONDITIONS. Shared contract: toggleCondition accepts ANY string. Prove it in tests: - turn.combat.test.js adds CUSTOM_CONDITIONS ('hexed','rager', 'marked_for_death','🛡️blessed') to the seeded queue + random pool, asserts condition-not-string invariant, and a dedicated add/remove round-trip test. - replay-combat.js mirrors the same CUSTOM_CONDITIONS through the live backend. Server-mode docs/env: REACT_APP_BACKEND_WS (stale pre-rename) -> correct REACT_APP_BACKEND_REALTIME_URL=ws://.../ws in README, env.example, docker/Dockerfile (STORAGE=ws -> server). DEVELOPMENT.md + scripts/README.md now point to ./scripts/dev-start.sh as the primary dev entrypoint. 133 shared tests green.This is amazing work! I love all the changes. One thing I noted in my testing, is that if a PC dies it goes through the death and then gets removed from the DM roster; I think we would want it to be marked inactive. Thoughts @keen ?
yeah, it decided to axe them instead of leaving them, completely ignoring the previous "dont remove them from initiative" fix......sigh...