Commit Graph
146 Commits
Author SHA1 Message Date
robertandClaude Opus 4.8 7d0ea883b0 fix: drop useless string concat in devTools KEY to unblock CI build
react-scripts build with CI=true treats the no-useless-concat ESLint
warning as an error. DefinePlugin inlining is already prevented by the
dynamic process.env[KEY] lookup, so the literal split was unnecessary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 23:07:04 -04:00
david raistrick b5b339a5dd feat: temp HP, character isNpc, initiative box
Temp HP:
- setTempHp() in shared/turn.js (replaces, no stacking)
- Damage absorbs temp HP first (both 5e + generic rulesets)
- Inline temp HP input on participant card (cyan when active)
- Temp HP field in edit modal
- makeParticipant tempHp field (default 0)
- 8 shared tests

Character isNpc:
- isNpc field on character roster model
- buildCharacterParticipant: type 'npc' when isNpc
- Add form checkbox + edit form checkbox
- NPC badge on character list row
- Writeback preserves isNpc

Initiative box:
- Amber-bordered box around initiative input
- Gold text (amber-300), disabled stays gold
- Stone-950 bg matches page
- Bumped maxHp (stone-200) + tempHp (stone-300/cyan) visibility
2026-07-08 21:36:26 -04:00
david raistrick eef11c3b6e fix: scope selectedEncounter + scrollY localStorage per campaign
- selectedEncounter key: ttrpg.selectedEncounter.{campaignId}
- scrollY key: ttrpg.scrollY.{campaignId}
- Restore scoped encounter selection on campaign switch
- Global keys unchanged (selectedCampaign, wakeLock, collapses)
- Prevents same-browser tabs on different campaigns from fighting
- Tests: 3 scoping cases (scoped encounter, scoped scroll, global intact)
2026-07-08 20:00:13 -04:00
david raistrick 2c6dfdafc8 fix: guard player display from cross-encounter conflicts
- Start encounter only claims display if slot empty or already ours
  (prevents stealing display from another live encounter)
- End encounter only clears display if THIS encounter is the one showing
  (prevents killing display for a different live encounter)
- Use unwrapped activeDisplayData (not snapshot wrapper activeDisplayInfo)
- Tests: 4 display guard cases (claim empty, no-steal busy, clear own,
  no-clear other)
2026-07-08 19:56:00 -04:00
david raistrick c05a283cf0 feat: encounter card 'IN PROGRESS' badge + red ring for active combat
- Pulsing red ⚔ IN PROGRESS badge on encounter card title
- Red ring around card when combat started and not ended
2026-07-08 19:41:35 -04:00
david raistrick 69ab0c35c0 fix: move revive/mark dead to stats row, bump stat font size
- Revive/Mark Dead buttons moved to init/hp/maxhp row (right justified)
- Separate from damage/heal controls
- Stat inputs bumped text-sm -> text-base, widths increased
2026-07-08 19:39:32 -04:00
david raistrick 91e23856b4 feat: tablet-friendly encounter layout
- Combat controls always left column (narrow portrait, wide desktop)
- Short labels below lg breakpoint, full labels at lg+
- Sticky controls all viewports
- Collapsible Add Participants section (auto-collapse during combat)
- Fullscreen encounter toggle
- Modals via createPortal to escape stacking context
- Reduced padding/margins in portrait orientation
2026-07-08 19:28:05 -04:00
david raistrick fd03ae48c8 Character writeback on encounter end
Campaign-level toggle (CharacterManager, live flip). When ON, ending an
encounter writes each character participant's current HP, Max HP, and AC back
to the campaign roster so they carry into the next encounter.

- shared: buildCharacterParticipant uses defaultCurrentHp (fallback maxHp)
- endEncounter: syncs maxHp/ac/currentHp to campaign players if syncCharacters,
  snapshots old values into undo payload for restore. Works retroactive
  (ctx.campaignId fallback for encounters lacking campaignId field).
- New encounters store campaignId field.
- Undo end-encounter restores character values client-side.
- CharacterManager: styled sync toggle (checkbox badge), stat badges in list
  (HP/Current HP/AC/Init), edit box supports Current HP field.

