Chrome Android forces the root URL into PWA installation when required
manifest icon files exist. PWA names come from the manifest and cannot be
edited, which breaks workflows using multiple tracker copies.
Keep required 192/512 icon assets intentionally unavailable by committing
generated files with .png-not suffixes. Removing the manifest link does not
restore root shortcut naming; Chrome still installs the root as a PWA.
Chrome handles /display differently: even with a working manifest it offers
Install app or Create shortcut, and Create shortcut permits renaming. Keep
both manifests intentionally non-installable for consistent user-controlled
shortcut names.
Also add separate player-display favicon/logo artwork and swap /display browser
favicon to player-favicon.ico. Keep generated standard/player logo sources
under renamed inert filenames.
- Add wake lock and browser fullscreen controls to encounter header/popout
- Re-acquire wake lock after browser release (still doesnt work on tablet)
- Retry fullscreen after Android unlock; keep manual control visible
- Add larger labeled character Save button
- Use distinct panel icon for encounter full-page expansion
- Add strong active/inactive, conditions-open, and editing button states
- Keep inactive participant controls visible
- Add regression tests and update affected characterization tests
- Update project TODO notes
- Recursively lint all production JS/JSX under src/ and shared/
- Exclude tests, mocks, and node_modules from static guard
- Update TODO status
- Exclude TODO.md from Docker build context
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).
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.
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.
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.
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.
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.
Match campaigns rollup pattern. Title button toggles collapse (chevron
left, like campaigns). Character count in header. Collapse state persisted
to localStorage key ttrpg.charactersCollapsed.
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.
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
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).
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.
Root cause: mutation writers stored oldValues only. Redo rebuilt from
current state via derived logic — wrong order, wrong content, or no-op.
Undo patched fields in place but ignored roster/array order changes.
Writers now store forward arrays (shared/turn.js):
- addParticipant: newTurnOrderIds, newCurrentTurnParticipantId
- removeParticipant: newTurnOrderIds, newCurrentTurnParticipantId
- updateParticipant: oldTurnOrderIds + newTurnOrderIds (initiative re-slot)
- reorderParticipants: newParticipants + newTurnOrderIds
- damage/heal/deathSave/stabilize/revive/deactivate_dead_monster:
oldValues + newValues both capture full death state incl conditions
expandUndo redo uses stored forward state instead of deriving:
- add/remove: order via newTurnOrderIds
- update: initiative change re-orders both directions
- reorder: re-applies newParticipants
- death ops: restore via newValues (was HP-only, dropped status/conditions)
Missing expandUndo cases added: stabilize, revive, deactivate_dead_monster
(were default:null -> undo no-op).
Test infra (shared/tests/_helpers.js):
- Mock storage undo() real now: applies patch + flips undone flag (was no-op)
- addDoc persists log entries, getCollection returns them
- undoLast/redoLast harness helpers exercise real mechanism
- Undo tests assert against actual persisted doc, not expandUndo output
turn.undo.test.js rewritten: every op tested as undo->deepEqual before,
redo->deepEqual after-op (12 cases). Was undo-only, redo untested.
turn.deathsave.undo.test.js added: 11 death-path roundtrips.
Dead code removed:
- round-trip.test.js: skipped suite testing deleted replay-from-logs.js
(5 skipped tests rotting). Coverage gap acknowledged in TODO.
Skip-guard added (scripts/run-tests.sh): pre-flight grep refuses to run
if any .skip/xdescribe/xit found in test dirs. No CI; this is the gate.
- 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
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.
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.
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.
'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.
All 5 storage.setDoc(activeDisplay, {...}, {merge:true}) →
storage.updateDoc(activeDisplay, {...}).
setDoc merge:true worked in prod (firebase honors merge) but ws adapter
+ mock ignore opts arg entirely → clobbers doc. updateDoc uses PATCH
across all adapters (firebase real updateDoc, ws PATCH endpoint, mock
merge). Consistent, no clobber.
Sites fixed:
- hidePlayerHp toggle
- startEncounter (set active ids)
- endEncounter (null active ids)
- deactivate active display
- activate new display
TDD: HideHpToggle.test RED first (assert updateDoc patch, impl still
setDoc → 0 calls found). GREEN after switch.
Char tests updated: Encounter.characterization (2) + Combat.characterization
(2) assert updateDoc on activeDisplay, not setDoc.
BUG-4: prod was already fixed (merge:true), test was RED due to mock
ignoring opts. Now all 3 adapters consistent via updateDoc.
Reslot (stable sort by init desc, tie-break = original array index) now
fires on all 4 paths that can change order:
1. Add participant — sortParticipantsByInitiative([...parts, new], parts)
2. Edit modal save (handleUpdateParticipant) — reslot + syncTurnOrder
3. Drag reorder — splice move (already correct, untouched)
4. Inline init field — reslot (already committed 08c27c1)
Before: add appended (ignored init), edit modal overwrote value without
moving slot. Both caused list order to drift from init order until
startEncounter (sorts once). Now any init change immediately reslots
into correct position. Display + AdminView reflect order.
Stable sort preserves drag order within ties (tie-break = original index
= reflects prior drag). Move-one semantics: only changed element moves.
EditParticipantModal: added htmlFor/id link on Initiative label (was
missing — a11y + testable).
Tests: ReslotAllPaths.test.js (2). RED first (add appended, edit modal
no reslot), green after impl.
Add form: optional initiative field (monster + character). Empty = roll
d20+mod (current behavior). Filled = use value, skip roll. 'blank=roll'
hint + 'auto' placeholder for clarity.
Inline edit: ALL participants. Number input in participant row. Blur or
Enter commits. Capped 2 digits (max 99). Auto-select on focus for quick
overwrite. Styled to match other fields (border-stone-700, rounded-md,
shadow-sm, w-10).
handleAddParticipant: manualInit detects set value. lastRollDetails
adapts display (manual flag shows 'Set initiative' vs 'Rolled d20').
Campaign card date: Clock icon + 'Created:' prefix, own row, muted
stone-300 opacity-70. Was crammed inline with character/encounter counts.
Tests: InitiativeField.test.js (2 tests - set value + empty=roll).
RED first (field missing), then green after impl. App + Participant
characterization still green (18 total).
Note: inline init edit does NOT re-sort. Known followup — displays + list
order must reflect changed initiative. Tracked separately.
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.
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.