Tests: 5 writeback cases (off/on/skip monsters/undo snapshot/missing char).
2026-07-08 16:33:21 -04:00
david raistrick 96770099f6 Persist campaign/encounter selection + scroll position across reload
UI reset to top on every reload/code update. Now:
- selectedCampaignId persisted via localStorage
- selectedEncounterId persisted via localStorage
- scrollY saved (beforeunload + pagehide + visibilitychange + 2s interval
  for Android Chrome reliability)
- scroll restored after campaigns data loads (300ms delay for mobile render)
2026-07-08 14:12:35 -04:00
david raistrick ebd91ca42a Inline editable fields (init/hp/maxhp/ac) with editing overlay
Participant card fields now editable in place (D-style): transparent bg,
underline on hover/focus, no spinners. Covers Initiative, Current HP, Max HP,
and AC badge value.

Click any value to edit. Blur/Enter saves. Escape cancels. Status recomputes
on HP change (conscious/dying/dead/down per ruleset).

Editing overlay: when a field is focused, amber ring highlights the card and
a centered pointer-events-none label shows '✎ Editing {field}'. Label floats
over card middle without blocking input — taps pass through to field.

Handlers: handleInlineCurrentHp (recomputes status), handleInlineMaxHp,
handleInlineAc. Keys prefixed to avoid React key collisions when values match.

Tests: selectors updated to use element id (monsterMaxHp) since inline
aria-labels now match form label queries.
2026-07-08 14:05:55 -04:00
david raistrick 41c1e48874 Wake lock persistence, button reposition, docs, dev LAN support
Wake lock (Prevent Sleep) toggles now persist across reloads via
localStorage in both AdminView and DisplayView. Buttons repositioned
inline in AdminView campaigns header bar (was floating overlay causing
overlap on tablets). DisplayView buttons persist localStorage too.

Wake lock acquire failure now shows toast with fix hint (HTTPS or
Chrome flag). Fullscreenchange listener re-acquires wake lock (Android
discards on screen off).

dev-start.sh: auto-detects LAN IP (en0/en1), frontend binds 0.0.0.0,
backend URL inlined as LAN IP so phones reach backend. DANGEROUSLY_DISABLE_HOST_CHECK
for LAN access. Outputs LAN URL + wake lock flag instructions.

Docs: README 'Prevent Sleep (Wake Lock)' section covering secure context
requirement, Android Chrome flag workaround for LAN testing, iOS Safari
standalone PWA bug. DEVELOPMENT.md LAN access + wake lock note.
2026-07-08 13:19:10 -04:00
david raistrick 863e8b3719 Add AC field (character, monster/npc, participant edit); layout polish
AC (Armor Class) optional field across all participant entry points:
- shared: ac field on makeParticipant + buildMonsterParticipant +
  buildCharacterParticipant, defaults null
- CharacterManager: defaultAc state + add form field + inline edit field +
  display in character list
- Monster add form: AC field
- EditParticipantModal: AC field next to Initiative
- ParticipantManager (DM list): AC badge on name row (sky-blue, stylized,
  large value, small label) for at-a-glance reading
- Player display: no AC (DM only)

Layout polish:
- Add participants form: 12-col grid, 5 fields single row (Init Mod, Initiative,
  AC, Max HP, HP Formula), shrunk from oversized fields
- Character add form: 12-col grid, name grows (col-span-6), Init Mod/AC/HP
  small right-aligned, order matches add participants
- Character inline edit: labels added (Name/HP/Init Mod/AC), name flex-grows
- HP Formula: label trimmed (example moved to placeholder 'e.g. 2d6+9'),
  Reroll button in edit modal
- ParticipantManager init input shrunk (w-8, centered)

Tests: 6 new AC builder tests (turn.ac.test.js). Existing test labels updated
for renamed fields.
2026-07-08 12:23:19 -04:00
david raistrick 4a92c667c5 Fix dev bulk-delete button gate + reposition inside campaigns
Gate was process.env.NODE_ENV === 'development' — unsafe default. react-scripts
inlines NODE_ENV=development when unset, so prod deploys forgetting the env var
exposed the delete-all button. Switched to explicit opt-in REACT_APP_DEV_TOOLS=1.

process.env.REACT_APP_DEV_TOOLS as static literal gets inlined by DefinePlugin at
webpack build time — runtime mutations in tests had no effect, and dev-start
without the env produced bundles with the branch dead-stripped. Extracted gate
to src/config/devTools.js using dynamic key access
(process.env['REACT_APP_' + 'DEV_TOOLS']) so DefinePlugin cannot inline it; the
value is read at runtime. dev-start.sh now exports REACT_APP_DEV_TOOLS=1.

Button had also drifted outside the campaigns collapse block to the page bottom;
moved it back inside the campaigns section after the grid.

Tests cover both paths: gate logic (unset/0/arbitrary/1) in BulkDelete.gate.test.js,
prod safety render (button absent when unset) in BulkDelete.render-hidden.test.js,
dev feature render (button present when DEV_TOOLS=1) in BulkDelete.render-shown.test.js.
2026-07-08 11:46:56 -04:00
david raistrick 6f11edab94 HP formula stored on participant, loads in edit modal
hpFormula field persisted on participant doc (makeParticipant + builder).
Add handler stores formula. Edit modal loads participant.hpFormula, reroll
button sets maxHp field (no save until save). Edit submit persists formula.
Formula only for monster/npc types.
2026-07-07 17:24:36 -04:00
david raistrick 2dfa155469 Add /display manifest for Android home-screen install
Android Chrome only reads manifest at install time from current page. Root
manifest has start_url '.' (root). Installing from /display launched root.

Fix: separate display-manifest.json (start_url /display, scope /, landscape).
App.js swaps link[rel=manifest] href when on /display path. Install from
/display now launches /display standalone.
2026-07-07 16:44:29 -04:00
david raistrick 0fa3928bf8 Track encounter start/end timestamps; display on card
startEncounter sets startedAt (clears endedAt on restart). endEncounter sets
endedAt. snapshotOf includes both for undo/redo fidelity. expandUndo split
start/end cases to restore old timestamps.

Display: encounter card shows Started/Ended independently (endedAt shows
even without startedAt). Undo sets null (can't delete via merge), test snap
strips only startedAt/endedAt null for compare.

Reviewed by pi gpt-5.5: stale endedAt on restart fixed, snap null-strip
narrowed. Medium-low risk.
2026-07-07 16:41:34 -04:00
david raistrick 907484fc7f Character drag reorder via players array
Character list items draggable (ChevronsUpDown handle). Drop reorders
players array on campaign doc (single updateDoc). Array order = display
order.
2026-07-07 15:40:58 -04:00
david raistrick 9dbbb88730 Campaign drag reorder: fix sort in fetchDetails path
fetchDetails (db active path) sorted by createdAt, ignored order field.
Drag persisted but UI didn't re-sort. Now both paths sort by order
(fallback createdAt).
2026-07-07 15:37:04 -04:00
david raistrick 1078062b53 Campaign drag reorder via order field
Campaign cards draggable (ChevronsUpDown handle in header). Drop reorders,
batch updates order field on affected campaign docs. Sort by order (fallback
createdAt). New campaign gets order = max+1.
2026-07-07 15:24:12 -04:00
david raistrick 94126567e7 Encounter drag reorder via order field
Encounter cards draggable (ChevronsUpDown handle). Drop reorders, batch
updates order field on affected encounter docs. Sort by order (fallback
createdAt). New encounter gets order = max+1.
2026-07-07 15:18:08 -04:00
david raistrick 95db6fdfdc Character section rollup: clickable title, count, persisted collapse
Match campaigns rollup pattern. Title button toggles collapse (chevron
left, like campaigns). Character count in header. Collapse state persisted
to localStorage key ttrpg.charactersCollapsed.
2026-07-07 14:51:45 -04:00
david raistrick a5b6d61e83 Generic ruleset UI + campaign/encounter tags + switch-during-combat fix
Campaign + encounter cards: ruleset tag (5e/GEN), create date visible.
CreateEncounterForm keyed by campaignId so default ruleset syncs on campaign switch.

Campaign switch during active combat:
- encounterStartedRef (unpaused) blocks switch + toast
- encounterActiveRef (started paused-or-not) gates display-follow effect
- manualSelectRef tracks user clicks; external display change clears it
  (BUG-12 follow still works for replay/other-DM)
- Prevents revert race when EncounterManager unmounts and refs go false

UI:
- campaign card: ruleset tag bottom-right, opposite delete
- encounter card: tag inline title, date left of participants count
- EncounterManager fetches campaignDoc for default ruleset inheritance

Tests green: app 100, shared 186, server 40.
2026-07-07 14:28:32 -04:00
david raistrick c2bb5cca29 Generic (non-5e) ruleset mode: negative HP, down status, mark dead/revive
New campaign/encounter ruleset toggle: 5e (default, unchanged) vs generic.

Generic mode:
- No death saves (deathSave/stabilize throw)
- Negative HP allowed (no clamp at 0)
- <=0 HP = status 'down' (no pips, no unconscious auto-condition)
- Monster death = dead + inactive (same as 5e)
- markDead button: DM sets dead manually (both modes)
- revive: dead->conscious (generic), dead->stable (5e)

5e mode: zero behavior change.

shared/turn.js:
- applyHpChangeGeneric: negative HP, down status, no death-save logic
- markDead: status dead, monster auto-inactive
- reviveParticipant: ruleset-aware (generic=conscious, 5e=stable)
- deathSave/stabilize: throw in generic
- expandUndo: mark_dead case added

UI (src/App.js):
- CreateCampaignForm + CreateEncounterForm: ruleset radio toggle
- handleCreateCampaign/handleCreateEncounter: store ruleset field
- EncounterManager: fetch campaignDoc for default ruleset inheritance
- DM participant: down/dead labels, Mark Dead button (generic), Revive both
- death-save pips/buttons gated 5e-only
- DisplayView: down label, generic status derivation

Tests: 15 generic cases (turn.generic.test.js). 186 shared total.
2026-07-07 13:36:16 -04:00
david raistrick 02344eb3f5 Bump Docker base images to node 22; mock firestore comment cleanup
Both Dockerfiles (root firebase-mode + docker/ caddy) bumped node:18-alpine
to node:22-alpine. better-sqlite3 v12 (from PR #6) requires node 20+; node 18
builds fail with node-gyp/python errors. openssl-legacy-provider flag kept
for react-scripts 5 compat.

.dockerignore: removed .env* exclusion so .env.local COPY works in firebase
image build.

mock firestore applyConstraints comment: offset handled by adapter (firebase.js
slices), mock never sees it — clarified dead-code reasoning.
2026-07-06 23:49:47 -04:00
david raistrick c544bc305a Merge branch 'pr-6' into single-source 2026-07-06 23:24:25 -04:00
robertandClaude Fable 5 995347d255 Fix white /logs page in firebase mode: emulate offset in adapter
The logs page white-screened when deployed against Firestore. LogsView
paginates with the neutral offset() constraint, which the server/sqlite
adapter pushes into SQL — but the firebase adapter's subscribeCollection
had no case for it and passed the raw {__type:'offset'} object through
to the SDK's query(), which throws on non-QueryConstraint values. The
throw fired inside useFirestoreCollection's effect on first render,
unmounting the whole tree. Dev never hit it because dev runs the server
adapter.

Fix: shared toSdkConstraints() translator in the firebase adapter,
used by getCollection and subscribeCollection. The client SDK has no
offset, so emulate it — widen limit to offset+limit and slice the
leading docs off the snapshot (same read cost as the admin SDK's
native offset, which also bills skipped docs). Unknown constraint
types are now dropped instead of passed through, so a future neutral
builder can't reintroduce the crash. This also fixes getCollection
silently skipping offset, which returned page 1 for every page.

Server/sqlite adapter unchanged; offset still runs in SQL. Two new
shared contract tests (offset pagination + offset past end) run
against both adapters and pass: firebase mock 33, live sqlite 40.

Also bump better-sqlite3 ^11.3.0 -> ^12.0.0: v11 fails to compile
against Node 26's V8 (GetPrototype removed), so server tests could
not run at all on Node 26. v12.11.1 builds clean; full server suite
passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:50:29 -04:00
david raistrick 9159755846 Scope player card transitions: ring/shadow fast, fade/transform slow
transition-all duration-1000 on PlayerParticipantCard wrapper slowed current-
turn ring/border animation. Turn pointer visibly laggy at 400ms turn intervals.

Fix: scoped transition properties. opacity/transform/filter/border-color = 1s
for fade/death animations. box-shadow = 300ms for current-turn ring highlight,
so turn pointer snaps between participants while fades stay smooth.
2026-07-06 22:48:10 -04:00
david raistrick dbe018825b Merge remote-tracking branch 'upstream/main' into single-source 2026-07-06 22:39:57 -04:00
david raistrick 3b75ec9b3d Animate player display inactive transitions and death state
Player display now tracks displayParticipants separately from raw encounter
participants so active->inactive can animate out before removal, and inactive->
active can animate in. This preserves 1-list order, keeps DM display behavior
unchanged, and avoids fading dying/stable participants.

PlayerParticipantCard handles transitions:
- active->inactive: scale/slide/fade out, then parent removes from display list
- inactive->active: scale/slide/fade in
- alive->dead: keep visible with dim/desaturate/red-rim death cue + skull pulse
- dying/stable: full visible, no fade/removal

DisplayView inactive characterization updated to wait for exit animation before
asserting removal. TODO item removed.
2026-07-06 22:38:33 -04:00
robertandClaude Fable 5 6991375afc Move Crit button inline with HP controls; fix death save button icon alignment
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:13:31 -04:00
david raistrick 055895875a Dev-only bulk delete all campaigns; deleteCollection SQL bulk + gates
Feature: debug button to wipe all campaigns/encounters/logs in dev builds.
Previously bulk delete fetched all logs per campaign, client-filtered,
batchWrite — 30s+/campaign. Now SQL bulk DELETE, no fetch.

Server (server/db.js, server/index.js):
- deleteCollection(collPath, {where}) — SQL DELETE FROM docs WHERE parent=?,
  optional where-filter. Broadcasts deletions to WS subscribers.
- DELETE /api/collection endpoint
- Gate: ALLOW_DEV_ENDPOINTS=1 env OR createServer({allowDevEndpoints:true})
- createServer accepts allowDevEndpoints param (tests bypass env)

Storage (src/storage/server.js, src/storage/firebase.js):
- deleteCollection(path, whereField, whereValue) both adapters
- Firebase: fetch matching + batch-delete (firestore no bulk), 500-chunk
- Gate: throws if NODE_ENV not development/test
- Contract-tested both backends

App (src/App.js):
- deleteCampaignCascade refactored (reusable, no try/catch split)
- handleDeleteAllCampaigns: Promise.all per campaign, deleteCollection for
  encounters (no fetch), deleteCollection logs once globally, parallel
- Button dev-gated (NODE_ENV), confirm modal, hidden when no campaigns

Mock fixes (surfaced by new tests):
- firebase firestore mock: added where() export, getDocs applies constraints
  (was returning all docs ignoring query constraints — pre-existing gap)

Tests:
- contract: deleteCollection (bulk, where-filter, empty) both backends
- server-contract: live deleteCollection (bulk, where, 403 gate)
- runStorageContract via makeStorage({allowDevEndpoints:true})

Safety (3 layers):
- UI button hidden in prod (NODE_ENV gate)
- storage method throws in prod (NODE_ENV gate)
- HTTP endpoint 403 in prod (env/param gate)
2026-07-06 19:06:52 -04:00
david raistrick 532af1ecc4 Populate campaign players array in combat replay and scenario
Both combat.js replay and Combat.scenario.test.js built character participants
inline via buildCharacterParticipant without persisting to campaign doc. Real
app stores chars in campaign doc players array; encounter participants built
from that. Replay/scenario diverged from real flow — campaign had zero chars.

Fix:
- replay.js: roster chars now have id, persisted to campaign doc players array
  at setup_campaign step
- Combat.scenario.test.js: addCharacterViaUI writes char to campaign doc
  players array (mirrors app), CAMPAIGN_PATH const added
2026-07-06 18:48:29 -04:00
david raistrick 47056788e8 Add Crit Damage button for dying/stable participants
5e crit-at-0-HP rule: any crit damage while downed = +2 death-save failures.
Shared logic supported via applyHpChange(..., { isCriticalHit: true }) but UI
had no trigger. Normal Damage at 0 only added 1 fail.

handleCritDamage handler calls applyHpChange with isCriticalHit: true.
Button renders in HP block (right-aligned) for character/NPC participants
when dying or stable and combat started. Dead excluded (no-op).
2026-07-06 18:38:49 -04:00
david raistrick 6fe4a27dd5 Show stable regain hint in DM view for stable+unconscious participants
D&D 5e: stable creature regains 1 HP after 1d4 hours. Static display-only
reminder, no storage/time-tracking. Fires only for participants with
status=stable (dying->stable or dead->revive), shown where Revive button
would sit. Derived from status, survives refresh.
2026-07-06 18:34:50 -04:00
david raistrick c80ac6882f Exercise death-save edge cases in combat replay and scenario tests
Replay (scripts/combat/replay.js):
- Merchant now type=npc via asNpc (was stale isNpc)
- Death-save eligibility keyed on type (character|npc), not removed isNpc
- Rotate outcomes success/fail/nat1/nat20 (was success-only)
- Import stabilizeParticipant, reviveParticipant
- Between-round revive: dead -> reviveParticipant, stable -> heal,
  inactive -> reactivate (heal was no-op on dead, leaving active-dead monsters)
- Fix deathSave return unwrap: it returns {enc, status}; callStep runner
  must extract .enc or state corrupts (round undefined, combat auto-ends)
- describe() fix: death-save arg is outcome, not type

Scenario (src/tests/Combat.scenario.test.js):
- Import stabilizeParticipant, reviveParticipant
- applyDamage accepts options (crit at 0 HP)
- Add stabilizeAction, reviveAction helpers
- addAllCharacters now actually exercises batch add (was no-op)
- New deterministic edge-case test: monster death (dead+inactive),
  NPC nat20, NPC nat1+damage+revive+heal, character stable->damage->crit,
  massive damage
2026-07-06 17:10:01 -04:00
david raistrick 1d4ec873dc Implement D&D 5e death-save state machine and cleanup combat ordering
- Add status-driven death-save model:
  - status: conscious | dying | stable | dead
  - deathSaveSuccesses/deathSaveFailures as display counters
  - remove old death-save fields as source of truth
- Add death-save actions:
  - success, fail, nat1, nat20
  - stabilize participant
  - revive dead participant to 0 HP, stable, unconscious
- Apply 5e HP transition rules:
  - characters and NPCs at 0 HP become dying
  - stable/dying participants gain unconscious condition
  - healing clears death saves and returns conscious
  - massive damage kills
  - non-NPC monsters at 0 HP become dead and inactive
- Split NPCs from monsters with type="npc":
  - NPCs use death saves like characters
  - NPCs remain DM-controlled/monster-colored in UI
  - new NPCs no longer persist isNpc flag
- Keep dead PCs/NPCs in encounter and initiative until DM removes or deactivates
- Allow DM active/inactive toggle for dead participants
- Hide inactive participants from player display
- Improve DM and player death-state UI:
  - show Dying/Dead/Unconscious states consistently
  - add revive button for dead participants
  - distinguish dead visual from inactive visual in DM view
- Fix initiative drag/reorder behavior:
  - downward drag now changes order instead of no-op
  - paused combat can reorder across current turn pointer
  - turnOrderIds stay synced to participants order
- Persist campaign collapse state in localStorage
- Update death-save docs and encounter builder docs
- Add/expand tests for:
  - death saves and HP transitions
  - dead/active/inactive behavior
  - NPC death-save behavior
  - player display visibility
  - drag reorder semantics
  - logging expectations

Tests:
- npm run test:all
- app: 94 passed
- shared: 158 passed, 5 skipped
- server: 32 passed
2026-07-06 16:48:50 -04:00
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
david raistrick 9f8b09c7d9 feat(ui): first-class undo/redo buttons in combat controls
Undo/redo pills in InitiativeControls, always visible when encounter open.

- Undo = latest non-undone log for this encounter, applies undo.updates.
- Redo = latest undone log, applies undo.redo (forward patch).
- encounterPath added to all 14 logAction contexts (filter key).
- redo:patch added to undoData so redo replays real forward state.
- Disabled when stack empty. Tooltip shows target action message.
- Uses current 2-write undo (non-tx). Race safety deferred to FEAT-LOG.

TODO updated. 84 FE tests green.
2026-07-04 22:48:58 -04:00
david raistrick 9ef3e3bd9e feat(ui): filter already-added chars from participant dropdown
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.
2026-07-04 22:28:41 -04:00
david raistrick 3423319b9b feat(ui): replace all alert() with toast + info modal
Native alert() vanished instantly (browser focus loss when player display
window or devtools steals focus). Replaced all 23 alert() calls with two
in-app feedback paths via React context:

- ToastStack: bottom-right stack, 6s auto-dismiss + manual X. For transient
  failures (storage throws, catch blocks).
- InfoModal: yellow notice modal with OK button, persistent until dismissed.
  For validations (dup-add, empty/invalid fields).

UIFeedbackProvider wraps all 3 App branches (admin/player/logs).
useUIFeedback() hook in 6 components (CharacterManager, ParticipantManager,
InitiativeControls, EncounterManager, LogsView, AdminView).

244 tests green (App 84, shared 133, server 27).
2026-07-04 22:27:32 -04:00
david raistrick 7bcf01dcf9 fix: custom conditions apply on add; correct server WS env docs
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.
2026-07-04 20:41:34 -04:00
david raistrick 1ad4b660a4 feat: custom conditions per campaign
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.
2026-07-04 18:20:32 -04:00
david raistrick 9c90c1500c feat(turn): D&D 5e death saves — success/fail tracking
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.
2026-07-04 17:56:14 -04:00
david raistrick 32b8dfd8a1 fix(turn): log addParticipants + updateParticipant (logging gaps)
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.
2026-07-04 17:25:22 -04:00
david raistrick ab9f8fa2e7 fix(turn): log reorder + deathSave (BUG-7)
reorderParticipants 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.
2026-07-04 17:22:52 -04:00
david raistrick 8ad750c60a test: add 10s timeout guard across all suites
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'.
2026-07-04 17:03:34 -04:00
david raistrick 4da5ed9bcc Merge remote-tracking branch 'upstream/main' into single-source
# Conflicts:
#	src/App.js
2026-07-04 16:43:14 -04:00
david raistrick c556860e49 refactor(App): remove dead SDK imports (BUG-17)
App.js imported 9 Firestore SDK functions it never called standalone:
doc, setDoc, addDoc, collection, onSnapshot, updateDoc, deleteDoc, query,
writeBatch. All writes go through storage adapter (storage.*). Auth pieces
(initializeApp, getAuth, signInAnonymously, onAuthStateChanged,
signInWithCustomToken, getFirestore) stay — real SDK init in firebase mode.

orderBy/limit stay — now neutral builders from index.js (prev commit).
2026-07-04 16:07:21 -04:00
david raistrick 27b2272ced fix(storage): neutral queryConstraints honored by both adapters
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.
2026-07-04 16:06:28 -04:00
david raistrick e94e1959ff refactor: rename ws adapter to server across codebase
'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.
2026-07-04 15:47:13 -04:00