208 Commits
Author SHA1 Message Date
david raistrick dc0a1f1633 fix: preserve user-renamable Android shortcuts
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.
2026-07-15 13:50:53 -04:00
david raistrick 119d252d02 feat: improve tablet screen controls and participant action states
- 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
2026-07-15 12:30:07 -04:00
david raistrick 30712f0e1e chore: strengthen lint coverage and update project housekeeping
- 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
2026-07-14 22:38:44 -04:00
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
robert 5c062bf944 Merge pull request 'Generic (non-5e) ruleset mode + UI polish + combat switch fix' (#7) from feat/generic-ruleset into main
Reviewed-on: #7
2026-07-08 22:54:03 -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 9870d4df0d Fix addParticipants: slot by initiative instead of append
addParticipants (bulk add all characters) appended to list end, ignoring
initiative. Pre-combat list showed random order until start encounter.
Now slots each participant by initiative desc, preserves existing order +
drag ties. Matches addParticipant single-add semantics.

Tests: 5 bulk-add slot cases (turn.bulkadd.test.js).
2026-07-07 16:07:20 -04:00
david raistrick 43b1f6ce41 WIP: add ttrpg-encounter-builder skill + server-mode API docs
UNTESTED work in progress. Do not assume correct.

Scope: SERVER BACKEND ONLY (SQLite/Express, REACT_APP_STORAGE=server).
Does NOT work with Firebase SDK mode — no HTTP backend there, different
transport/auth. Skill + doc explicitly call this out.

- .agents/skills/ttrpg-encounter-builder/: harness-agnostic skill
  (pi/claude/codex via .agents/skills + symlinks). SKILL.md + helper
  script that batch-writes encounters via REST, rolls initiative, verifies.
- docs/ENCOUNTER_BUILDER.md: add Path normalization section, Build flow
  (API/scripts) section with REST endpoint table, object templates,
  recipe. Server-mode-only caveat noted.

Helper script syntax-checked + campaign lookup verified against running
instance, but full seed flow not regression-tested against test suite.
2026-07-07 15:42:02 -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 f3ed971592 Merge remote-tracking branch 'upstream/main' into feat/generic-ruleset 2026-07-07 14:53:42 -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
robertandClaude Opus 4.8 76f5dc09ab Trigger Portainer stack webhooks after image push
Adds a main-only Redeploy stage that POSTs to two Portainer stack
webhooks (re-pull + redeploy) after a successful push. URLs are read
from Secret-text credentials so they stay out of the repo and logs.
Gated by the REDEPLOY_PORTAINER parameter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 13:57:47 -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
robertandClaude Opus 4.8 7955bed4a9 Add Jenkins CI and Portainer stack for image deployment
Jenkinsfile builds and pushes both the firebase (nginx/static) and
sqlite (caddy+node) images to thinkserver:5000, guarded to main only.
docker/portainer-stack.yml deploys the prebuilt sqlite image from the
registry behind the external TLS-terminating nginx.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 12:43:46 -04:00
robert 66f3cc1380 Adding Jenkinsfile to simplify my deployment. 2026-07-07 12:13:16 -04:00
robert 19de9fcf75 Merge pull request 'Player display animations, death visual, upstream merge' (#5) from single-source into main
Reviewed-on: #5
2026-07-07 11:21:23 -04:00
robert c41a8aa994 Merge branch 'main' into single-source 2026-07-07 11:21:12 -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
keen e191aa5ad2 Merge pull request 'Fix white /logs page in firebase mode: emulate offset in adapter' (#6) from white-log-page into main
Reviewed-on: #6
2026-07-06 23:32:37 -04:00
david raistrick c544bc305a Merge branch 'pr-6' into single-source 2026-07-06 23:24:25 -04:00
david raistrick 835a4663f7 Docker build cache: .dockerignore, cache mount, layer reorder, restart script
- .dockerignore: exclude node_modules/.git/tmp/data/logs
- Dockerfile: BuildKit cache mount for npm, --omit=dev at install, drop
  npm prune step
- Reorder layers: shared/src/frontend copy before build, server copy +
  better-sqlite3 rebuild after. Server/shared changes no longer invalidate
  frontend build layer unless shared (FE dep) changes
- Remove duplicate shared COPY in runtime stage (bundled in FE build)
- docker/restart.sh: stop + rebuild + start in one script
2026-07-06 23:22:19 -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
robert 3646a9cf8e Merge pull request 'D&D 5e death-save state machine, undo/redo fixes, dev bulk delete' (#4) from single-source into main
Reviewed-on: #4
2026-07-06 22:14:05 -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
robert 77570097f8 Merge branch 'main' into single-source 2026-07-06 22:01:24 -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 bf1fccfd3b Exclude pause/resume from undo; fix verify round-wrap false positive
togglePause no longer writes log entry (shared/turn.js). Lifecycle op, not
player action. Undo stack now targets last real action, not flag flip.
Removed dead pause/resume cases from expandUndo.

verify.js false positive: round wrap via non-nextTurn event (removeParticipant)
not detected — cycleActed never reset, actors flagged as acted_twice. Fix:
detect round change on ANY event, drain removed/inactive before finalize
(avoids false skipped when actor removed mid-round).

Tests updated: togglePause logging test asserts no log; undo test asserts
excluded. Repro confirmed via minimal verify case (removeParticipant r1->r2).
2026-07-06 18:30:51 -04:00
david raistrick 2c997de0da Fix undo/redo forward-state restore, harden test infra, skip-guard
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.
2026-07-06 18:09:28 -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
robert 8354d21fc6 Merge pull request 'Single source of truth: combat logic, storage parity, slot ordering' (#3) from single-source into main
Reviewed-on: #3
2026-07-05 00:07:56 -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 ed1783e419 docs: reorg TODO — merge FEAT-2+M6 into FEAT-LOG, add undo/redo UI + Is NPC clarify 2026-07-04 22:36:15 -04:00
david raistrick 6fe2b762ab docs: update TODO — toast/modal + dropdown filter done 2026-07-04 22:30:33 -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 f2797595f4 test: static guard — every mutation logged
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.
2026-07-04 17:26:59 -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 5a09f71d4c refactor(turn): remove dead computeTurnOrderAfterAddition
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.
2026-07-04 17:18:29 -04:00
david raistrick 942545c085 fix(turn): block cross-pointer reorder (BUG-13)
reorderParticipants allowed dragging across current pointer mid-round.
nextTurn walks turnOrderIds forward from pointer, so cross-pointer drag
= ambiguous who-acts-next: upcoming dragged ahead of pointer = skipped
(walk wraps past), acted dragged behind pointer = double-act.

Full fix needs actedThisRound tracking. Pragmatic now: block both
directions during active encounter. Pre-combat: free reorder (no pointer).

reorderParticipants: added pointer check (currentTurn index). If drag
crosses pointer, return null patch (no-op). Same-side reorder allowed.
syncTurnOrder now always mirrors (was started-only — pre-combat reorder
left turnOrderIds stale).

Tests:
- turn.bug13.test.js: cross-pointer blocked (both dirs), same-side allowed,
  pre-combat free.
- turn.reorder.test.js: 3 tests updated to pre-combat (cross-pointer now
  blocked post-start).
- turn.invariant.test.js: reorder test uses same-side upcoming pair.

214 tests green.
2026-07-04 17:12:13 -04:00
david raistrick 0ff50561c7 test: BUG-10 deact+reactivate double-act (already fixed by 1-list model)
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.
2026-07-04 17:06:07 -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 81a1629118 docs: rewrite TODO clean — closed items to history, open items current
Cross-referenced every item vs actual code state. 11 items closed (moved to
history): BUG-4/5/6/8/11/12/14/15/16/17/18, FEAT-1, add-all-characters,
warning=failure, scenario-100-rounds.

Open (7): BUG-7 (reorder undo), BUG-10 (deact/reactivate double-act),
BUG-13 (cross-pointer reorder), FEAT-2 (structured logs), FEAT-3 (initiative
field), FEAT-M6 (transactional undo), death-saves-fails, custom-conditions,
test-timeouts.
2026-07-04 16:59:52 -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
david raistrick cb41d9ec8f fix(turn): slot-not-sort in add/update; static guard against stray .sort()
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.
2026-07-04 15:40:39 -04:00
david raistrick 6baecd374e ws adapter: match main truth (id injection + setDoc merge)
- 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.
2026-07-04 12:35:28 -04:00
david raistrick 79af61cb8b match main firebase behavior; fix contract + mismatches
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.
2026-07-04 12:22:19 -04:00
david raistrick b12ac904a6 test: add storage coverage (firebase contract, factory), silence scenario log
- 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).
2026-07-03 18:53:58 -04:00
david raistrick 7f60ec140e fix(scenario): run 100 rounds not turns, exercise all combat paths
Previous test lied: called nextTurn 100x = ~100 turns = ~12 rounds (see
docs/GLOSSARY.md turn vs round). Now loops by actual round-wrap count.

100 rounds = 975 turns verified via console log + expect(roundsDone).toBe(100).

Exercises ALL shared combat paths deterministically (vs replay random):
startEncounter, nextTurn, togglePause, addParticipant, addParticipants,
updateParticipant, removeParticipant, toggleParticipantActive, applyHpChange
(damage+heal), deathSave (1/2/3-success → isDying path), toggleCondition,
reorderParticipants (drag same-init tie), endEncounter, activateDisplay,
clearDisplay, toggleHidePlayerHp.

Rotation integrity checked every 10 rounds: no dup turnOrderIds, currentTurn
in order, turnOrderIds === participants.map(id).

Run time: 19ms (was 72s React, lieing).
2026-07-03 18:29:35 -04:00
david raistrick e650cd2710 refactor: single-source display lifecycle + scenario test no React
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.
2026-07-03 18:26:02 -04:00
david raistrick d83d1383c0 refactor: single-source combat logic (App handlers call shared, slot model)
App.js no longer inlines combat logic. All handlers delegate to @ttrpg/shared:
startEncounter, nextTurn, togglePause, endEncounter, addParticipant(s),
updateParticipant, removeParticipant, toggleParticipantActive, applyHpChange,
deathSave, toggleCondition, reorderParticipants. ONE code path for UI.

shared/turn.js aligned to slot model (docs/INITIATIVE_ORDERING.md):
- addParticipant: splice into slot by initiative (no sort)
- updateParticipant: re-slot on initiative change (no sort)
- reorderParticipants: same-init drag only (cross-init blocked)
- applyHpChange: death flips isActive=false (matches App), revive reactivates

No renames, no API changes. Shared func signatures unchanged.

Tests updated to match unified behavior:
- dead-skip: death deactivates (was FEAT-1 stays active)
- characterization: death deactivates + revive reactivates
- reorder/invariant: cross-init drag blocked, same-init allowed

Both suites green: shared 91/91, App 77/77, 0 warnings.
2026-07-03 17:59:41 -04:00
david raistrick c3d89554e8 docs: initiative ordering design (slot, never sort)
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.
2026-07-03 17:18:00 -04:00
david raistrick d1cc3a8b9a test: warning=failure guard + fix ambiguous switch selector
- 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.
2026-07-03 16:05:04 -04:00
david raistrick 31d19c5912 test: kill act env warnings + remove debug console noise
Root cause: mock _notify cb fires during waitFor poll, but testing-library
asyncWrapper flips IS_REACT_ACT_ENVIRONMENT=false. Raw react act() then
warns 'not configured to support act' (146 warnings).

Fix: _notify sets globalThis.IS_REACT_ACT_ENVIRONMENT=true around act(),
restores after. setupTests sets flag globally. Both together = 0 warnings.

Also:
- Remove 4 debug console.log from App.js (encounter start/end, drag, add chars)
- Remove always-fires scenario summary console.log
- Add fastWaitFor helper (5ms poll) to testHelpers
- Swap scenario waitFor -> fastWaitFor

Tests: 77 pass / 0 fail / 0 warnings / 0 console noise.
2026-07-03 15:59:14 -04:00
david raistrick 530aaadf90 test: kill act env warnings (globalThis flag + _notify act wrapper)
- setupTests.js: set globalThis.IS_REACT_ACT_ENVIRONMENT = true (RTL reads
  getGlobalThis, not global). Kills 'environment not configured for act'.
- _mock-db.js _notify: set flag true around subscriber cb, restore prev
  after. RTL waitFor flips flag false mid-poll -> act() warned. Now clean.
- 146 act warnings -> 0.
2026-07-03 15:58:50 -04:00
david raistrick 1a744e511a test: upgrade @testing-library/react v14, kill act warnings
@testing-library/react v13 -> v14. v13 used deprecated
ReactDOMTestUtils.act on every render -> 708 deprecation warnings.
v14 imports act from react directly. Drops to 0.

Mock _notify (src/__mocks__/firebase/_mock-db.js): wrap subscriber
callbacks in act(() => cb()). Sync setState from subscribe callbacks
fired outside test act boundary -> 14 warnings. Now 0. try/catch
fallback if react/act unavailable (defensive).

Full suite: 166 pass / 1 fail (Combat.scenario BUG-11 pre-existing
crash). Warnings: 1416 -> 0.

Note: NOT complete kill-shared. App.js still inlines 8 logic fns
(startEncounter, nextTurn, togglePause, endEncounter, addParticipant,
updateParticipant, reorder/drag). turn.js versions dead in app, used
by replay + turn tests only. Next: make handlers call turn.js.
2026-07-03 15:46:27 -04:00
robert 8cf3dad5b6 Merge pull request 'Rework backend' (#2) from rework-backend into main
Reviewed-on: #2
2026-07-03 08:35:32 -04:00
david raistrick 3d0dc79206 feat(UI): hide NPC/monster HP toggle (player display)
Mirrors hidePlayerHp. New hideNpcHp flag on activeDisplay doc.
- AdminView: 'Hide NPC/monster HP' switch in Player Display settings.
- DisplayView: HP bar gated by !(hideNpcHp && p.type !== 'character').
  Covers monsters + NPCs (all non-player).
- Default false (show NPC HP) — opposite of player HP default true.

updateDoc patch (BUG-4 pattern). No clobber.

Test: HideHpToggle selector scoped to 'hide player hp' (now 2 switches).
2026-07-02 11:29:02 -04:00
david raistrick 4b8b8bccfb feat(UI): collapsible Campaigns section
Campaigns heading -> clickable button. ChevronDown expanded,
ChevronRight collapsed. Toggles grid visibility. Shows count
(e.g. 'Campaigns (61)'). Preserves Create Campaign button.

State: campaignsCollapsed in AdminView. aria-expanded/controls for a11y.
2026-07-02 11:17:04 -04:00
david raistrick c0998da0a7 fix(BUG-4): updateDoc patch on activeDisplay (not setDoc replace)
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.
2026-07-01 23:32:23 -04:00
david raistrick d00cc104c9 feat(FEAT-3): reslot on all participant mutation paths
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.
2026-07-01 23:00:40 -04:00
david raistrick 36d7186a54 scripts: dev-start/dev-stop for local stack (backend+frontend)
dev-start.sh: starts node backend (better-sqlite3, :4001) + react frontend
(ws storage mode, :3999). Uses absolute DB_PATH to avoid workspace cwd
ambiguity (npm run server:dev runs in server/ subdir). Idempotent — skips
ports already in use.

dev-stop.sh: kills procs on :3999/:4001, sweeps node --watch + react-scripts.

Both write tmp/*.log + tmp/*.pid for debugging.
2026-07-01 22:55:37 -04:00
david raistrick 08c27c1ca5 feat(FEAT-3): reslot on inline init change + gate field
Reslot: handleInlineInitiative now sorts participants[] by init desc
(stable, tie-break original index) via sortParticipantsByInitiative.
Display + AdminView reflect new order after init edit. Not a blind
re-sort — only moved element changes position.

Gate: inline init field disabled when combat active + not paused.
Matches drag gating. DM must pause to edit initiative mid-combat.

Tests: InitiativeReslot.test.js (2). RED first (no reslot, Goblin stayed
at idx 1), green after impl (reslots to idx 0). Field gate test.
2026-07-01 22:29:38 -04:00
david raistrick 0514939c51 feat(FEAT-3): initiative first-class field (add + inline edit)
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.
2026-07-01 22:25:52 -04:00
robertandClaude Sonnet 5 ec578eeef5 Bump to v0.4, document self-hosted backend in README
The rework-backend merge added an optional self-hosted Express/ws/SQLite
backend (npm workspaces, single-container Docker deployment) alongside
the existing Firebase default. Bump APP_VERSION and refresh README to
cover both storage modes and the new repo layout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 19:59:46 -04:00
robertandClaude Sonnet 5 c54fd88c32 fix(docker): copy workspace package.json files before npm install
npm workspaces needs shared/package.json and server/package.json present
at install time to link @ttrpg/shared, otherwise the build stage fails
with "Module not found: Error: Can't resolve '@ttrpg/shared'".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 19:40:23 -04:00
robert e31fe15382 Merge pull request 'Rework backend' (#1) from rework-backend into main
Reviewed-on: #1
2026-07-01 19:29:33 -04:00
david raistrick a2c63cc77f docs(REWORK_PLAN): M5 done, PRable
M5 Docker: single container (caddy+node) verified working. REST roundtrip,
WS push, 20-round replay CLEAN, UI styled. Done.

PRable: separate docker/ tree, root Dockerfile untouched, firebase
default preserved (STORAGE=firebase). Friend merges, gets our docker
infra without touching his firebase path.

M0-M5 all done.
2026-07-01 19:26:23 -04:00
david raistrick e22f412c52 fix(docker): drop image: field so compose never pulls service image
Image: ttrpg-app:local named a registry image. Without --build flag,
compose tried pull first -> 'pull access denied' (private, unpublished).
Then fell back to build. Confusing error.

Removed image: field. Compose now auto-names (docker-app), always builds
local, never attempts registry pull. Base images (node/caddy) pull once
on first build, then cache. No pull_policy needed.

docker compose up (from docker/ dir) now works clean.
2026-07-01 19:22:31 -04:00
david raistrick 4406fd2045 docs: ENCOUNTER_BUILDER + TESTING guides for LLM session handoff
ENCOUNTER_BUILDER.md: DM interface — entity model (campaign/encounter/
participant), build flow (campaign→chars→encounter→participants), combat
controls (start/next/pause/HP/deathsaves/conditions), player display,
1-list turn order model, storage paths quick-ref.

TESTING.md: test+automation ops — commands, suites (90+24+66+4), layers
(L1 mock vs L2 live backend), types, TDD discipline, replay tool,
analyze-turns.js, audit tools, docker stack (single caddy+node container),
dev servers, storage modes, known RED backlog.

Both aimed at another LLM session picking up repo. DEVELOPMENT.md
cross-refs updated.
2026-07-01 19:16:12 -04:00
david raistrick 81c0b26b71 fix(docker): copy tailwind+postcss config so CSS compiles in build
Build produced 308-byte stub CSS (raw @tailwind directives, unprocessed)
instead of real 27KB compiled stylesheet. Dockerfile missed copying
tailwind.config.js + postcss.config.js into build context. Page rendered
as unstyled white text.

Added COPY for both configs. Rebuild: CSS now 27146 bytes. App styled.
2026-07-01 19:07:21 -04:00
david raistrick da25f46e3e fix(docker): single container (caddy+node), ESM adapters fix blank page
Docker: moved all docker files to docker/ tree (was conflated with
upstream Dockerfile at root + server/Dockerfile). Single container now:
caddy (front, serves static + proxies /api /ws) + node backend (internal
:4001). Node never exposed. entrypoint.sh runs both. Compose: one service.

Blank page root cause: storage adapters had inconsistent module systems.
firebase.js = ESM (export). ws.js + memory.js = CJS (module.exports).
CRA prod build = ESM strict -> CJS runtime crash, blank root. Dev mode
lenient, masked bug. First ws prod build (docker) = first exposure.
Never dev/prod split intended; just inconsistency from M2 era.

Fix: all adapters ESM. ws.js lazy-loads 'ws' pkg via dynamic import()
(Node/jest only; browser uses global WebSocket). index.js static
imports. server jest: added babel.config.js (preset-env, node target)
to transform ESM for jest.

Test: src/tests/StorageEsm.test.js — 4 tests grep all adapters for
module.exports / require(). Regression guard catches CJS leak.

Verified: docker page renders (root 4534 chars, UI visible).
server 24 green, shared 90 green, FE ESM 4 green.
2026-07-01 19:03:59 -04:00
david raistrick c1d982b4a4 fix(BUG-8): ws adapter auto-reconnect after drop
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.
2026-07-01 18:26:42 -04:00
david raistrick afdd72e829 fix(analyzer): match new 'round N starting' marker
Replay marker changed 'complete'→'starting' (commit d734057). Analyzer
regex only matched 'complete' = 0 rounds parsed. Now matches both.

6 rounds parse, skips only in truncated final round (incomplete run).
2026-07-01 17:36:17 -04:00
david raistrick 58ae04b400 fix(BUG-15): DisplayView no longer re-sorts participants by initiative
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.
2026-07-01 17:31:40 -04:00
david raistrick d73405753a fix(replay): round markers align with turn-line round labels
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.
2026-07-01 17:21:55 -04:00
david raistrick 3b07fc27b0 docs(TODO): add BUG-13 (reorder cross-pointer), BUG-14 (addParticipant post-drag)
BUG-12 marked done (selection follows activeDisplay).
2026-07-01 17:19:31 -04:00
david raistrick af165f4491 fix(replay): no-op + pointer-crossing reorder picks
Replay reorder picker used living[0]→living[1] (HP-sorted). Wolf(20)
already before Merchant(19) = no-op drag. Fired every 8 turns = UI
animated drag, nothing changed = visual funk. 3 useless Wolf→Merchant
drags in round 16-17 log.

Also fixed pointer-cross: old picker dragged arbitrary pair. If swap
crossed current pointer → ambiguous who-acted semantics (skip/double).

New picker: swap two ADJACENT UPCOMING actors (both strictly after
current pointer). Always real move, never crosses pointer.

13-round replay: 0 skips, 0 double-acts, 0 order shifts (was 2 skips,
4 double-acts with arbitrary swaps).

Note: reorderParticipants itself has no pointer logic — pure drag.
Crossing pointer behavior in real app untested (potential BUG-13).
2026-07-01 17:14:06 -04:00
david raistrick dbd0c75792 fix(BUG-12): campaign selection follows activeDisplay
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.
2026-07-01 17:05:00 -04:00
david raistrick 750ee99080 feat: display campaign createdAt in UI card
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).
2026-07-01 16:41:17 -04:00
david raistrick 313a897e4b docs: TODO update — 1-list model done, BUG-6 fixed, FEAT-3 backlog
Mark 1-list turn order model DONE (architecture section). BUG-6 fixed
structurally. BUG-5 reaffirmed (held under 1-list). Pipeline updated.
Add FEAT-3 backlog: initiative first-class entry (add+edit field).
2026-07-01 16:08:05 -04:00
david raistrick 3ea67019d2 refactor: App.js 3 sites to shared 1-list contract
delete/toggle/hp sites used OLD computeTurnOrderAfterRemoval/Addition
contract (return turnOrderIds). New 1-list contract: helpers return
advance-only + insertAt; list sync via syncTurnOrder at call site.

- delete: syncTurnOrder(updated) + advance-only removal
- toggle: stay-in-slot, flip isActive, sync, advance only if deact==current
- hp: FEAT-1 unchanged (death/revive no turn changes)

shared exports syncTurnOrder. Build green.
2026-07-01 16:05:18 -04:00
david raistrick 7c3ec105d5 refactor: App.js 1-list display + start/resume (no re-sort)
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.
2026-07-01 16:03:38 -04:00
david raistrick d1cbe7091a refactor: App.js imports shared turn funcs (DRY), delete duplicates
Delete duplicate consts (DEFAULT_MAX_HP/INIT_MOD/MONSTER_DEFAULT_INIT_MOD,
generateId, rollD20, formatInitMod) + funcs (sortParticipantsByInitiative,
computeTurnOrderAfterRemoval, computeTurnOrderAfterAddition) from App.js.
Import from @ttrpg/shared (1-list model). Kills second drift source.

CRA resolves @ttrpg/shared via npm workspaces symlink. Build green.
2026-07-01 16:02:35 -04:00
david raistrick 5d3a0607ef refactor: 1-list turn order model (turnOrderIds === participants.map(id))
Single source of truth. No re-sort after startEncounter. Drag overrides
initiative (cross-init drag allowed, DM choice). Display === rotation by
construction — same array.

shared/turn.js:
- syncTurnOrder(participants) helper: turnOrderIds = participants.map(id)
- startEncounter: sort ALL participants by init (active+inactive), inactive
  stay in slot, nextTurn skips them. currentTurn = first active.
- addParticipant: splice into participants[] by init pos, sync turnOrderIds.
  computeTurnOrderAfterAddition returns insertAt (caller splices + syncs).
- removeParticipant: filter participants[], sync turnOrderIds, advance
  current if removed==current.
- toggleParticipantActive: stay in slot (flip isActive only), sync. Advance
  current only if deact hits current.
- reorderParticipants: cross-init drag allowed (remove same-init restriction).
  Splice participants[], sync turnOrderIds. Fixes BUG-6.
- computeTurnOrderAfterRemoval: only handles current-advance now (list sync
  at call site).

Tests updated to 1-list contract:
- turn.invariant.test.js: 10 tests, turnOrderIds===participants.map(id)
  always, cross-init drag, inactive-in-slot, rotation follows list.
- turn.characterization/reorder/round-rotation/undo/remove: updated
  expectations (inactive-in-slot, cross-init drag, turnOrderIds sync on
  reorder, insertAt return).

Results: shared 90 green. 500-round replay CLEAN (0 skips, 0 doubles,
0 order shifts). BUG-6 (reorder divergence) fixed structurally.

FE App.js still has duplicate turn funcs + sortParticipantsByInitiative
display render (step 4: delete dups, render participants[] directly).
2026-07-01 16:00:00 -04:00
david raistrick 94b62dc5ab feat(replay+parser): log order+init, detect unexplained order shifts
replay-combat.js:
- Turn line now dumps order=[Name:init,...] (both, not names only)
- reorderParticipants call fixed: real drag (dragged→before target), correct
  signature (ids not array). Was broken (passed array, func wants ids,
  swallowed by try/catch silent no-op).

analyze-turns.js:
- Parse order=[Name:init,...] from turn lines
- detectOrderShifts: compare order+init between consecutive turns. Flag shifts
  NOT explained by logged reorder, roster change (add/remove), or init change.
  Catches display/rotation divergence (invariant: display===turnOrderIds===nextTurn).
- Report order shifts count + sample. CLEAN requires 0 shifts.

Result: 100-round replay CLEAN (0 skips, 0 doubles, 0 shifts).
Note: shift detector reads turnOrderIds dump. reorder still leaves turnOrderIds
unchanged (BUG-6) — Path A (step 3) aligns display+rotation, then shift
detector catches true divergence.
2026-07-01 15:37:56 -04:00
david raistrick fcddb58b8b test: 3-list invariant net (display === turnOrderIds === nextTurn)
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.
2026-07-01 15:32:30 -04:00
david raistrick 7467a8d30f feat(M5): docker-compose full stack (caddy + node backend + sqlite)
docker-compose.yml: two profiles.
  - backend: backend (node+ws+better-sqlite3, /data volume) + frontend
    (Caddy static build, STORAGE=ws, same-origin proxy)
  - firebase: existing Dockerfile + nginx (upstream path, untouched)
  Run: docker compose --profile backend up --build. OrbStack local now,
  remote docker context later.

server/Dockerfile: node:18-alpine, workspaces (shared dep), rebuild
better-sqlite3 for musl, DB at /data/tracker.sqlite.

Dockerfile.ws: CRA build STORAGE=ws → caddy:2-alpine serves /srv.
No backend URL baked (same-origin).

Caddyfile: handle /api/* + handle /ws → backend:4001 (path preserved,
mutually-exclusive handles so try_files SPA fallback never shadows proxy).
handle { static try_files } last. HTTP basic auth block optional.

src/storage/ws.js: same-origin defaults. Empty baseUrl = relative fetch
(Caddy proxy). wsUrl derives from window.location (http→ws/https→wss).
Fallback localhost for bare npm start dev.

.dockerignore: add data/ scratch/ tmp/ (never bake into image). Keep
Caddyfile in context (frontend build COPYs it).

Smoke verified via OrbStack:
  - GET / → 200 (static SPA)
  - PUT/GET /api/doc roundtrip → JSON persists
  - WS /ws subscribe + change push → both work through proxy

Firebase profile: pre-existing Dockerfile requires .env.local (hardcoded
COPY on main, not changed here). User must create file. Not a regression.
2026-07-01 14:39:47 -04:00
david raistrick 5521a2f6c6 docs: mark M4 BUG-5 done, move M6 undo to TODO backlog
REWORK_PLAN: M4 →  (slot-array + DRY core, 500 rounds clean). M6 undo
moved to TODO (feature work, not infra). M5 docker: nginx → Caddy
(simpler WS config). Milestone numbering clarified.

TODO: BUG-5 → FIXED. Added FEAT-M6 (transactional undo from plan),
BUG-10 (deact+reactivate double-act, distinct from BUG-5), BUG-11
(FE Combat.scenario pre-existing crash). Pipeline updated.
2026-07-01 14:33:00 -04:00
david raistrick 494327ff17 fix(BUG-5): unify turn-advance core (DRY), 500 rounds skip-free
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).
2026-07-01 14:22:02 -04:00
david raistrick c72b88f8bb WIP turn.js: computeTurnOrderAfterRemoval bumps round on wrap
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.
2026-07-01 12:00:23 -04:00
david raistrick 0473eacc1d WIP: BUG-5 slot-array fix + FEAT-1 dead-not-skipped + skip parser
WORK IN PROGRESS — fix not complete. analyze-turns.js on 500-round
replay still finds 46 real skips + 64 double-acts.

turn.js changes:
- computeTurnOrderAfterAddition: insert by initiative (not append end)
- nextTurn wrap: no re-sort, cycle pointer
- togglePause resume: no re-sort, order stable
- addParticipant: patches turnOrderIds when started
- applyHpChange: death no longer flips isActive or touches turnOrderIds
  (FEAT-1 dead-not-skipped)

Tests:
- shared/tests/turn.skip.test.js (NEW): deterministic skip invariants
  pure 100 rounds + 540 rounds w/ mutations, both green
- shared/tests/turn.dead-skip.test.js: 4 green (FEAT-1)
- turn.characterization.test.js: 3 sites updated to new behavior
- turn.combat.test.js: boundary count fixed (wrap-turn attributed to
  new round), debug dump removed

scripts/analyze-turns.js (NEW): deterministic replay-stdout parser.
Reconstructs rounds, reports real skips + double-acts. Exit 1 on issue.
Catches bugs unit tests miss (46 skips/64 double-acts in 500 rounds).

TODO: FEAT-1 marked done, FEAT-2 added (upgrade app logs parseable).
2026-07-01 11:42:43 -04:00
david raistrick c6d3b7e1a6 docs: move dead-not-skipped (FEAT-1) to TODO backlog, M4 = BUG-5 fix
REWORK_PLAN.md M4 = resolve initiative rotation corruption (BUG-5).
Mid-round add/revive corrupts rotation. RED locked.

TODO.md FEAT-1 = dead participants stay in turn order (user request,
Saturday game). Feature backlog, not milestone.
2026-06-30 16:33:02 -04:00
david raistrick e0f75cfb6c update todo 2026-06-30 16:30:26 -04:00
david raistrick b62996dcbf TODO: backlog of bugs + long-term items, milestones live in REWORK_PLAN
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.
2026-06-30 16:28:18 -04:00
david raistrick 260fb314bc docs: separate REWORK_PLAN (infra/milestones) from TODO (bugs)
REWORK_PLAN.md: backend rework plan only. M0-M3 done, M4 = dead-not-skipped
(the one feature request), M5 docker, M6 undo, M7 e2e. Removed bug-stuff
and hallucinated JUMP_TURN_TO. Server = generic KV doc store.

TODO.md: bugs only. BUG-1/2 resolved, BUG-4/5/6/7/8/9 confirmed. No
milestones, no features. Pipeline stripped to bugs.

M4 (dead-not-skipped) lives in REWORK_PLAN only.
2026-06-30 16:26:10 -04:00
david raistrick fb7fbb2263 remove hallucinated JUMP_TURN_TO feature
Not requested by user. Only real feature request: dead participants not
skipped. Removed turn.jump.test.js + FEAT-2 from TODO.
2026-06-30 16:22:13 -04:00
david raistrick 435e109070 tests: removeParticipant edge cases (5 green)
turn.remove.test.js: current-removed picks next, last wraps to first,
all-inactive → current null (BUG-9 candidate, broken state doc),
non-current kept, dead-removed stays out (BUG-3 overlap explicit action).

No RED. Documents removeParticipant robust.
2026-06-30 14:05:29 -04:00
david raistrick b024fa08bb tests: scenario rotation integrity check (every 10 rounds)
Combat.scenario.test.js: per-10-round assertion - turnOrderIds no dup,
currentTurnParticipantId in turnOrderIds. 299/299 phases pass.

NOTE: scenario runs against firebase MOCK. Mock updateDoc merges correctly
(real ws adapter would clobber per BUG-4 class). So check validates mock
shape, not adapter translation. Layer 2 (ws-contract) covers adapter.
2026-06-30 14:04:27 -04:00
david raistrick 49ea39ea93 TODO: BUG-4 broader - all 5 activeDisplay setDoc calls clobber fields
{merge:true} ignored by setDoc (replace per contract). Each write wipes
other fields on activeDisplay/status doc.
2026-06-30 14:02:32 -04:00
david raistrick b2fd06ed17 tests: JUMP_TURN_TO RED (3 tests, 2 fail - feature missing)
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.
2026-06-30 14:00:50 -04:00
david raistrick e514a48d6e tests: BUG-8 ws reconnect RED, BUG-7 reorder no-undo doc, ws _test accessor
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.
2026-06-30 13:59:58 -04:00
david raistrick c90fc6ffb0 tests: M4 dead-participant skip RED (4 tests, turn.dead-skip.test.js)
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.
2026-06-30 13:57:55 -04:00
david raistrick d979b03f2e tests: BUG-4 RED locked (hide-HP clobbers activeDisplay), add write recorder
src/tests/HideHpToggle.test.js: renders App, selects campaign, toggles
hide-player-HP switch, asserts setDoc data includes activeCampaignId +
activeEncounterId. RED: data only {hidePlayerHp:true}, both clobbered.

Root cause proven with evidence (recorder):
  setDoc(activeDisplay/status, {hidePlayerHp:true}, {merge:true})
  data written = {hidePlayerHp:true} ONLY
  activeCampaignId = undefined
  activeEncounterId = undefined

setDoc = replace per contract. {merge:true} arg ignored. Toggle wipes
encounter pointer → DisplayView reads null → 'Game Session Paused'.
Fix: use updateDoc (patch), not setDoc.

src/storage/firebase.js: adapter recorder now captures setDoc + updateDoc
(data + opts). Was subscribe-only. Enables write-path assertions.
2026-06-30 13:55:14 -04:00
david raistrick be481767f0 tests: undo roundtrip (10 green) + reorderParticipants BUG-7 candidate
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).
2026-06-30 13:50:48 -04:00
david raistrick bac94d85ff tests: reorderParticipants characterization + BUG-6 RED
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.
2026-06-30 13:49:38 -04:00
david raistrick 08c6146cf7 tests: turn.combat.test.js (deterministic RED for BUG-5), deprecate audits
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.
2026-06-30 12:33:56 -04:00
david raistrick d48ecf1460 todo: BUG-4 hide-player-HP breaks display (preexisting) 2026-06-29 17:12:22 -04:00
david raistrick c314d1975e chore: move audit tools tests/audit, add scratch/ gitignored
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.
2026-06-29 17:11:46 -04:00
david raistrick a8e88cf0f0 tooling: audit-state pause+resume paired, guard advance-while-paused
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.
2026-06-29 16:36:43 -04:00
david raistrick d35a730e12 fix(turn): BUG-2 addParticipant rejects duplicate id
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.
2026-06-29 16:25:39 -04:00
david raistrick 912c493974 docs: rewrite DEVELOPMENT.md (current state), add scripts/README.md
- Layout: tests/ per workspace, scripts/ tools, docs/ structure
- Test section: 4 types (unit/integration/characterization/scenario),
  counts (134 green + 1 validated skip), per-file run, scenario slow note
- Tools section: replay-combat (live demo), audit-rotation (rotation),
  audit-state (9 invariant classes)
- Storage: generic KV, path norm, STORAGE_MODE flow, test layers
- Status: M2/M3 done, M4 next
- scripts/README.md: tool usage + bug-finder not unit test
2026-06-29 16:23:34 -04:00
david raistrick 40fc4e596b TODO: BUG-1 symptom chain (4 faces, 1 root cause), fix test paths 2026-06-29 16:22:38 -04:00
david raistrick 80b454d087 tooling: audit-state.js expanded bug-finder (9 invariant classes)
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.
2026-06-29 16:21:54 -04:00
david raistrick 2756b7b3eb tests: skip dup-id test (BUG-2, see TODO), enable clean push
test.skip preserves the test + its comment documenting BUG-2. Re-enable
(remove .skip) when fix lands.
2026-06-29 16:03:03 -04:00
david raistrick f81308a0df tests: consolidate into tests/ dirs, fix import paths
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).
2026-06-29 16:02:22 -04:00
david raistrick 33e0e52789 tests: pause-add rotation corruption + dup-id, log bugs to TODO
- turn.pause-add.test.js: 3 tests isolating addParticipant+pause/resume
  interaction. Clean minimal repro passes (bug needs more state than
  single add+pause). Audit authoritative repro.
- turn.characterization.test.js: RED 'addParticipant rejects duplicate id'.
  Validates current allow-dup behavior.
- TODO.md: BUG-1 (add+pause rotation corruption, 32/100 audit violations),
  BUG-2 (dup id allow). Both confirmed real, NOT fixed.

Audit bisect: dmg+heal+cond+toggle+remove+add+pause = 32 violations.
add+pause alone = 0. Combo needs full state.

No feature code changed.
2026-06-29 15:52:17 -04:00
david raistrick 13490fe3de tests: round-rotation audit, dup-id fail, replay rewrite
- 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.
2026-06-29 15:49:39 -04:00
david raistrick 7866dec83b replay: loop by real rounds, visible damage each turn, faster default
- 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)'.
2026-06-29 15:21:48 -04:00
david raistrick 891fc696d9 docs: add glossary (turn vs round, participants, views, backend) 2026-06-29 15:19:18 -04:00
david raistrick 9fd0f3ec38 M3: fix path-shape drift via adapter contract + identity tests
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).
2026-06-29 15:13:03 -04:00
david raistrick 6630fd9158 M3: add combat replay script + TODO for M4 skip/dead fixes
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.
2026-06-29 14:36:02 -04:00
david raistrick 1d4c561c09 M3: add full 100-round combat scenario test
Drives same UI buttons a DM clicks. Exercises:
- campaign + character roster (3 PCs)
- monsters, NPC, add-all, hidden hp toggle
- start combat, 100 rounds of nextTurn
- damage/heal, conditions (stunned), toggle-active
- edit initiative, edit name, remove participant
- pause/resume with reinforcement mid-combat
- damage-to-0 + death saves (x3) + revive
- end combat + confirm

Harness: each phase wrapped in recordAsync try/catch. Failures collected,
reported at end, do NOT abort run. Found no app bugs; selector bugs in harness
fixed (char form ids vs placeholders, encounter row scope via Init: marker,
conditions panel async render, dead-participant damage skip as expected game
state, end-encounter modal title 'End Encounter?' not 'End Combat').

289/289 phases ok. 58 other frontend + 39 shared + 19 ws-contract = 116 total green.
2026-06-29 13:51:32 -04:00
david raistrick 84a8b78acd M2: refactor DisplayView to storage adapter (red test first)
DisplayView missed in original M2 refactor — raw onSnapshot(doc(db,path))
survived. In ws/memory mode db is a stub sentinel, so raw SDK calls crash
('Expected first argument to collection() to be a CollectionReference...').
Reported by human testing player display after start combat.

TDD:
1. RED: DisplayView.characterization.test.js asserts adapter.subscribeDoc
   called for campaign + encounter + activeDisplay paths. Adapter recorder
   (getAdapterCalls/resetAdapterCalls in firebase.js) instruments subscribe
   calls — catches raw-SDK bypass that firebase mock alone cannot (mock db
   satisfies raw onSnapshot, hiding the bug).
2. Fix: 2 raw onSnapshot sites in DisplayView -> storage.subscribeDoc.
3. GREEN: 2 new tests pass, 116 total green.

Audit confirmed DisplayView was the ONLY remaining raw SDK site in App.js.
2026-06-29 13:13:46 -04:00
david raistrick 52866784b2 M2: replace shape-specific backend with generic KV doc store (firebase mirror)
Backend was endpoint-mapper (POST /api/campaigns ignores path id, etc). Adapter
translation layer brittle, untested, lost doc identity. Generic contract (Layer 2)
test caught 15 bugs immediately.

Rewrite to firebase-mirror KV model:
- server/db.js: docs table (path PK, parent, data JSON). getDoc/setDoc/updateDoc/
  deleteDoc/getCollection/batchWrite. parent = path prefix for collection queries.
- server/index.js: generic REST (GET/PUT/PATCH/DELETE /api/doc, GET /api/collection,
  POST /api/collection addDoc, POST /api/batch). WS subscribe by path (doc|collection),
  broadcast to doc subs at changed path + collection subs at parent path.
- src/storage/ws.js: thin passthrough adapter. norm() strips firebase prefix.
  initial value via REST (independent of WS connect), subsequent changes via WS.
- shared/turn.js kept (M4 use). server/handlers.js + server.test.js removed (logic
  now in App, backend is dumb KV).
- src/storage/contract.js: flush() bumped to 50ms (WS roundtrip > setTimeout(0)).

Layer 2 test (server/ws-contract.test.js): spins fresh backend per test, runs same
storage contract spec against createWsStorage. Catches adapter translation bugs
that firebase-mock Layer 1 tests cannot.

nanoid v5 ESM breaks jest CJS -> replaced with crypto.randomUUID (Node builtin).

Tests: 114 green (39 shared + 19 ws-contract + 56 frontend).
2026-06-29 13:00:24 -04:00
david raistrick 35cd1581e3 Reapply "M3: stub db sentinel in ws/memory mode so legacy guards pass"
This reverts commit d1ee69a70a.
2026-06-29 12:36:16 -04:00
david raistrick ed67535b1f Reapply "M2: fix ws adapter for browser WebSocket + firebase path prefix"
This reverts commit 74b4c2c42d.
2026-06-29 12:36:16 -04:00
david raistrick 17245dfa1b Reapply "M2: gate storage init on STORAGE mode (firebase vs ws/memory)"
This reverts commit e843acdf8a.
2026-06-29 12:36:16 -04:00
david raistrick e843acdf8a Revert "M2: gate storage init on STORAGE mode (firebase vs ws/memory)"
This reverts commit 3e84f28325.
2026-06-29 12:03:57 -04:00
david raistrick 74b4c2c42d Revert "M2: fix ws adapter for browser WebSocket + firebase path prefix"
This reverts commit e743d40e8d.
2026-06-29 12:03:57 -04:00
david raistrick d1ee69a70a Revert "M3: stub db sentinel in ws/memory mode so legacy guards pass"
This reverts commit a5a4df78f0.
2026-06-29 12:03:57 -04:00
david raistrick a5a4df78f0 M3: stub db sentinel in ws/memory mode so legacy guards pass
ws/memory storage mode never set the module-level `db` variable. 24 handlers
guarded with `if (!db) return` early-exited, silently dropping all writes
(create campaign, add encounter, participant CRUD, combat, logs).

db stays a truthy sentinel object { __localStub: true } in non-firebase mode.
All real reads/writes route through storage.*; db only used by guards.

56 frontend tests green. Verified via headed browser: create campaign flow
works end-to-end (modal closes, campaign appears via WS realtime push).
2026-06-29 11:30:08 -04:00
david raistrick b095e37bfe M3: remove project dev-serve skill (moving to generic global skill) 2026-06-29 11:25:13 -04:00
david raistrick 54e8df9ffa M3: add dev-serve skill (boot stack + headed browser for human testing)
- pkill stale procs, boot backend + frontend in background
- STORAGE=ws default (firebase opt-in)
- agent_browser --headed fresh session for user-visible window
- troubleshooting table + teardown

Skill loads via /skill:dev-serve
2026-06-29 11:24:23 -04:00
david raistrick e743d40e8d M2: fix ws adapter for browser WebSocket + firebase path prefix
- WebSocketImpl: native browser WebSocket if present, else ws pkg (node/jest)
- ensureWs: use onopen/onmessage/onerror/onclose property handlers (browser API)
  instead of ws.on('open') EventEmitter (node-only) — was silent no-op in browser
- norm(): strip 'artifacts/{APP_ID}/public/data/' prefix from all paths
  App passes firebase-prefixed paths; backend uses canonical campaigns/...
- apply norm() to getDoc/getCollection/setDoc/updateDoc/deleteDoc/addDoc/
  subscribeDoc/subscribeCollection/changeTypesForDocPath/changeTypesForCollPath

Verified: STORAGE=ws boots, WS subscribe fires, backend broadcast -> browser
live update (curl POST campaign -> appears without reload). Cross-device sync
confirmed end-to-end.
2026-06-28 22:43:30 -04:00
david raistrick 3e84f28325 M2: gate storage init on STORAGE mode (firebase vs ws/memory)
- STORAGE_MODE = getStorageMode()
- initializeStorage(): firebase = real SDK init; ws/memory = stub auth
- App auth flow: ws/memory skip signInAnonymously, unblock UI
- error screen mode-aware message

56 tests green. STORAGE=ws now boots past config error.
2026-06-28 21:11:56 -04:00
david raistrick 812298fa73 M2: refactor all firebase write sites to storage adapter
- 37 call sites: setDoc/updateDoc/deleteDoc/addDoc/getDocs/writeBatch -> storage.*
- adapter wraps SDK, path-string interface
- storage instance app-wide (getStorage)
- firebase.js: static imports (getDoc/getDocs alias), no dynamic import

56 frontend tests green. STORAGE=firebase = identical behavior.
2026-06-28 21:05:39 -04:00
david raistrick 5bb9e5fc19 M2: refactor hooks to storage adapter (subscribe)
- src/storage/index.js: getStorage() factory + SDK re-exports
- App.js: useFirestoreDocument/Collection call storage.subscribeDoc/Collection
- getStorage import added

56 frontend tests green. Hooks now impl-agnostic (firebase vs ws).
2026-06-28 19:03:44 -04:00
david raistrick 35b5a1d238 test: logs + deathSave characterization (6 tests)
- Logs.characterization.test.js: logAction (write + undo payload), clearLogs batch delete, undo (updateDoc encounter + mark undone), deathSave increment + isDying
- mock firestore getDocs: return .ref.path on docs (batch.delete support)
- mock addDoc: record full doc path not collection path

All write sites characterized. 56 frontend tests green.
2026-06-28 19:00:08 -04:00
david raistrick d581e60ba3 test: combat characterization (9 tests)
- Combat.characterization.test.js: startEncounter (state + activeDisplay), nextTurn, round wrap, pause, resume, endEncounter (reset + clear activeDisplay), toggleHidePlayerHp

Locks combat control write paths.
2026-06-28 18:52:49 -04:00
david raistrick 4158a1634d test: participant characterization (9 tests)
- Participant.characterization.test.js: addMonster (shape, initiative range, NPC), deleteParticipant, toggleActive, applyDamage, damage-to-0, heal revive, toggleCondition
- testHelpers.js: getParticipantForm (scoped), addMonsterViaUI, setupReady, startCombatViaUI

Locks participant write paths + payload shapes. Refactor guard.
2026-06-28 18:50:42 -04:00
david raistrick 0c1196aee1 test: encounter characterization (6 tests)
- Encounter.characterization.test.js: createEncounter, path nesting, togglePlayerDisplay on/off, deleteEncounter + clears activeDisplay
- testHelpers.js: createEncounterViaUI, selectEncounterByName

Locks encounter write paths + payload shapes.
2026-06-28 18:30:57 -04:00
david raistrick 672f042b60 chore: alphabetize package.json deps after install/uninstall churn
trivial reorder, no version changes
2026-06-28 18:29:09 -04:00
david raistrick b6555648ee test: campaign characterization (7 tests)
- src/testHelpers.js: renderApp, createCampaignViaUI, selectCampaignByName
- App.characterization.test.js: createCampaign, addCharacter, updateCharacter, deleteCharacter, deleteCampaign + path namespace + bg url
- mock firestore writeBatch sync (was async, app no-await)

Locks path + payload shape per action. Refactor guard.
2026-06-28 18:12:27 -04:00
david raistrick 84dd17e174 test: Firebase mock harness + createCampaign characterization
- src/__mocks__/firebase/*: jest manual mocks (app/auth/firestore)
- src/__mocks__/firebase/_mock-db.js: in-memory DB + call recorder
- src/setupTests.js: jest-dom, env stubs, crypto polyfill, DB reset
- src/App.characterization.test.js: createCampaign -> setDoc path/payload locked
- src/storage/contract.js (renamed from .test.js, helper not suite)

21 tests green (memory 19 + createCampaign 2).
2026-06-28 17:59:50 -04:00
david raistrick 12b24eb707 M2 (C): storage barrel re-export, App.js imports swapped
- src/storage/index.js: re-exports Firebase SDK
- App.js: imports from ./storage (was firebase/* direct)
- STORAGE=firebase = identical behavior
- dev server compiles clean

Safe refactor proof. Next: per-call-site path-based rewrite for ws adapter.
2026-06-28 17:51:39 -04:00
david raistrick 2ee2bba93b M2 (TDD): storage contract test + memory impl
- src/storage/contract.test.js: storage interface spec (19 assertions)
- src/storage/memory.js: in-process impl (Map + EventEmitter)
- src/storage/storage.test.js: runner, memory first

TDD: contract RED first, memory built to satisfy, 19/19 green.
Next impls (ws, firebase) run same contract.
2026-06-28 17:18:14 -04:00
david raistrick 9457f48b23 ci: local pre-push hook instead of GH Actions (private repo)
- remove .github/workflows/ci.yml
- add .githooks/pre-push: runs npm run test:all
- git config core.hooksPath .githooks (set)
- docs/DEVELOPMENT.md: document local pipeline

Private repo = no free Actions. Revisit when public.
2026-06-28 17:16:23 -04:00
david raistrick fa19913e23 ci: add GitHub Actions workflow + dev docs + test:all script
- .github/workflows/ci.yml: runs shared + server tests on push/PR
- docs/DEVELOPMENT.md: setup, run, test, architecture, status
- package.json: test:all script (shared + server suites)
2026-06-28 17:14:51 -04:00
david raistrick 0e76fb2fc7 M1: backend (Express+ws+better-sqlite3) + integration tests
- server/db.js: SQLite schema mirroring Firestore doc tree
- server/handlers.js: action -> shared turn fn -> tx persist -> broadcast
- server/index.js: REST endpoints + WebSocket real-time push
- server/server.test.js: 7 integration tests (REST CRUD + combat flow)
- --forceExit for jest (open WS handles)

Backend boots, serves state, persists to SQLite.
2026-06-28 17:01:53 -04:00
david raistrick e06adaa081 M1: shared turn logic + characterization tests (39 green)
- npm workspaces: shared/, server/
- shared/turn.js: port turn logic verbatim from App.js (bugs preserved)
- 39 characterization tests lock current behavior
- gitignore: sqlite data, logs
2026-06-28 16:57:43 -04:00
david raistrick d679c9d1e9 docs: restore approved milestone plan (backend=M1, drop FSM-extract milestone) 2026-06-28 16:53:18 -04:00
david raistrick ad7979d8fd docs: add rework plan (backend-first, test-ecosystem baseline) 2026-06-28 16:47:48 -04:00
robertandClaude Sonnet 4.6 33b27775b4 Bump version to v0.3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 20:15:20 -04:00
robertandClaude Sonnet 4.6 d96d3a6cf2 Guard against undefined values in combat log undo payloads
Firestore rejects writes containing undefined values. The pause/resume
and end-combat undo snapshots read encounter fields that may not yet
exist in Firestore, so add ?? false / ?? null / ?? 0 fallbacks to
match the pattern already used in the start-combat undo path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 17:19:52 -04:00
robertandClaude Sonnet 4.6 439f48871e Fix mid-round activation causing participants to be skipped in round 2+
When a participant was activated mid-combat, computeTurnOrderAfterAddition
appended them to the end of turnOrderIds. The visual display sorted by
initiative (putting them at the top), but the turn pointer followed the
append order, making it look like the top-initiative participants were
skipped when the round wrapped.

Fix: at the round boundary in handleNextTurn, rebuild turnOrderIds from
all active participants sorted by initiative. Mid-round additions go last
in round 1 (standard D&D ruling), then slot into proper initiative order
from round 2 onwards. Also adds turnOrderIds to the next-turn undo snapshot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 16:57:08 -04:00
robertandClaude Sonnet 4.6 35990f588e Add per-entry undo buttons to combat log, bump to v0.2.5
Each logged action now stores a Firestore snapshot of the affected
encounter state. The /logs page shows an ↩ Undo button on any entry
with undo data; clicking it restores the encounter to its pre-action
state and marks the entry as rolled back (greyed out, strikethrough).

Covered actions: damage/heal, condition toggle, activate/deactivate,
add/remove participant, next turn, start/pause/resume/end combat.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 16:45:07 -04:00
robertandClaude Sonnet 4.6 b11fbe4715 Update README to reflect current feature set
Documents conditions list (now 22), combat log at /logs, hide player HP
toggle, inactive monster hiding, fullscreen button, and wake lock toggle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 11:59:36 -04:00
robertandClaude Sonnet 4.6 7b52480329 Add wake lock toggle to prevent screen sleep on player display
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 11:56:11 -04:00
robertandClaude Sonnet 4.6 58cc588726 Add fullscreen button to player display view
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 11:53:56 -04:00
robertandClaude Sonnet 4.6 e200f59b7e Hide inactive monsters from player display view
Inactive monsters are now filtered out of the DisplayView so DMs can
pre-stage summoned/reserve monsters without spoiling them for players.
Inactive characters remain visible since their inactive state is
player-relevant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 14:39:59 -04:00
robert bb65709e26 Added shield to list of conditions. 2026-05-16 15:29:27 -04:00
robertandClaude Sonnet 4.6 33d831af54 Fix race condition causing permissions error on /display page
onAuthStateChanged fires with null before signInAnonymously completes,
causing DisplayView to query Firestore unauthenticated. Now only marks
auth ready when an actual user is present; auth failures are handled in
the catch block to avoid hanging the UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:53:03 -04:00
robertandClaude Sonnet 4.6 4150267925 Add combat action log at /logs
Instruments 9 handlers (combat start/end/pause/resume, next turn,
participant add/remove/toggle, HP changes, conditions) to write
timestamped entries to a Firestore logs collection. New LogsView
at /logs shows entries newest-first with encounter context, and
includes a Clear Log button. Adds a View Logs link in the header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:25:17 -04:00
robertandClaude Sonnet 4.6 e23cea205a Add HP toggle, new conditions, fix turn order sync bug
- Add DM toggle (default on) to hide player HP bars on player display;
  persisted in activeDisplay Firestore doc for real-time sync
- Add Alchemist Fire and Bardic Inspiration conditions; sort all
  conditions alphabetically
- Fix turn order skipping when participants are deleted, deactivated,
  or killed mid-combat: turnOrderIds was never updated, causing
  handleNextTurn to resolve currentIndex as -1 and snap back to the
  first participant. Now all mutation paths (delete, toggle active,
  HP death/resurrection) keep turnOrderIds in sync and advance the
  turn pointer correctly when the current participant is removed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:00:17 -04:00
robert 6cd25dadaa Alchemist Fire and Bardic Inspiration conditions, sort alphabetically. 2026-05-16 09:37:11 -04:00
robert 90cfb36b56 Adds a 'Hide player HP' toggle in the Combat Controls panel (on by default) that suppresses health bars for player characters in the DisplayView. Setting is persisted in the activeDisplay Firestore doc so it survives page reloads and takes effect in real time on the player screen. 2026-05-16 09:30:35 -04:00
robert 7676751a5b Added weapon conditions 2026-04-26 17:12:29 -04:00
robert 16118dd958 Updated README.md and added CLAUDE.md 2026-04-26 11:42:22 -04:00
robert 33c93ab86b Added conditions. 2026-04-26 10:37:25 -04:00
robert 451151628c fixing build and nginx 2026-04-25 20:35:52 -04:00
robert 1e0df31cd4 More text improvements. 2026-04-25 20:25:34 -04:00
robert 3be9b0a921 Updated look of site. 2026-04-25 18:37:55 -04:00
robert 895fa06227 updated fix for adding combatants mid fight and added some death effects. 2025-12-13 19:15:26 -05:00
robert 82f45e60f0 Updated readme, and added a license. 2025-05-28 18:58:38 -04:00
robert ceba7632f8 Update README.md
Just a little clean up.
2025-05-28 15:33:44 -04:00
137 changed files with 26029 additions and 2441 deletions
@@ -0,0 +1,294 @@
---
name: ttrpg-encounter-builder
description: |
Seeds D&D encounter rosters into the TTRPG Initiative Tracker via its REST API
(no UI clicking). Use when the user asks to "set up encounters", "build
encounters", "add participants to a campaign", "seed this encounter list",
or provides a statblock list (name + HP + init) for a campaign running on a
self-hosted tracker instance running in SERVER MODE (bundled Express
backend). NOT for Firebase SDK mode — that has no HTTP backend; write path
differs entirely. Harness-agnostic: pi, Claude Code, and Codex all run the
same script + curl recipes.
---
# TTRPG Encounter Builder
Build encounter rosters directly via the tracker REST API. No UI clicking, no
browser automation. One batch write per session, then verify.
## When to use
User provides a campaign name + a list of encounters, each with named
participants carrying HP and initiative modifiers (or rolled initiative).
Examples:
- "set up these encounters in the 2026 D&D campaign on bee.icantclick.org:8080"
- "build encounter: Barrowdeep 1 - Ankheg HP 45 Init +0"
- "seed these statblocks into my tracker"
Do NOT use for: starting combat, rolling HP mid-fight, editing live combat
state, or driving the player display. Those are in-combat ops, not build ops.
## Prerequisites
- A running tracker instance in **SERVER MODE** (`REACT_APP_STORAGE=server`,
bundled Express backend mounted). Reachable over HTTP.
- **Firebase SDK mode NOT supported.** No `/api/*` endpoints exist; data
lives in Firestore via the in-browser SDK with different auth + write
paths. This skill cannot reach it. If the user is on firebase mode, tell
them: switch to server mode, or build encounters via the UI / a
firebase-admin script (out of scope here).
- Node.js 18+ (for `fetch` + `crypto.randomUUID`). Helper script uses only
builtins — no `npm install`.
- Campaign must already exist (created via UI or `POST /api/collection`).
This skill adds encounters to an existing campaign.
Confirm host reachable + server mode before building:
```bash
curl -s <HOST>/api/collection?path=campaigns | head -c 200
```
Returns JSON array of campaign docs = server mode confirmed. HTML (SPA shell)
= firebase mode OR backend not mounted. Either way, this skill won't work —
tell the user.
## Data model (read this before writing)
Three nested entities, all opaque JSON docs in a path-keyed KV store:
```
Campaign campaigns/{campaignId}
└─ Encounter campaigns/{campaignId}/encounters/{encounterId}
└─ Participant (inline array item on the encounter doc)
```
**Participants are inline.** There is NO `participants` sub-collection. A
`participants` path returns empty. Each encounter doc carries its full roster
in a `participants[]` array.
### Path normalization (gotcha)
App code uses firebase-prefixed paths (`artifacts/{APP_ID}/public/data/campaigns/...`).
The REST API uses **bare canonical** paths (`campaigns/...`). The adapter
strips the prefix server-side.
- REST / scripts / direct DB → bare: `campaigns/{cid}/encounters/{eid}`
- If `GET /api/collection?path=artifacts/.../campaigns` returns `[]`, you used
the prefixed form. Drop the prefix.
### Encounter doc shape
```js
{
name: 'Barrowdeep 1 - Antechamber',
createdAt: new Date().toISOString(),
participants: [], // inline roster
round: 0,
currentTurnParticipantId: null,
isStarted: false,
isPaused: false,
ruleset: '5e',
}
```
Build encounters with `round: 0`, `isStarted: false`. Starting combat is a
separate in-combat op (UI Start button or a later skill).
### Participant object shape
```js
{
id: <uuid>, // crypto.randomUUID(), client-generated
name: 'Ankheg',
type: 'monster', // 'monster' | 'npc' | 'character'
originalCharacterId: null, // set only if type === 'character'
initiative: <int>, // rolled ONCE at add; stored, not re-derived
maxHp: 45,
currentHp: 45, // start at full
conditions: [],
isActive: true,
status: 'conscious',
deathSaveSuccesses: 0, // character/npc only
deathSaveFailures: 0, // character/npc only
}
```
### type field
- `monster` — DM-controlled, dead at 0 HP (auto-inactive). Default.
- `npc` — DM-controlled but keeps death saves (revivable). Use for named
allies/villains the DM runs.
- `character` — player character. Requires `originalCharacterId` linking to a
campaign roster entry. For build-from-statblock, use `monster` or `npc`,
not `character`.
### Initiative semantics
- Default: `initiative = rollD20() + initMod` (d20 ∈ [1,20], rolled fresh per
participant).
- Manual override: set `initiative` directly (pre-rolled values), skip the
roll. `initMod` is NOT stored on the participant — only the final
`initiative` value persists.
- Fixed at add time. Never re-derived after.
### id format
`generateId()` = `crypto.randomUUID()` (fallback `id_{Date.now()}_{rand10}`).
Generate participant ids client-side so they're stable across batch writes.
Server `POST /api/collection` auto-generates a UUID for the doc id — fine for
campaigns/encounters, but for inline participants you generate them yourself.
## REST endpoints
All paths bare canonical.
| Method | Path | Body / Query | Action |
|---|---|---|---|
| `GET` | `/api/doc` | `?path=` | read one doc |
| `PUT` | `/api/doc` | `{path, data}` | replace doc |
| `PATCH` | `/api/doc` | `{path, patch}` | shallow merge, create-on-miss |
| `DELETE` | `/api/doc` | `?path=` | delete one doc |
| `GET` | `/api/collection` | `?path=` | list docs under collection |
| `POST` | `/api/collection` | `{path, data}` | addDoc: auto-id under collection |
| `POST` | `/api/batch` | `{ops:[{type, path, data?}]}` | atomic multi-write |
`POST /api/batch` is the workhorse for seeding. One batch, all encounters,
atomic.
## Build flow
### 1. Parse the user's request
Extract:
- **Host** — the running tracker URL (e.g. `http://bee.icantclick.org:8080`).
If omitted, ask. Do not guess.
- **Campaign name** — must match an existing campaign doc's `name` field.
- **Encounters** — list of `{name, participants:[{name, type?, maxHp, initMod?|initiative?}]}`.
A statblock line like `Ankheg - HP 45 - Init +0` parses to:
`{name:"Ankheg", type:"monster", maxHp:45, initMod:0}`.
### 2. Resolve the campaign id
Campaigns are keyed by id, not name. Look up by name:
```bash
curl -s "<HOST>/api/collection?path=campaigns" \
| jq -r '.[] | select(.name=="<CAMPAIGN NAME>") | .id'
```
Exactly one match expected. Zero → campaign doesn't exist (tell user to create
it first). Multiple → ambiguous name, ask user which id.
### 3. Write encounters (two options)
#### Option A — helper script (recommended)
`scripts/build-encounters.js` handles id gen, initiative rolling, batch write,
and verification. Reads a spec file or stdin.
Spec format (JSON array):
```json
[
{
"name": "Optional Road/Trail Ankheg",
"participants": [
{ "name": "Ankheg", "type": "monster", "maxHp": 45, "initMod": 0 }
]
},
{
"name": "Barrowdeep 2 - Minor Burial Chamber",
"participants": [
{ "name": "Cult Fanatic", "type": "monster", "maxHp": 39, "initMod": 2 },
{ "name": "Cultist 1", "type": "monster", "maxHp": 9, "initMod": 2 },
{ "name": "Cultist 2", "type": "monster", "maxHp": 9, "initMod": 2 }
]
}
]
```
Run (resolve script path relative to this skill dir):
```bash
node scripts/build-encounters.js <HOST> "<CAMPAIGN NAME>" spec.json
# or stdin:
cat spec.json | node scripts/build-encounters.js <HOST> "<CAMPAIGN NAME>" -
```
Script prints each encounter id + participant init rolls, then verifies.
#### Option B — raw curl (any harness, no node)
Build the batch payload inline. Generate UUIDs and roll initiative yourself.
One `POST /api/batch` writes everything:
```bash
curl -s -X POST "<HOST>/api/batch" \
-H 'Content-Type: application/json' \
-d '{
"ops": [
{
"type": "set",
"path": "campaigns/CID/encounters/EID1",
"data": {
"name": "Optional Road/Trail Ankheg",
"createdAt": "2026-07-07T00:00:00.000Z",
"participants": [
{ "id": "UUID1", "name": "Ankheg", "type": "monster", "originalCharacterId": null, "initiative": 15, "maxHp": 45, "currentHp": 45, "conditions": [], "isActive": true, "status": "conscious", "deathSaveSuccesses": 0, "deathSaveFailures": 0 }
],
"round": 0, "currentTurnParticipantId": null, "isStarted": false, "isPaused": false, "ruleset": "5e"
}
}
]
}'
```
Response: `{"ok":true}`. If you get an error, check path is bare (no
`artifacts/` prefix) and JSON is valid.
### 4. Verify
Always verify after write. Don't trust the batch `ok` alone.
```bash
curl -s "<HOST>/api/collection?path=campaigns/CID/encounters" \
| jq '.[] | {name, participants: (.participants|length), started: .isStarted}'
```
Expect one object per encounter with correct participant count and
`started: false`.
### 5. Report back to user
Tell the user:
- Host + campaign name resolved
- Per encounter: name, id, participant count, rolled initiatives
- Verification result (counts matched)
- That combat is NOT started (encounters sit at `round: 0, isStarted: false`).
User starts combat via the UI Start button when ready.
## Common mistakes
| Symptom | Cause | Fix |
|---|---|---|
| `GET /api/collection?path=artifacts/.../campaigns` returns `[]` | used firebase-prefixed path | drop `artifacts/{APP_ID}/public/data/` prefix, use bare `campaigns` |
| `GET /health` or collection returns HTML (SPA shell) | server in firebase mode, or backend not mounted | confirm `REACT_APP_STORAGE=server` + backend running on the instance |
| Batch returns `ok` but encounter missing | wrong campaign id, or path typo | re-resolve campaign id by name; check path segments |
| Participant count mismatch on verify | participant objects malformed / dropped in JSON | validate each has all required fields; use script to avoid hand-JSON errors |
| Initiative looks wrong | forgot `initiative` is stored value, not re-derived | roll `d20 + initMod` once at build, store the int. No `initMod` field on participant. |
| Duplicate cultist names across encounters | fine — names need only be unique WITHIN an encounter, not across | no fix needed |
## Out of scope
- Creating campaigns (use UI or `POST /api/collection` with a campaign doc)
- Starting combat / advancing turns / HP damage / death saves (in-combat ops)
- Player display control (`activeDisplay/status`)
- Firebase SDK mode (this skill is server-mode REST only)
- Campaign roster characters (`players[]` on campaign doc) — those are
templates; this skill adds inline combatants, not roster entries
## Reference
Full entity model, combat flow, and storage paths: `docs/ENCOUNTER_BUILDER.md`
in the repo root. REST + DB internals: `docs/DEVELOPMENT.md` and
`server/index.js`.
@@ -0,0 +1,149 @@
// build-encounters.js — seed TTRPG encounters via REST API.
// Cross-harness: pi, claude, codex all call same script.
// No deps beyond node builtins. Reads spec JSON, writes batch, verifies.
//
// Usage:
// node build-encounters.js <host> <campaignName> <specFile>
// cat spec.json | node build-encounters.js <host> <campaignName> -
//
// specFile = JSON array of encounters:
// [
// {
// "name": "Barrowdeep 1 - Antechamber",
// "participants": [
// { "name": "Ankheg", "type": "monster", "maxHp": 45, "initMod": 0 },
// { "name": "Boss", "type": "npc", "maxHp": 39, "initMod": 2, "initiative": 15 }
// ]
// }
// ]
//
// type: 'monster' | 'npc' | 'character'. Default 'monster'.
// initiative: optional manual override. If omitted, rolled d20 + initMod.
// initMod default 0. maxHp default 10.
//
// Output: written encounter ids + rolled initiatives. Exit 0 on success.
'use strict';
const crypto = require('crypto');
const genId = () =>
(typeof crypto !== 'undefined' && crypto.randomUUID)
? crypto.randomUUID()
: `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
const rollD20 = () => Math.floor(Math.random() * 20) + 1;
function makeParticipant(spec) {
const type = spec.type || 'monster';
const maxHp = spec.maxHp || 10;
const initMod = spec.initMod || 0;
const manual = spec.initiative !== undefined && spec.initiative !== null;
return {
id: genId(),
name: spec.name,
type,
originalCharacterId: spec.originalCharacterId || null,
initiative: manual ? Number(spec.initiative) : rollD20() + initMod,
maxHp,
currentHp: maxHp,
conditions: [],
isActive: true,
status: 'conscious',
deathSaveSuccesses: 0,
deathSaveFailures: 0,
};
}
async function getJson(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(`GET ${url} -> ${r.status}: ${await r.text()}`);
return r.json();
}
async function postJson(url, body) {
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(`POST ${url} -> ${r.status}: ${await r.text()}`);
return r.json();
}
async function findCampaignId(host, name) {
const list = await getJson(`${host}/api/collection?path=campaigns`);
const matches = list.filter(c => c.name === name);
if (matches.length === 0) {
console.error(`No campaign named "${name}". Existing:`);
list.forEach(c => console.error(` ${c.name}`));
process.exit(2);
}
if (matches.length > 1) {
console.error(`Multiple campaigns named "${name}". Use unique name or pass id.`);
matches.forEach(c => console.error(` ${c.id} ${c.name}`));
process.exit(2);
}
return matches[0].id;
}
async function main() {
const [host, campaignName, specArg] = process.argv.slice(2);
if (!host || !campaignName || !specArg) {
console.error('Usage: node build-encounters.js <host> <campaignName> <specFile|->');
process.exit(1);
}
const specRaw = specArg === '-'
? require('fs').readFileSync(0, 'utf8')
: require('fs').readFileSync(specArg, 'utf8');
const encounters = JSON.parse(specRaw);
if (!Array.isArray(encounters)) throw new Error('spec must be a JSON array of encounters');
const cid = await findCampaignId(host, campaignName);
console.log(`campaign: "${campaignName}" -> ${cid}`);
const encPath = `campaigns/${cid}/encounters`;
const now = new Date().toISOString();
const ops = [];
const summary = [];
for (const enc of encounters) {
const id = genId();
const path = `${encPath}/${id}`;
const parts = (enc.participants || []).map(makeParticipant);
ops.push({
type: 'set',
path,
data: {
name: enc.name,
createdAt: now,
participants: parts,
round: 0,
currentTurnParticipantId: null,
isStarted: false,
isPaused: false,
ruleset: enc.ruleset || '5e',
},
});
summary.push({ name: enc.name, id, parts });
}
const res = await postJson(`${host}/api/batch`, { ops });
if (!res.ok) throw new Error(`batch failed: ${JSON.stringify(res)}`);
console.log(`batch: ${ops.length} encounter(s) written\n`);
// Verify
const written = await getJson(`${host}/api/collection?path=${encPath}`);
const byId = new Map(written.map(e => [e.id, e]));
for (const s of summary) {
const got = byId.get(s.id);
const ok = got && got.name === s.name && (got.participants || []).length === s.parts.length;
console.log(`${ok ? '✓' : '✗'} ${s.name}`);
console.log(` id: ${s.id}`);
if (!ok) { console.log(` VERIFY FAILED: got ${JSON.stringify(got && { name: got.name, n: (got.participants || []).length })}`); continue; }
s.parts.forEach(p => {
const i = p.initiative >= 0 ? `+${p.initiative}` : `${p.initiative}`;
console.log(` - ${p.name} (${p.type}, hp ${p.maxHp}, init ${i})`);
});
}
console.log('\nDone.');
}
main().catch(e => { console.error('FAIL:', e.message); process.exit(1); });
+9 -29
View File
@@ -1,31 +1,11 @@
# Ignore Git directory
.git
.gitignore
# Ignore Node.js modules (they will be installed in the Docker image)
TODO.md
node_modules
# Ignore build output (it will be generated in the Docker image)
build
dist
# Ignore Docker files themselves
Dockerfile
.dockerignore
# Ignore any local environment files if you have them
.env
# .env.local
.env.development.local
.env.test.local
.env.production.local
# Ignore IDE and OS-specific files
.vscode/
.idea/
*.suo
*.user
*.userosscache
*.sln.docstates
Thumbs.db
**/node_modules
.git
tmp
data
*.log
npm-debug.log*
.DS_Store
coverage
.nyc_output
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
# pre-push: run test suites before push. Skip with --no-verify.
echo "[pre-push] running tests..."
npm run test:all || { echo "[pre-push] tests failed. push aborted. (skip: git push --no-verify)"; exit 1; }
exit 0
+9 -2
View File
@@ -1,4 +1,3 @@
# .gitignore
node_modules
build
dist
@@ -6,4 +5,12 @@ dist
.env.local
.env.development.local
.env.test.local
.env.production.local
.env.production.local
*.log
data/*.sqlite
data/*.sqlite-*
server/data/*.sqlite
server/data/*.sqlite-*
/data
/scratch
tmp/
+78
View File
@@ -0,0 +1,78 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
npm start # Dev server at http://localhost:3000
npm run build # Production build
npm test # Run tests (no test files currently exist)
```
Docker:
```bash
docker build -t ttrpg-initiative-tracker .
docker run -p 8080:80 --rm --name ttrpg-tracker-app ttrpg-initiative-tracker
```
The Dockerfile uses `NODE_OPTIONS=--openssl-legacy-provider npm run build` to work around an OpenSSL compatibility issue between Node 18 and react-scripts 5.
## Architecture
Single-page React app (Create React App) for DMs to manage TTRPG combat encounters with a real-time player display. The entire application — all components, hooks, Firebase init, and routing — lives in **`src/App.js`** (~2500 lines).
**Key dependencies:** React 18, Firebase SDK v10 (Firestore + anonymous auth), Tailwind CSS v3, lucide-react icons.
### Two App Modes (query-param routing)
- Default URL → `AdminView`: full DM interface (campaign/encounter/participant management)
- `?playerView=true` or `/display``DisplayView`: read-only player-facing view for a second monitor
### Firebase / Firestore
All app state lives in Firestore under `artifacts/{APP_ID}/public/data/`:
- `campaigns/` — campaign documents
- `campaigns/{id}/encounters/` — sub-collections with a `participants` array per encounter
- `activeDisplay/status` — single doc controlling what the player display shows
`APP_ID` defaults to `"ttrpg-initiative-tracker-default"` and can be overridden via `REACT_APP_TRACKER_APP_ID`.
All users authenticate anonymously (Firebase Anonymous Auth). If `window.__initial_auth_token` is set, a custom token is used instead.
Real-time updates use two custom hooks in App.js: `useFirestoreDocument(docPath)` and `useFirestoreCollection(collectionPath)`, both backed by `onSnapshot`.
### App.js Sections (in order)
1. Constants — `APP_VERSION`, `CONDITIONS` array, defaults
2. Firebase config — reads `REACT_APP_FIREBASE_*` env vars
3. Firestore path helpers — `getPath` object
4. Utility functions — `generateId()`, `rollD20()`, sort helpers
5. Custom hooks — `useFirestoreDocument`, `useFirestoreCollection`
6. Reusable UI — `Modal`, `ConfirmationModal`, `LoadingSpinner`, `ErrorDisplay`
7. Feature components — forms, managers, controls (see below)
8. `AdminView` (~line 1942) — root DM component
9. `DisplayView` (~line 2186) — root player component
10. `App` (~line 2426) — auth + routing
Major feature components: `CreateCampaignForm`, `CreateEncounterForm`, `EditParticipantModal`, `CharacterManager`, `ParticipantManager`, `InitiativeControls`, `EncounterManager`.
### Styling
Tailwind CSS with two custom font families configured in `tailwind.config.js`:
- `font-cinzel` — Cinzel (serif, used for headings)
- `font-garamond` — Alegreya Sans (default body font)
### Environment Variables
Copy `env.example` to `.env.local` and fill in Firebase credentials:
```
REACT_APP_FIREBASE_API_KEY
REACT_APP_FIREBASE_AUTH_DOMAIN
REACT_APP_FIREBASE_PROJECT_ID
REACT_APP_FIREBASE_STORAGE_BUCKET
REACT_APP_FIREBASE_MESSAGING_SENDER_ID
REACT_APP_FIREBASE_APP_ID
REACT_APP_TRACKER_APP_ID # optional, Firestore namespace
```
+288
View File
@@ -0,0 +1,288 @@
# Death Saves 5e - Implementation Plan
TDD required. Tests first, implementation after.
## Gap Analysis
**Current:**
- ✅ Separate success/fail counters (deathSaves, deathFails)
- ✅ 3 success → stable, 3 fail → dead
- ✅ UI shows ✓/✕ buttons, status badges
**Missing (from DEATH_SAVES.md):**
- ❌ Nat 20: 1 HP + conscious, reset counters
- ❌ Nat 1: 2 failures
- ❌ Damage at 0 HP → add failures (normal=1, crit=2)
- ❌ Massive damage: damage >= currentHp + maxHp → instant dead
- ❌ Stabilize manual action
- ❌ Healing resets counters
- ❌ Damage breaks stable → back to dying
**Test Gap:**
- turn.deathsave.test.js: basic counters only
- turn.combat.test.js: single deathSave call, no coverage
- Combat.scenario.test.js: basic fail/death flow
- No nat20, nat1, damage-at-0, massive damage, stabilize, healing integration
## Phase 1: Extend Tests (TDD)
### File: `shared/tests/turn.deathsave.nat20.test.js`
Test cases:
- Nat20 → hp=1, deathSaves=0, deathFails=0, isDying=false, isStable=false, status="conscious"
- Nat20 overrides existing saves/fails
- Nat20 log entry correct
### File: `shared/tests/turn.deathsave.nat1.test.js`
Test cases:
- Nat1 → deathFails += 2
- Nat1 triggers instant death (3 fails total)
- Nat1 log entry correct
### File: `shared/tests/turn.deathsave.damage.test.js`
Test cases:
- Damage at 0 HP → deathFails += 1
- Crit damage at 0 HP → deathFails += 2
- Mixed: save success + damage → independent tracking works
- Damage log includes "while dying"
### File: `shared/tests/turn.deathsave.massive.test.js`
Test cases:
- Damage >= currentHp + maxHp → instant dead
- Bypasses death saves entirely
- Log entry shows "massive damage"
### File: `shared/tests/turn.deathsave.stabilize.test.js`
Test cases:
- Stabilize → hp=0, isStable=true, isDying=false, counters=0
- Stabilize from dying state
- Stabilize log entry correct
### File: `shared/tests/turn.deathsave.heal.test.js`
Test cases:
- Healing at 0 HP → reset counters, isDying=false, isStable=false
- Healing at stable → same reset
- Healing log includes "cleared death saves"
### File: `shared/tests/turn.deathsave.stable.test.js`
Test cases:
- Damage to stable → isStable=false, isDying=true, counters reset
- Stable takes damage → fresh death save loop
### File: `shared/tests/turn.deathsave.undo.test.js`
Test cases:
- Undo deathSave restores previous state
- Undo nat20 restores counters
- Undo damage at 0 HP restores fail count
- Undo stabilize restores dying state
## Phase 2: Combat Test Integration
### Update `shared/tests/turn.combat.test.js`
Add phases:
- Nat20 phase: roll nat20, verify 1 HP + conscious
- Nat1 phase: roll nat1, verify 2 failures
- Damage-at-zero phase: normal damage + crit damage at 0 HP
- Massive damage phase: instant death bypass
- Stabilize phase: stabilize dying character
- Healing-reset phase: heal at 0 HP, verify counters reset
- Assert death save state at each phase
### Update `src/tests/Combat.scenario.test.js`
Add action functions:
- deathSaveActionNat20(name)
- deathSaveActionNat1(name)
- stabilizeAction(name)
- applyDamageAtZero(name, amount, isCrit)
- applyMassiveDamage(name)
- healingResetAction(name, amount)
Add scenario phases:
- Round 10-20: nat20/nat1 scenarios
- Round 30-40: damage-at-zero scenarios (normal + crit)
- Round 50-60: stabilize scenarios
- Round 70-80: massive damage scenarios
- Round 90-100: healing-reset scenarios
- Verify log entries include all death save types
### Update `scripts/combat.js`
Verify death save events:
- Check death_save events in log
- Verify nat20, nat1, damage_at_zero, massive_damage log types
- Verify stabilize events
- Verify healing_reset events
## Phase 3: Extend `deathSave` Function
Add roll result parameter:
```ts
async function deathSave(encounter, participantId, type, n, rollResult, ctx) {
// rollResult: number (1-20) or null for manual input
}
```
Handle nat20:
```ts
if (rollResult === 20) {
updates.hp = 1;
updates.deathSaves = 0;
updates.deathFails = 0;
updates.isDying = false;
updates.isStable = false;
status = 'conscious';
}
```
Handle nat1:
```ts
if (rollResult === 1) {
const currentFails = participant.deathFails || 0;
updates.deathFails = Math.min(3, currentFails + 2);
// Check for death...
}
```
## Phase 4: Extend `applyHpChange` Function
Add damage-at-zero logic:
```ts
if (changeType === 'damage' && participant.currentHp === 0) {
const failuresToAdd = isCritical ? 2 : 1;
const currentFails = participant.deathFails || 0;
updates.deathFails = Math.min(3, currentFails + failuresToAdd);
updates.isStable = false;
// Check for death...
}
```
Add massive damage rule:
```ts
if (changeType === 'damage' && damage >= participant.currentHp + participant.maxHp) {
updates.hp = 0;
updates.isDying = true;
updates.deathSaves = 0;
updates.deathFails = 3;
updates.isStable = false;
}
```
Add healing reset logic:
```ts
if (changeType === 'heal' && (participant.currentHp === 0 || participant.isStable || participant.isDying)) {
updates.deathSaves = 0;
updates.deathFails = 0;
updates.isDying = false;
updates.isStable = false;
}
```
## Phase 5: New `stabilizeParticipant` Function
```ts
async function stabilizeParticipant(encounter, participantId, ctx) {
// hp=0, isStable=true, isDying=false, counters=0
// Log: "Stabilized"
}
```
## Phase 6: UI Extensions
### Phase 6a: Roll Result Modal
Add roll result input:
- Modal when clicking death save button
- Input: d20 result (1-20) or "manual"
- Auto-detect nat20/nat1
### Phase 6b: Nat1/Nat20/Stabilize Buttons
Death save row layout:
```
[Saves: □ □ □] [Fails: □ □ □]
[Nat1] [Nat20] [Stabilize]
```
**Nat1 button:**
- Red icon (AlertTriangle)
- Click → deathSave(participantId, 'fail', null, 1, ctx)
- Adds 2 failures immediately
- Log: "Nat 1! [name] takes 2 death save failures"
- Disable if deathFails >= 2 (would cause death anyway)
**Nat20 button:**
- Gold icon (Zap)
- Click → deathSave(participantId, 'success', null, 20, ctx)
- Sets hp=1, resets counters, conscious
- Log: "Nat 20! [name] restored to 1 HP, conscious"
**Stabilize button:**
- Emerald icon (HeartPulse)
- Only show when dying (hp=0, !isStable, !dead)
- Click → stabilizeParticipant(participantId, ctx)
- Sets isStable=true, counters=0
- Log: "[name] stabilized at 0 HP"
- Hide if already isStable
**Conditional visibility:**
- All buttons: only when hp === 0 && !dead
- Stabilize: hide if already isStable
- Nat1: disable if deathFails >= 2
### Phase 6c: Crit Checkbox for Damage
Add crit checkbox:
- For damage at 0 HP
- Pass crit to applyHpChange
- UI: checkbox "Critical hit?"
### Phase 6d: Enhanced Log Messages
- Nat20: "Nat 20! [name] restored to 1 HP, conscious"
- Nat1: "Nat 1! [name] takes 2 death save failures"
- Damage at 0: "[name] takes [n] damage while dying"
- Massive damage: "Massive damage! [name] instantly killed"
- Stabilize: "[name] stabilized at 0 HP"
- Healing reset: "[name] healed to [n] HP, death saves cleared"
## Phase 7: Integration Tests
File: `shared/tests/turn.deathsave.flow.test.js`
Test full scenarios:
- Dying → nat20 → conscious → take damage → dying
- Dying → nat1 → 2 fails → another fail → dead
- Dying → stabilize → damage → dying again
- Massive damage bypasses all
- Healing resets mid-death-save loop
## Execution Order
1. Phase 1: Write all test files (fail expected)
2. Phase 2: Update combat tests (fail expected)
3. Phase 3: Extend deathSave, run tests
4. Phase 4: Extend applyHpChange, run tests
5. Phase 5: Add stabilizeParticipant, run tests
6. Phase 6a: Roll result modal
7. Phase 6b: Nat1/Nat20/Stabilize buttons
8. Phase 6c: Crit checkbox
9. Phase 6d: Enhanced log messages
10. Phase 7: Integration tests
11. Manual testing: full scenarios in dev server
## Risk Notes
- Roll result input: optional field (DM may not want to input every roll)
- Crit detection: manual checkbox (no way to auto-detect)
- Massive damage: optional rule (some tables ignore)
- Backward compat: existing API must still work with null rollResult
- Nat1/Nat20/Stabilize buttons: DM-optional, not required to use
+9 -3
View File
@@ -1,14 +1,17 @@
# Dockerfile
# Stage 1: Build the React application
FROM node:18-alpine AS build
FROM node:22-alpine AS build
LABEL stage="build-local-testing"
WORKDIR /app
# Copy package.json and package-lock.json (or yarn.lock)
# Copy root package.json/lockfile plus workspace manifests so npm can
# resolve the @ttrpg/shared and server workspace links during install.
COPY package*.json ./
COPY shared/package.json ./shared/
COPY server/package.json ./server/
# Install dependencies using the lock file for consistency
RUN npm install
@@ -38,8 +41,11 @@ LABEL stage="nginx-server"
# Copy the build output from the 'build' stage to Nginx's html directory
COPY --from=build /app/build /usr/share/nginx/html
# Replace default nginx config with one that handles SPA client-side routing
COPY nginx-docker.conf /etc/nginx/conf.d/default.conf
# Expose port 80 (Nginx default)
EXPOSE 80
# Start Nginx when the container launches
CMD ["nginx", "-g", "daemon off;"]
CMD ["nginx", "-g", "daemon off;"]
Vendored
+172
View File
@@ -0,0 +1,172 @@
// Jenkinsfile — build + publish both images to the thinkserver:5000 registry.
//
// ttrpg-tracker-firebase : root Dockerfile, nginx serving the static SPA,
// Firebase storage (config baked in at build time).
// ttrpg-tracker-sqlite : docker/Dockerfile, Caddy + Node, server/SQLite
// storage (REACT_APP_STORAGE=server).
//
// Requirements on the Jenkins agent:
// * Docker CLI available, agent user in the docker group (or DooD socket).
// * thinkserver:5000 reachable and trusted by the Docker daemon. If it is a
// plain-HTTP registry, add it to /etc/docker/daemon.json on the AGENT host:
// { "insecure-registries": ["thinkserver:5000"] } (then restart docker)
//
// Jenkins credentials this pipeline expects:
// * ttrpg-firebase-env-local (Secret file) — the .env.local contents with
// REACT_APP_FIREBASE_* values. Baked into the firebase image at build time.
// * thinkserver-registry (Username/pw) — OPTIONAL. Only needed if the
// registry requires auth; the login stage is guarded below.
pipeline {
agent any
options {
timestamps()
disableConcurrentBuilds()
timeout(time: 30, unit: 'MINUTES')
}
parameters {
string(name: 'TRACKER_APP_ID',
defaultValue: 'ttrpg-initiative-tracker-default',
description: 'Firestore/app namespace baked into the SQLite image (REACT_APP_TRACKER_APP_ID).')
booleanParam(name: 'PUSH_LATEST', defaultValue: true,
description: 'Also tag and push :latest in addition to the commit-SHA tag.')
booleanParam(name: 'REGISTRY_AUTH', defaultValue: false,
description: 'Enable docker login using the thinkserver-registry credential.')
booleanParam(name: 'REDEPLOY_PORTAINER', defaultValue: true,
description: 'After pushing, POST to the Portainer stack webhooks to re-pull and redeploy.')
}
environment {
REGISTRY = 'thinkserver:5000'
FIREBASE_IMAGE = "${REGISTRY}/ttrpg-tracker-firebase"
SQLITE_IMAGE = "${REGISTRY}/ttrpg-tracker-sqlite"
DOCKER_BUILDKIT = '1'
}
stages {
stage('Checkout') {
steps {
checkout scm
script {
env.SHORT_SHA = sh(returnStdout: true, script: 'git rev-parse --short=8 HEAD').trim()
// Only build/push on main. BRANCH_NAME is set by Multibranch jobs; it
// is unset in a plain "Pipeline from SCM" job, which already only ever
// checks out main, so treat unset as main too.
env.IS_MAIN = (env.BRANCH_NAME == null || env.BRANCH_NAME == 'main').toString()
if (env.IS_MAIN != 'true') {
echo "Branch '${env.BRANCH_NAME}' is not main — skipping image build/push."
} else {
echo "Building tag ${env.SHORT_SHA} (build #${env.BUILD_NUMBER})"
}
}
}
}
stage('Registry login') {
when { allOf { expression { env.IS_MAIN == 'true' }; expression { return params.REGISTRY_AUTH } } }
steps {
withCredentials([usernamePassword(credentialsId: 'thinkserver-registry',
usernameVariable: 'REG_USER',
passwordVariable: 'REG_PASS')]) {
sh 'echo "$REG_PASS" | docker login "$REGISTRY" -u "$REG_USER" --password-stdin'
}
}
}
stage('Build firebase image') {
when { expression { env.IS_MAIN == 'true' } }
steps {
// Firebase config is compiled into the static bundle, so it must exist
// at build time. Inject .env.local from the Jenkins secret file, then
// the root Dockerfile's `COPY .env.local .env` picks it up.
withCredentials([file(credentialsId: 'ttrpg-firebase-env-local', variable: 'FIREBASE_ENV')]) {
sh '''
cp "$FIREBASE_ENV" .env.local
docker build \
-f Dockerfile \
-t "$FIREBASE_IMAGE:$SHORT_SHA" \
.
'''
}
}
}
stage('Build sqlite image') {
when { expression { env.IS_MAIN == 'true' } }
steps {
// Server/SQLite build. BuildKit is required (syntax directive + cache
// mount in docker/Dockerfile). Context is the repo root.
sh '''
docker build \
-f docker/Dockerfile \
--build-arg REACT_APP_TRACKER_APP_ID="$TRACKER_APP_ID" \
-t "$SQLITE_IMAGE:$SHORT_SHA" \
.
'''
}
}
stage('Tag latest') {
when { allOf { expression { env.IS_MAIN == 'true' }; expression { return params.PUSH_LATEST } } }
steps {
sh '''
docker tag "$FIREBASE_IMAGE:$SHORT_SHA" "$FIREBASE_IMAGE:latest"
docker tag "$SQLITE_IMAGE:$SHORT_SHA" "$SQLITE_IMAGE:latest"
'''
}
}
stage('Push') {
when { expression { env.IS_MAIN == 'true' } }
steps {
sh '''
docker push "$FIREBASE_IMAGE:$SHORT_SHA"
docker push "$SQLITE_IMAGE:$SHORT_SHA"
'''
script {
if (params.PUSH_LATEST) {
sh '''
docker push "$FIREBASE_IMAGE:latest"
docker push "$SQLITE_IMAGE:latest"
'''
}
}
}
}
stage('Redeploy (Portainer webhooks)') {
when { allOf { expression { env.IS_MAIN == 'true' }; expression { return params.REDEPLOY_PORTAINER } } }
steps {
// Webhook URLs are Secret-text credentials, so they never appear in the
// repo and Jenkins masks them in the console. They are bound to shell
// env vars and referenced inside a single-quoted sh block, so Groovy
// never interpolates the value into the pipeline log.
withCredentials([
string(credentialsId: 'portainer-webhook-firebase', variable: 'WEBHOOK_FIREBASE'),
string(credentialsId: 'portainer-webhook-sqlite', variable: 'WEBHOOK_SQLITE'),
]) {
sh '''
set +x
echo "Triggering Portainer redeploy (firebase stack)"
curl -fsS -X POST --retry 3 --retry-delay 5 "$WEBHOOK_FIREBASE"
echo "Triggering Portainer redeploy (sqlite stack)"
curl -fsS -X POST --retry 3 --retry-delay 5 "$WEBHOOK_SQLITE"
'''
}
}
}
}
post {
always {
// Never leave Firebase secrets in the workspace or log out cleanly.
sh 'rm -f .env.local || true'
sh 'docker logout "$REGISTRY" || true'
}
success {
echo "Pushed ${FIREBASE_IMAGE}:${SHORT_SHA} and ${SQLITE_IMAGE}:${SHORT_SHA}"
}
}
}
+437
View File
@@ -0,0 +1,437 @@
Attribution-NonCommercial-ShareAlike 4.0 International
=======================================================================
Creative Commons Corporation ("Creative Commons") is not a law firm and
does not provide legal services or legal advice. Distribution of
Creative Commons public licenses does not create a lawyer-client or
other relationship. Creative Commons makes its licenses and related
information available on an "as-is" basis. Creative Commons gives no
warranties regarding its licenses, any material licensed under their
terms and conditions, or any related information. Creative Commons
disclaims all liability for damages resulting from their use to the
fullest extent possible.
Using Creative Commons Public Licenses
Creative Commons public licenses provide a standard set of terms and
conditions that creators and other rights holders may use to share
original works of authorship and other material subject to copyright
and certain other rights specified in the public license below. The
following considerations are for informational purposes only, are not
exhaustive, and do not form part of our licenses.
Considerations for licensors: Our public licenses are
intended for use by those authorized to give the public
permission to use material in ways otherwise restricted by
copyright and certain other rights. Our licenses are
irrevocable. Licensors should read and understand the terms
and conditions of the license they choose before applying it.
Licensors should also secure all rights necessary before
applying our licenses so that the public can reuse the
material as expected. Licensors should clearly mark any
material not subject to the license. This includes other CC-
licensed material, or material used under an exception or
limitation to copyright. More considerations for licensors:
wiki.creativecommons.org/Considerations_for_licensors
Considerations for the public: By using one of our public
licenses, a licensor grants the public permission to use the
licensed material under specified terms and conditions. If
the licensor's permission is not necessary for any reason--for
example, because of any applicable exception or limitation to
copyright--then that use is not regulated by the license. Our
licenses grant only permissions under copyright and certain
other rights that a licensor has authority to grant. Use of
the licensed material may still be restricted for other
reasons, including because others have copyright or other
rights in the material. A licensor may make special requests,
such as asking that all changes be marked or described.
Although not required by our licenses, you are encouraged to
respect those requests where reasonable. More considerations
for the public:
wiki.creativecommons.org/Considerations_for_licensees
=======================================================================
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
Public License
By exercising the Licensed Rights (defined below), You accept and agree
to be bound by the terms and conditions of this Creative Commons
Attribution-NonCommercial-ShareAlike 4.0 International Public License
("Public License"). To the extent this Public License may be
interpreted as a contract, You are granted the Licensed Rights in
consideration of Your acceptance of these terms and conditions, and the
Licensor grants You such rights in consideration of benefits the
Licensor receives from making the Licensed Material available under
these terms and conditions.
Section 1 -- Definitions.
a. Adapted Material means material subject to Copyright and Similar
Rights that is derived from or based upon the Licensed Material
and in which the Licensed Material is translated, altered,
arranged, transformed, or otherwise modified in a manner requiring
permission under the Copyright and Similar Rights held by the
Licensor. For purposes of this Public License, where the Licensed
Material is a musical work, performance, or sound recording,
Adapted Material is always produced where the Licensed Material is
synched in timed relation with a moving image.
b. Adapter's License means the license You apply to Your Copyright
and Similar Rights in Your contributions to Adapted Material in
accordance with the terms and conditions of this Public License.
c. BY-NC-SA Compatible License means a license listed at
creativecommons.org/compatiblelicenses, approved by Creative
Commons as essentially the equivalent of this Public License.
d. Copyright and Similar Rights means copyright and/or similar rights
closely related to copyright including, without limitation,
performance, broadcast, sound recording, and Sui Generis Database
Rights, without regard to how the rights are labeled or
categorized. For purposes of this Public License, the rights
specified in Section 2(b)(1)-(2) are not Copyright and Similar
Rights.
e. Effective Technological Measures means those measures that, in the
absence of proper authority, may not be circumvented under laws
fulfilling obligations under Article 11 of the WIPO Copyright
Treaty adopted on December 20, 1996, and/or similar international
agreements.
f. Exceptions and Limitations means fair use, fair dealing, and/or
any other exception or limitation to Copyright and Similar Rights
that applies to Your use of the Licensed Material.
g. License Elements means the license attributes listed in the name
of a Creative Commons Public License. The License Elements of this
Public License are Attribution, NonCommercial, and ShareAlike.
h. Licensed Material means the artistic or literary work, database,
or other material to which the Licensor applied this Public
License.
i. Licensed Rights means the rights granted to You subject to the
terms and conditions of this Public License, which are limited to
all Copyright and Similar Rights that apply to Your use of the
Licensed Material and that the Licensor has authority to license.
j. Licensor means the individual(s) or entity(ies) granting rights
under this Public License.
k. NonCommercial means not primarily intended for or directed towards
commercial advantage or monetary compensation. For purposes of
this Public License, the exchange of the Licensed Material for
other material subject to Copyright and Similar Rights by digital
file-sharing or similar means is NonCommercial provided there is
no payment of monetary compensation in connection with the
exchange.
l. Share means to provide material to the public by any means or
process that requires permission under the Licensed Rights, such
as reproduction, public display, public performance, distribution,
dissemination, communication, or importation, and to make material
available to the public including in ways that members of the
public may access the material from a place and at a time
individually chosen by them.
m. Sui Generis Database Rights means rights other than copyright
resulting from Directive 96/9/EC of the European Parliament and of
the Council of 11 March 1996 on the legal protection of databases,
as amended and/or succeeded, as well as other essentially
equivalent rights anywhere in the world.
n. You means the individual or entity exercising the Licensed Rights
under this Public License. Your has a corresponding meaning.
Section 2 -- Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License,
the Licensor hereby grants You a worldwide, royalty-free,
non-sublicensable, non-exclusive, irrevocable license to
exercise the Licensed Rights in the Licensed Material to:
a. reproduce and Share the Licensed Material, in whole or
in part, for NonCommercial purposes only; and
b. produce, reproduce, and Share Adapted Material for
NonCommercial purposes only.
2. Exceptions and Limitations. For the avoidance of doubt, where
Exceptions and Limitations apply to Your use, this Public
License does not apply, and You do not need to comply with
its terms and conditions.
3. Term. The term of this Public License is specified in Section
6(a).
4. Media and formats; technical modifications allowed. The
Licensor authorizes You to exercise the Licensed Rights in
all media and formats whether now known or hereafter created,
and to make technical modifications necessary to do so. The
Licensor waives and/or agrees not to assert any right or
authority to forbid You from making technical modifications
necessary to exercise the Licensed Rights, including
technical modifications necessary to circumvent Effective
Technological Measures. For purposes of this Public License,
simply making modifications authorized by this Section 2(a)
(4) never produces Adapted Material.
5. Downstream recipients.
a. Offer from the Licensor -- Licensed Material. Every
recipient of the Licensed Material automatically
receives an offer from the Licensor to exercise the
Licensed Rights under the terms and conditions of this
Public License.
b. Additional offer from the Licensor -- Adapted Material.
Every recipient of Adapted Material from You
automatically receives an offer from the Licensor to
exercise the Licensed Rights in the Adapted Material
under the conditions of the Adapter's License You apply.
c. No downstream restrictions. You may not offer or impose
any additional or different terms or conditions on, or
apply any Effective Technological Measures to, the
Licensed Material if doing so restricts exercise of the
Licensed Rights by any recipient of the Licensed
Material.
6. No endorsement. Nothing in this Public License constitutes or
may be construed as permission to assert or imply that You
are, or that Your use of the Licensed Material is, connected
with, or sponsored, endorsed, or granted official status by,
the Licensor or others designated to receive attribution as
provided in Section 3(a)(1)(A)(i).
b. Other rights.
1. Moral rights, such as the right of integrity, are not
licensed under this Public License, nor are publicity,
privacy, and/or other similar personality rights; however, to
the extent possible, the Licensor waives and/or agrees not to
assert any such rights held by the Licensor to the limited
extent necessary to allow You to exercise the Licensed
Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this
Public License.
3. To the extent possible, the Licensor waives any right to
collect royalties from You for the exercise of the Licensed
Rights, whether directly or through a collecting society
under any voluntary or waivable statutory or compulsory
licensing scheme. In all other cases the Licensor expressly
reserves any right to collect such royalties, including when
the Licensed Material is used other than for NonCommercial
purposes.
Section 3 -- License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the
following conditions.
a. Attribution.
1. If You Share the Licensed Material (including in modified
form), You must:
a. retain the following if it is supplied by the Licensor
with the Licensed Material:
i. identification of the creator(s) of the Licensed
Material and any others designated to receive
attribution, in any reasonable manner requested by
the Licensor (including by pseudonym if
designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of
warranties;
v. a URI or hyperlink to the Licensed Material to the
extent reasonably practicable;
b. indicate if You modified the Licensed Material and
retain an indication of any previous modifications; and
c. indicate the Licensed Material is licensed under this
Public License, and include the text of, or the URI or
hyperlink to, this Public License.
2. You may satisfy the conditions in Section 3(a)(1) in any
reasonable manner based on the medium, means, and context in
which You Share the Licensed Material. For example, it may be
reasonable to satisfy the conditions by providing a URI or
hyperlink to a resource that includes the required
information.
3. If requested by the Licensor, You must remove any of the
information required by Section 3(a)(1)(A) to the extent
reasonably practicable.
b. ShareAlike.
In addition to the conditions in Section 3(a), if You Share
Adapted Material You produce, the following conditions also apply.
1. The Adapter's License You apply must be a Creative Commons
license with the same License Elements, this version or
later, or a BY-NC-SA Compatible License.
2. You must include the text of, or the URI or hyperlink to, the
Adapter's License You apply. You may satisfy this condition
in any reasonable manner based on the medium, means, and
context in which You Share Adapted Material.
3. You may not offer or impose any additional or different terms
or conditions on, or apply any Effective Technological
Measures to, Adapted Material that restrict exercise of the
rights granted under the Adapter's License You apply.
Section 4 -- Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that
apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
to extract, reuse, reproduce, and Share all or a substantial
portion of the contents of the database for NonCommercial purposes
only;
b. if You include all or a substantial portion of the database
contents in a database in which You have Sui Generis Database
Rights, then the database in which You have Sui Generis Database
Rights (but not its individual contents) is Adapted Material,
including for purposes of Section 3(b); and
c. You must comply with the conditions in Section 3(a) if You Share
all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not
replace Your obligations under this Public License where the Licensed
Rights include other Copyright and Similar Rights.
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
c. The disclaimer of warranties and limitation of liability provided
above shall be interpreted in a manner that, to the extent
possible, most closely approximates an absolute disclaimer and
waiver of all liability.
Section 6 -- Term and Termination.
a. This Public License applies for the term of the Copyright and
Similar Rights licensed here. However, if You fail to comply with
this Public License, then Your rights under this Public License
terminate automatically.
b. Where Your right to use the Licensed Material has terminated under
Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided
it is cured within 30 days of Your discovery of the
violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any
right the Licensor may have to seek remedies for Your violations
of this Public License.
c. For the avoidance of doubt, the Licensor may also offer the
Licensed Material under separate terms or conditions or stop
distributing the Licensed Material at any time; however, doing so
will not terminate this Public License.
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
License.
Section 7 -- Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different
terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the
Licensed Material not stated herein are separate from and
independent of the terms and conditions of this Public License.
Section 8 -- Interpretation.
a. For the avoidance of doubt, this Public License does not, and
shall not be interpreted to, reduce, limit, restrict, or impose
conditions on any use of the Licensed Material that could lawfully
be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is
deemed unenforceable, it shall be automatically reformed to the
minimum extent necessary to make it enforceable. If the provision
cannot be reformed, it shall be severed from this Public License
without affecting the enforceability of the remaining terms and
conditions.
c. No term or condition of this Public License will be waived and no
failure to comply consented to unless expressly agreed to by the
Licensor.
d. Nothing in this Public License constitutes or may be interpreted
as a limitation upon, or waiver of, any privileges and immunities
that apply to the Licensor or You, including from the legal
processes of any jurisdiction or authority.
=======================================================================
Creative Commons is not a party to its public
licenses. Notwithstanding, Creative Commons may elect to apply one of
its public licenses to material it publishes and in those instances
will be considered the “Licensor.” The text of the Creative Commons
public licenses is dedicated to the public domain under the CC0 Public
Domain Dedication. Except for the limited purpose of indicating that
material is shared under a Creative Commons public license or as
otherwise permitted by the Creative Commons policies published at
creativecommons.org/policies, Creative Commons does not authorize the
use of the trademark "Creative Commons" or any other trademark or logo
of Creative Commons without its prior written consent including,
without limitation, in connection with any unauthorized modifications
to any of its public licenses or any other arrangements,
understandings, or agreements concerning use of licensed material. For
the avoidance of doubt, this paragraph does not form part of the
public licenses.
Creative Commons may be contacted at creativecommons.org.
+153 -31
View File
@@ -1,15 +1,23 @@
# TTRPG Initiative Tracker (v0.2.1)
# TTRPG Initiative Tracker (v0.4)
This application is the result of not having the exact tool I want to use, and a few sessions of [vibe-coding](https://www.youtube.com/watch?v=Tw18-4U7mts) with [Google Gemini](https://developers.google.com/gemini-code-assist/docs/overview).
![Here it is in use](images/in_use.png)
This application is the result of not having the exact tool I want to use, and after a few sessions of [vibe-coding](https://www.youtube.com/watch?v=Tw18-4U7mts) with [Google Gemini](https://developers.google.com/gemini-code-assist/docs/overview), here it is!.
**Use at your own risk.**
A web-based application designed to help Dungeon Masters (DMs) manage and display combat initiative for tabletop role-playing games (TTRPGs). It features a DM admin interface for controlling encounters and a separate player display view suitable for an external monitor.
![](images/initiative_tracker.png)
Have you tried it? Got feedback or questions? Discuss here: [https://discourse.draft13.com/c/ttrpg-initiative-tracker/16](https://discourse.draft13.com/c/ttrpg-initiative-tracker/16)
As of v0.4, the app can optionally run against a self-hosted backend (Node/Express/ws/SQLite, shipped as a single Docker container) instead of Firebase — useful if you'd rather not depend on a Google account. Firebase remains the default; see [Self-Hosted Backend (Optional)](#self-hosted-backend-optional) below.
## Features
![DM View.](images/dm_view.png)
* **Campaign Management:**
* Create campaigns to organize game sessions.
* Option to set a custom background image URL for the player display on a per-campaign basis.
@@ -17,6 +25,7 @@ Have you tried it? Got feedback or questions? Discuss here: [https://discourse.d
* **Character Management:**
* Add and manage characters (player characters) within each campaign.
* Set default Max HP and default Initiative Modifier for each campaign character.
* The character list is collapsible to save screen space.
* **Encounter Management:**
* Create multiple encounters per campaign.
* Add characters from the campaign roster (with auto-rolled initiative based on their modifier and HP pre-filled from campaign defaults) or add custom monsters.
@@ -24,28 +33,50 @@ Have you tried it? Got feedback or questions? Discuss here: [https://discourse.d
* "Add All Campaign Characters" button for quickly populating encounters with initiative rolls.
* DM controls to start, pause/resume, advance turns, and end encounters.
* Visual feedback for rolled initiative when adding individual participants.
* Participants can be added mid-combat while the encounter is paused; the turn order recalculates on resume.
* **HP Tracking:**
* Apply damage or healing to any participant during combat directly from the DM view.
* When a participant reaches 0 HP they are automatically marked inactive and shown with a skull (☠️) icon.
* Healing a dead participant above 0 HP revives them and reactivates them in the turn order.
* **Death Save Tracking:**
* When a player character reaches 0 HP, three death save checkboxes appear in the DM view.
* Marking the third death save triggers a dissolve animation on the player display, then removes the participant from the encounter.
* **Conditions:**
* Apply any of 22 conditions to a participant, including all standard D&D conditions plus several extras: Alchemist Fire 🔥, Bardic Inspiration 🎵, Blinded 🙈, Charmed 💘, Deafened 🔇, Exhaustion 😴, Frightened 😱, Grappled 🤜, Grazed 🩹, Incapacitated 💫, Invisible 👻, Paralyzed ⚡, Petrified 🗿, Poisoned 🤢, Prone ⬇️, Restrained 🕸️, Sapped 🔨, Shield 🛡️, Slowed 🐌, Stunned 💥, Unconscious 💤, Vexed 🎯.
* Active conditions are shown as emoji badges on both the DM view and the player display.
* Conditions can be toggled directly from the badge or from an expandable condition picker per participant.
* **Player Display:**
* A clean interface showing the current initiative order, participant HP (monster HP totals are hidden, only the bar is shown), and current turn.
* NPCs (monster-type) are visually distinct from hostile monsters.
* Displays custom campaign background if set.
* Shows a "Game Session Paused" message when no encounter is active or if the current encounter is paused by the DM.
* Player display is opened in a separate window via a button in the DM's header.
* **Real-time Updates:** Uses Firebase Firestore for real-time synchronization between DM actions and the player display.
* A clean interface showing the current initiative order, round number, participant HP, and current turn.
* HP bars use color-coded thresholds: green (above half), yellow (quarter to half), red (below quarter), dark red (dead).
* Monster and NPC HP totals are hidden; only the color-coded bar is shown.
* Player character HP can be hidden from the player display via a toggle in the DM's initiative controls ("Hide player HP"). When enabled, only the color-coded bar is shown for characters as well.
* Active conditions are displayed as emoji badges on each participant's card.
* Inactive participants (and dead ones) are greyed out with a grayscale filter.
* Inactive monsters (e.g. pre-staged or summoned reserves) are hidden from the player display entirely; inactive player characters still appear greyed out.
* The current participant's card auto-scrolls into view when the turn advances.
* NPCs are visually distinct from hostile monsters, which are distinct from player characters.
* Displays a custom campaign background image if one is set.
* Shows a "Game Session Paused" message when no encounter is active, or "(Combat Paused)" when the DM pauses a running encounter.
* Player display is opened in a separate window via the "Open Player Window" button in the DM's header.
* A **fullscreen button** (top-right corner) toggles the browser into fullscreen mode — ideal for a dedicated second monitor.
* A **prevent sleep toggle** (moon/coffee icon, top-right corner) uses the browser Wake Lock API to keep the screen on while active.
* **Combat Action Log:** A running log of combat events (HP changes, condition changes, turn advances, participant additions/removals, encounter starts/ends, etc.) is available at `/logs`. Entries are timestamped and tagged with the encounter name. Most entries include an **↩ Undo** button that rolls back the action in Firestore (restoring HP, conditions, turn order, etc.). Rolled-back entries are greyed out with a strikethrough. The log can be cleared in bulk from that page.
* **Real-time Updates:** Uses Firebase Firestore for real-time synchronization between DM actions and the player display (or a self-hosted WebSocket backend, see below).
* **Initiative Tie-Breaking:** DMs can drag-and-drop participants with tied initiative scores (before an encounter starts or while paused) to set a manual order.
* **Responsive Design:** Styled with Tailwind CSS.
* **Confirmation Modals:** Implemented for destructive actions like deleting campaigns, characters, encounters, or ending combat.
* **Confirmation Modals:** Used for destructive actions like deleting campaigns, characters, encounters, or ending combat.
## Tech Stack
* **Frontend:** React
* **Styling:** Tailwind CSS
* **Backend/Database:** Firebase Firestore (for real-time data)
* **Authentication:** Firebase Anonymous Authentication
* **Backend/Database:** Firebase Firestore (default), or a self-hosted Node/Express + `ws` + SQLite (`better-sqlite3`) backend behind Caddy (optional, see [Self-Hosted Backend](#self-hosted-backend-optional))
* **Authentication:** Firebase Anonymous Authentication (Firebase mode only; the self-hosted backend has no auth layer — intended for trusted/local networks)
* **Shared logic:** Turn-order state machine (`shared/`) is framework-agnostic and used by both the frontend and the self-hosted backend, covered by a Jest test suite
## App Usage Overview
![DM View Screenshot Placeholder](images/dm_view.png)
*(Replace with an actual screenshot of your DM view)*
![](images/running_combat.png)
The TTRPG Initiative Tracker is designed for Dungeon Masters to manage combat encounters and display the initiative order to players. Here's a typical workflow:
@@ -65,16 +96,33 @@ The TTRPG Initiative Tracker is designed for Dungeon Masters to manage combat en
* Once participants are added and initiative is set, click "Start Encounter". This also automatically makes the encounter live on the Player Display.
* Use the "Pause" button to temporarily halt combat. While paused, you can adjust HP and re-order tied initiatives. Click "Resume" to continue.
* Use the "Next Turn" button (disabled when paused) to advance through the initiative order. The current combatant will be highlighted.
* Apply damage or healing to participants directly in the Admin View.
* Mark participants as inactive (e.g., if knocked out) using the toggle next to their name.
* Apply damage or healing to participants directly in the Admin View. Participants at 0 HP are automatically deactivated and marked with a skull icon. Healing them above 0 HP revives them.
* For player characters at 0 HP, track death saving throws using the three checkboxes that appear. Marking the third failure triggers a death animation on the player display and removes the participant.
* Apply or remove conditions (Blinded, Charmed, Poisoned, etc.) using the ✨ button next to each participant. Active conditions appear as emoji badges on both the DM view and the player display.
* Mark participants as inactive (e.g., if knocked out) using the toggle next to their name. Inactive monsters are hidden from the player display entirely, making this useful for pre-staging summoned reserves.
* Toggle **Hide player HP** in the initiative controls to show or hide player character HP values on the player display.
* Click "End Encounter" (with confirmation) when combat is over. This also deactivates it from the Player Display.
2. **Player Display Window:**
* This window (opened by the DM via `/?playerView=true` URL) shows a simplified, header-less view of the active encounter.
* It displays the initiative order, current turn, round number, and participant HP (monster/NPC HP values are hidden, only the bar is shown).
* NPCs are styled with a muted gray background, distinct from hostile monsters (custom reddish-brown) and player characters (blueish).
* If a custom background URL was set for the campaign, it will be displayed.
* If no encounter is active on the Player Display, or if the current encounter is paused by the DM, it will show an appropriate message ("Game Session Paused" or "Combat Paused").
* This window (opened by the DM via the "Open Player Window" button, or accessed directly at `/display` or `/?playerView=true`) shows a simplified, header-less view of the active encounter.
* It displays the initiative order, current turn, round number, and participant HP bars. Monster/NPC HP totals are hidden; only the color-coded bar is shown. Player character HP is shown numerically unless the DM has enabled "Hide player HP".
* HP bars are color-coded: green (above 50%), yellow (2550%), red (below 25%), dark red (dead/0 HP).
* Active conditions are shown as emoji badges on each participant's card.
* The current participant's card automatically scrolls into view when the turn advances.
* NPCs are styled with a muted gray background, distinct from hostile monsters (reddish-brown) and player characters (dark blue/indigo).
* Dead and inactive participants are greyed out with a grayscale filter. Inactive monsters are hidden entirely.
* When a player character's third death save is marked, a dissolve animation plays on the display before the participant is removed.
* If a custom background URL was set for the campaign, it will be displayed with a semi-transparent overlay behind the participant cards.
* If no encounter is active on the Player Display, it shows "Game Session Paused". If the current encounter is paused by the DM, it shows "(Combat Paused)".
* Use the **fullscreen button** (top-right) to go fullscreen — great for a dedicated second monitor. Press Escape or click again to exit.
* Use the **prevent sleep toggle** (moon icon, top-right) to keep the screen awake using the browser Wake Lock API. The icon turns amber and switches to a coffee cup when active.
3. **Combat Log (`/logs`):**
* A dedicated page that records all significant combat events: encounter starts and ends, turn advances, HP changes, condition changes, participant additions/removals, and activation toggles.
* Each entry is timestamped and tagged with the encounter name for easy reference.
* Most entries have an **↩ Undo** button. Clicking it rolls back that specific action in Firestore — restoring HP, conditions, turn order, or participant state to what it was before. The entry is then greyed out and marked "rolled back".
* The log can be cleared in bulk from the page (with confirmation).
* Accessible via the "Logs" link in the DM header or directly at `/logs`.
This flow allows the DM to prepare and run encounters efficiently while providing a clear, real-time view for the players.
@@ -83,16 +131,18 @@ This flow allows the DM to prepare and run encounters efficiently while providin
### Prerequisites
* **Node.js and npm:** Ensure you have Node.js (which includes npm) installed. You can download it from [nodejs.org](https://nodejs.org/).
* **Firebase Project:** You'll need a Firebase project with:
* **Firebase Project** (only if using the default Firebase storage mode): You'll need a Firebase project with:
* Firestore Database created and initialized.
* Anonymous Authentication enabled in the "Authentication" > "Sign-in method" tab.
* **Git:** For cloning the repository.
The project is an npm workspaces monorepo (`server/`, `shared/`, plus the CRA frontend at the root) — a single `npm install` at the repo root installs everything.
### Local Development Setup (using npm)
1. **Clone the Repository:**
```bash
git clone <your-repository-url>
git clone https://code.draft13.com/robert/ttrpg-initiative-tracker
cd ttrpg-initiative-tracker
```
@@ -145,7 +195,14 @@ This flow allows the DM to prepare and run encounters efficiently while providin
### Deployment with Docker
This project includes a `Dockerfile` to containerize the application for deployment. It uses a multi-stage build:
There are two Docker paths, depending on which storage mode you want:
* **Firebase mode (default):** the root `Dockerfile` builds a static frontend-only image, described below.
* **Self-hosted mode:** the `docker/` directory builds a single container running the Node backend + SQLite + the frontend behind Caddy, with no Firebase dependency. See [Self-Hosted Backend (Optional)](#self-hosted-backend-optional).
#### Firebase-mode image (root `Dockerfile`)
This project includes a `Dockerfile` to containerize the Firebase-backed application for deployment. It uses a multi-stage build:
* **Stage 1 (build):** Installs dependencies, copies your `.env.local` (for local testing builds), and builds the static React application using `npm run build`.
* **Stage 2 (nginx):** Uses an Nginx server to serve the static files produced in the build stage.
@@ -180,31 +237,96 @@ This project includes a `Dockerfile` to containerize the application for deploym
* If your CI/CD pipeline builds the Docker image, ensure these environment variables are securely provided to the build environment.
* **Implement strict Firebase Security Rules** appropriate for a production application to protect your data.
### Self-Hosted Backend (Optional)
If you'd rather not depend on Firebase/Google, the app can run entirely self-hosted: a Node/Express + `ws` backend backed by SQLite, with the frontend talking to it instead of Firestore. Intended for trusted/local networks (no auth layer yet).
**Quickest path — Docker Compose (single container: Caddy + Node + SQLite):**
```bash
docker compose -f docker/docker-compose.yml up --build
```
This serves the app at `http://localhost:8080` (override with `PORT`), proxies `/api` and `/ws` to the backend, and persists the SQLite database in a named Docker volume. Override the Firestore-style app-id namespace with `TRACKER_APP_ID` if needed.
**Local dev (recommended):**
```bash
npm install # installs root, server/, and shared/ workspaces
./scripts/dev-start.sh # backend :4001 + frontend :3999, server mode
./scripts/dev-stop.sh # stop both
```
**Manual (fallback):**
```bash
npm install
npm run server:dev # starts backend on :4001 (SQLite at data/tracker.sqlite)
# in another terminal:
REACT_APP_STORAGE=server \
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
REACT_APP_BACKEND_REALTIME_URL=ws://127.0.0.1:4001/ws \
npm start
```
See `docs/DEVELOPMENT.md` for the full architecture (generic KV doc store, storage adapter interface, test layers) and `docs/REWORK_PLAN.md` for the design rationale.
## Project Structure
<pre>
ttrpg-initiative-tracker/
├── .dockerignore # Specifies intentionally untracked files that Docker should ignore
├── .env.example # Example environment variables
├── .env.local # Local environment variables (ignored by Git)
├── .gitignore # Specifies intentionally untracked files that Git should ignore
├── Dockerfile # Instructions to build the Docker image
├── Dockerfile # Firebase-mode image (frontend-only, served by nginx)
├── package.json # Workspaces root: frontend + server + shared
├── package-lock.json # Records exact versions of dependencies
├── package.json # Project metadata and dependencies
├── postcss.config.js # PostCSS configuration (for Tailwind CSS)
├── tailwind.config.js # Tailwind CSS configuration
├── docker/ # Self-hosted deployment: Dockerfile, docker-compose.yml, Caddyfile
├── docs/ # Rework plan, dev setup, testing, encounter-builder guide, glossary
├── scripts/ # Manual demo/ops tooling (e.g. replay-combat.js)
├── tests/ # Exploratory audit tooling (not part of the automated suite)
├── public/ # Static assets
│ ├── favicon.ico
│ ├── index.html # Main HTML template
│ └── manifest.json
── src/ # React application source code
├── App.js # Main application component
├── index.css # Global styles (including Tailwind directives)
── index.js # React entry point
── src/ # React frontend (Create React App)
├── App.js # Main application component
│ ├── storage/ # Storage adapter layer: firebase / ws / memory
│ ├── index.css # Global styles (including Tailwind directives)
│ └── index.js # React entry point
├── server/ # Self-hosted backend: Express + ws + SQLite (better-sqlite3)
└── shared/ # Framework-agnostic turn-order logic, used by frontend + server
</pre>
## Further Reading
* `docs/DEVELOPMENT.md` — setup, running the backend, test suites
* `docs/REWORK_PLAN.md` — why/how the self-hosted backend was added
* `docs/TESTING.md` — test layers and how to run them
* `docs/ENCOUNTER_BUILDER.md` — entity model and storage paths behind the DM interface
* `docs/GLOSSARY.md` — domain terms (turn vs. round, etc.)
* `TODO.md` — known bugs and backlog
## Prevent Sleep (Wake Lock)
DM and Player views have a Coffee/Moon toggle to keep screen awake during combat.
**Requires secure context** (HTTPS or localhost). The Screen Wake Lock API refuses
on plain HTTP.
- **Prod (HTTPS):** works automatically on all devices.
- **Local dev (localhost):** works automatically — localhost is a trusted origin.
- **LAN IP (phones, plain HTTP):** wake lock fails silently. Two options:
1. **Android Chrome flag (dev testing):** `chrome://flags/#unsafely-treat-insecure-origin-as-secure` → add full URL with port, e.g. `http://10.0.0.5:3999`. Enable flag, relaunch.
2. **mkcert local CA:** generate trusted cert for LAN IP. Heavier setup.
**iOS Safari:** Wake Lock API broken in standalone PWA mode (iOS 16.418.4, WebKit bug 254545). Fixed in 18.4+. Works in browser tab (not installed PWA).
**Troubleshooting:** if toggle shows active but screen still sleeps, check:
- Battery saver / power save mode (browser refuses)
- Chrome devtools console for `WakeLockError`
- Secure context: `window.isSecureContext` must be `true`
## Contributing
If you want to contribute, send me a message here: [https://discourse.draft13.com/c/ttrpg-initiative-tracker/16](https://discourse.draft13.com/c/ttrpg-initiative-tracker/16), and I can add to this Gitea instance and you can feel free to fork the repository and submit pull requests. For major changes, please pose a topic to the Discourse instance above linked above first to discuss what you would like to change.
+104
View File
@@ -0,0 +1,104 @@
# TODO
Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
## Open
x caff doent work on fron tpage or big tablet
x max/caff need to float or at least be availabe in combagt popout
x save needs bigger save button on char editor
x fullscreen and dont lock on main app dm view and the no-game-player view ...and doesnt actually prevent lock on android
x also better vert tab layout - labelt friendly
x needs AC for players dude
x and quick entry hp
x hp do not carry from encounter to ecnounter!!!
^feature to add back to campaign character after encounter?
maybe campaign toggle in charc section (choosing each end is DM overload will be missed forgotten done wront)
x refresh/reload/code update causes UI to update to top unselected cambpaign have to drill all the way back down to active encounter....every time. siemtmes?
x encounters cards need clear "in progress"...
x I wonder...if a charcter-list-character should have a isNPC option vailble to them - for longer lived
npcs.....
x hp wont go over max and no temp hp support
## monsters per campaign and npcs
## or/and ....copy from encoubnter?
### npm install warnings cleanup pass
lots of updates
### TEST GAP: current branch changes need focused coverage
- Storage `where()` contract: firebase + server adapters should honor `[where('encounterPath','==',x), orderBy('ts','desc'), limit(n)]`.
- Server SQL query test: `where + orderBy + limit` should return latest logs for one encounter only.
- Undo/redo stack order: undo A3 then A2, redo must replay A2 first, then A3.
- Combat controls should not subscribe to logs while mounted; undo/redo should query logs only on click.
- Unified CLI smoke: `node scripts/combat.js verify <fixture.json>` returns CLEAN on known-good log.
- Unified CLI replay smoke: `node scripts/combat.js replay ... --out tmp/x.json` writes JSON array and auto-verifies.
- Ctrl-C replay behavior: SIGINT during replay should end encounter, clear active display, write partial log, run verify.
- SQLite schema/index test: `idx_docs_parent_ts` and `idx_docs_parent_encounter_ts` exist for server DB.
### confirm warnings treated as error = fail in all tests, build pipeline, linters, everything. again.
### BUG: addParticipants (batch add) does not slot by initiative
- shared/turn.js addParticipants appends `[...existing, ...new]`, no slotIndexForInit.
- Violates INIT doc: "Add = insert into slot by initiative."
- Single addParticipant slots correct. Batch (add-all-chars) appends.
- Pre-start batch add = wrong order. Post-start worse.
### BUG: nextTurn throws on solo combatant
- nextActiveAfter loop `for step=1; step<n` skips when n=1 → {nextId:null}.
- nextTurn throws "Could not determine next participant."
- Solo active combat cannot pass turn.
### BUG: reorderParticipants cross-pointer drag = silent no-op
- Cross-pointer drag returns encounter unchanged, no log, no toast.
- DM drags across current turn → nothing happens, no feedback.
### BUG: addParticipant undo missing currentTurnParticipantId when started
- undo saves participants + conditional turnOrderIds, no currentTurnParticipantId.
- Pointer can misalign on undo if added near pointer region.
### BUG: computeTurnOrderAfterRemoval isActive uses find() not boolean
- `isActive = id => updatedParticipants.find(p => p.id === id && p.isActive)`
- Returns participant obj (truthy) not boolean. Works by accident, fragile.
### FEAT: clarify "Is NPC" in add-participant
- Ambiguous label. May expand work based on what NPC means here (ally? monster?
display-only? skip in turn order?). Clarify intent before UX changes.
### quality of life fix: 2. UI says "Campaign Characters", field is players --- naming mismatch (separate concern, flag for later)
## FEAT - parallel campaigns
## FEAT - multi user
## FEAT - clarify what end encounter does and what initiatives reset means
## Done (history)
+39
View File
@@ -0,0 +1,39 @@
# Ignore Git directory
.git
.gitignore
# Ignore Node.js modules (they will be installed in the Docker image)
node_modules
**/node_modules
# Ignore build output (it will be generated in the Docker image)
build
dist
# Ignore Docker files themselves (Caddyfile MUST stay in context for frontend build)
Dockerfile
Dockerfile.ws
.dockerignore
docker-compose.yml
# Ignore any local environment files if you have them
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Ignore IDE and OS-specific files
.vscode/
.idea
*.suo
*.user
*.userosscache
*.sln.docstates
Thumbs.db
.DS_Store
# Ignore local sqlite data + scratch diagnostics (never bake into image)
data/
scratch/
tmp/
+18
View File
@@ -0,0 +1,18 @@
# Caddyfile — single-container (caddy + node)
# Caddy serves built frontend, proxies /api + /ws to node backend on :4001.
# Node never exposed directly; only caddy on :80.
:80 {
handle /api/* {
reverse_proxy 127.0.0.1:4001
}
handle /ws {
reverse_proxy 127.0.0.1:4001
}
# catch-all: static frontend (SPA fallback)
handle {
root * /srv
try_files {path} /index.html
file_server
}
}
+55
View File
@@ -0,0 +1,55 @@
# syntax=docker/dockerfile:1
# docker/Dockerfile --- single container: caddy (front) + node (back).
# Build context = repo root.
# ---- build stage: frontend + install backend deps ----
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
COPY shared/package.json ./shared/
COPY server/package.json ./server/
RUN --mount=type=cache,target=/root/.npm \
npm install --include-workspace-root --omit=dev --prefer-offline
# frontend code (changes often)
COPY shared/ ./shared/
COPY src/ ./src/
COPY public/ ./public/
COPY tailwind.config.js postcss.config.js ./
# build frontend (server storage, same-origin /api + /ws via caddy)
ARG REACT_APP_TRACKER_APP_ID=ttrpg-initiative-tracker-default
ENV REACT_APP_STORAGE=server
ENV REACT_APP_TRACKER_APP_ID=$REACT_APP_TRACKER_APP_ID
RUN NODE_OPTIONS=--openssl-legacy-provider npm run build
# server after backend - it's fast. so changing server only doesnt invalidate slow frontend.
COPY server/ ./server/
# better-sqlite3 native build (alpine musl). Cached if server unchanged.
RUN cd server && npm rebuild better-sqlite3
# ---- runtime stage: caddy + node ----
FROM node:22-alpine
RUN apk add --no-cache caddy
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/shared/node_modules ./shared/node_modules
COPY --from=build /app/server/node_modules ./server/node_modules
COPY --from=build /app/package*.json ./
COPY --from=build /app/shared/package.json ./shared/
COPY --from=build /app/server/package.json ./server/
COPY server/ ./server/
# built frontend served by caddy
COPY --from=build /app/build /srv
COPY docker/Caddyfile /etc/caddy/Caddyfile
COPY docker/entrypoint.sh /entrypoint.sh
ENV NODE_ENV=production
ENV PORT=4001
ENV DB_PATH=/data/tracker.sqlite
EXPOSE 80
WORKDIR /app
CMD ["/entrypoint.sh"]
+23
View File
@@ -0,0 +1,23 @@
# docker/docker-compose.yml — single container: caddy (front) + node (back).
# Usage (from repo root):
# docker compose -f docker/docker-compose.yml up --build
services:
app:
# no image: field => compose auto-names (docker-app), never pulls,
# always builds local. Service image private, never published.
build:
context: ..
dockerfile: docker/Dockerfile
args:
- REACT_APP_TRACKER_APP_ID=${TRACKER_APP_ID:-ttrpg-initiative-tracker-default}
ports:
- "${PORT:-8080}:80"
volumes:
- app-data:/data
environment:
- DB_PATH=/data/tracker.sqlite
- PORT=4001
restart: unless-stopped
volumes:
app-data:
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# docker/entrypoint.sh — run node backend + caddy proxy in one container.
# Caddy foreground (PID 1, handles signals). Node background.
set -e
# node backend (internal :4001)
cd /app/server
node index.js &
NODE_PID=$!
# caddy proxy (foreground, :80)
exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
+27
View File
@@ -0,0 +1,27 @@
# docker/portainer-stack.yml — Portainer stack for the SQLite/server build.
# Deploys the prebuilt image from the local registry (built + pushed by Jenkins).
# Paste into Portainer's web editor (Stacks -> Add stack -> Web editor).
#
# TLS is terminated by the external nginx, which should reverse_proxy to
# http://<docker-host>:8080 (and pass the WebSocket upgrade headers for /ws).
services:
app:
image: thinkserver:5000/ttrpg-tracker-sqlite:latest
container_name: ttrpg-tracker
ports:
- "8080:80" # host:container — nginx upstream is :8080
volumes:
- app-data:/data # SQLite DB lives here; back this up
environment:
- DB_PATH=/data/tracker.sqlite
- PORT=4001 # node backend port (internal; caddy proxies to it)
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:80/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
app-data:
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# scripts/docker-restart.sh — rebuild + restart docker container.
# Usage: ./scripts/docker-restart.sh
set -euo pipefail
cd "$(dirname "$0")/.."
echo "=== stopping existing container ==="
docker compose -f docker/docker-compose.yml down 2>/dev/null || true
echo "=== rebuilding image ==="
docker compose -f docker/docker-compose.yml build
echo "=== starting container ==="
docker compose -f docker/docker-compose.yml up -d
echo "=== status ==="
docker compose -f docker/docker-compose.yml ps
echo ""
echo "app: http://localhost:${PORT:-8080}"
echo "logs: docker compose -f docker/docker-compose.yml logs -f"
+142
View File
@@ -0,0 +1,142 @@
# Death Saves (D&D 5e)
Concept and rule structure for the death-save state machine. Reference for how
combat tracking should behave at 0 HP. This is the 5e death-save loop, not a
code spec.
## Scope: tracker tracks, DM decides
This tracker is not a virtual tabletop. It does not roll dice or interpret the
table. The DM rolls at the table, decides what happened, and feeds the result
into the tracker through the UI. The tracker records the result and applies the
automatic pieces.
- DM does: rolls the d20, determines crits vs normal hits, declares damage
amounts, decides when to stabilize or heal.
- Tracker does: increments success/failure counters on DM input, resolves the
terminal states (3 successes → stable, 3 failures → dead), resets counters on
heal or stabilize, records HP changes.
The transitions below describe the rules the tracker must honor once the DM
hands it a result. The DM is the source of truth for what happened; the tracker
is the source of truth for what state that produces.
## State
A character or NPC at 0 HP enters a death-save loop. Track per death-save participant:
```ts
deathSaveSuccesses: 0 | 1 | 2 | 3
deathSaveFailures: 0 | 1 | 2 | 3
status: "conscious" | "dying" | "stable" | "dead"
hp: number
```
## Transitions
**Drop to 0 HP** (damage, character/NPC):
- If damage >= currentHp + maxHp → outright dead (massive damage). Equivalently: after reducing currentHp to 0, overflow damage >= maxHp.
- Otherwise → `status = "dying"`, counters reset to 0.
**Drop to 0 HP** (monster):
- Non-NPC monsters do not use death saves.
- `status = "dead"`, counters reset, `isActive = false`.
**Death save** (d20 roll on a dying character/NPC turn):
```ts
if roll === 1: failures += 2 // natural 1 = two failures
else if roll === 20: hp = 1; successes = 0; failures = 0; status = "conscious" // nat 20 = 1 HP, conscious
else if roll >= 10: successes += 1
else: failures += 1
```
Then resolve immediately on the click/action that reaches 3:
```ts
if failures >= 3: status = "dead"; successes = 0; failures = 0
if successes >= 3: status = "stable"; successes = 0; failures = 0
```
The UI must transition immediately. A third failure shows Dead state, not three
active failure boxes. A third success shows Stable state, not three active
success boxes.
**Damage while dying** (at 0 HP):
```ts
failures += isCriticalHit ? 2 : 1
if failures >= 3: status = "dead"
```
Attacks from within 5 ft against an unconscious creature are auto-crits, so
melee vs a dying character/NPC adds 2 failures.
**Healing while dying or stable**:
Any +HP received while `dying` or `stable` makes the character/NPC conscious.
This usually comes from magic, or from the stable-state time-delayed recovery
(1 HP after 1d4 hours, if the DM chooses to track it here).
```ts
hp = max(1, hp + amount)
successes = 0
failures = 0
status = "conscious"
```
Any HP regained from `dying` or `stable` clears all counters and ends the
death-save loop. This does **not** apply to `dead`; dead requires revive flow,
revival magic, or DM state override, not normal healing.
**Stabilize** (manual action, not a save):
```ts
hp = 0
successes = 0
failures = 0
status = "stable"
```
## Stable state
- Stays at 0 HP, unconscious, no longer makes death saves.
- 3 successes = **stable (unconscious)**, NOT conscious. NOT 1 HP.
1 HP only comes from a natural 20 or healing.
- Taking any damage ends stable → back to `dying` (counters reset, then damage
applies its failure per "damage while dying").
## Tool-specific initiative behavior
Death is a combat state, not an automatic removal action.
When a character or NPC becomes `dead`:
- Do **not** remove them from initiative.
- Do **not** automatically deactivate them.
- Do **not** remove them from the encounter participants list.
- Leave them in turn order. Other table/tool events may still matter on that
initiative count.
- DM may manually mark them inactive or remove them.
When a non-NPC monster becomes `dead`:
- Set `isActive = false` automatically.
- Keep it in the encounter participants list until DM removes it.
- Player display hides it because inactive participants are hidden.
## Invariants
- `status === "dying"` ⟺ hp === 0 AND no terminal flag.
- `status === "stable"` ⟹ hp === 0, unconscious, counters 0.
- `status === "dead"` ⟹ hp === 0, no further saves, counters 0.
- `status === "dead"` ⟹ participant remains in initiative and participants until DM removes them.
- `status === "dead"` does not itself mean inactive; monster death sets inactive as a separate rule.
- Normal +HP applies to `dying` or `stable`, not `dead`; dead requires revive, revival, or DM override.
- Successes and failures tracked **independently**. 2 fail + 3 success = stable.
Counters do not need to be consecutive.
- A roll of 10+ is a success. 9 or lower is a failure.
## Sources
- [PHB 5e: Death Saving Throws](https://roll20.net/compendium/dnd5e/Rules%3ADamage%20and%20Healing)
- [Reddit: What happens on 3 successful death saves?](https://www.reddit.com/r/DnD/comments/io5js7/what_happens_if_you_succeed_in_3_death_saving/)
- [Roll20: Conditions (Unconscious)](https://roll20.net/compendium/dnd5e/Conditions)
+295
View File
@@ -0,0 +1,295 @@
# Development
TTRPG Initiative Tracker — fork with self-hosted backend. Monorepo via npm workspaces.
## Prerequisites
- Node.js 22+
- npm 10+
## Layout
```
/
package.json # workspaces root
src/ # React frontend (CRA)
App.js # main app (~2900 lines)
storage/ # adapter layer (firebase/ws/memory + contract)
__mocks__/firebase/ # firebase SDK mock (Layer 1 tests)
tests/ # frontend tests
server/ # Backend: generic KV doc store (firebase mirror)
index.js # REST (doc/coll/batch) + WS bootstrap
db.js # SQLite docs table, KV ops, broadcast
tests/ # backend + adapter-vs-live tests
shared/ # Pure logic, no I/O (client + server + tests import)
turn.js # turn-order state machine
tests/ # turn logic tests
scripts/ # manual demo tool (NOT test)
replay-combat.js # live backend demo
tests/
audit/ # exploratory bug-finders (manual, Math.random)
audit-rotation.js # rotation invariant
audit-state.js # 9 invariant classes
scratch/ # gitignored: throwaway repro/exploration
docs/
REWORK_PLAN.md
DEVELOPMENT.md # this file
GLOSSARY.md # domain terms (turn vs round, etc)
ENCOUNTER_BUILDER.md # DM interface guide
TESTING.md # test + automation ops
```
## Setup
```bash
git clone git@github.com:keen99/ttrpg-initiative-tracker.git
cd ttrpg-initiative-tracker
npm install
git config core.hooksPath .githooks # enable pre-push test gate
```
## Run
### Local dev stack (one command)
```bash
./scripts/dev-start.sh # backend :4001 + frontend :3999, server mode
./scripts/dev-stop.sh # stop both
```
- backend: `npm run server:dev` (:4001, sqlite `data/tracker.sqlite`)
- frontend: `npm start` (:3999, `REACT_APP_STORAGE=server`)
- logs: `tmp/server.log`, `tmp/fe.log`
Idempotent: if port busy, leaves existing proc as-is.
**LAN access (phones):** frontend binds 0.0.0.0, LAN IP auto-detected.
Other devices hit `http://<lan-ip>:3999`.
**Wake Lock (Prevent Sleep) on LAN IP:** requires secure context.
Plain HTTP on LAN IP = wake lock silently fails. Android Chrome flag workaround:
`chrome://flags/#unsafely-treat-insecure-origin-as-secure` → add
`http://<lan-ip>:3999` (with port), enable, relaunch. See README for full details.
Smoke check:
```bash
curl http://127.0.0.1:4001/health # -> {"ok":true}
```
Never put db in `/tmp` (wipe risk). Use `./data/` (gitignored) or docker volume.
### Manual (fallback)
Backend:
```bash
npm run server:dev # :4001, db: server/data/tracker.sqlite
# or direct:
DB_PATH=./data/tracker.sqlite PORT=4001 node server/index.js
```
Frontend (server mode):
```bash
REACT_APP_STORAGE=server \
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
REACT_APP_BACKEND_REALTIME_URL=ws://127.0.0.1:4001/ws \
BROWSER=none PORT=3999 \
npm start
```
Opens http://127.0.0.1:3999/. Admin view `/`, player view `/display`.
Firebase mode (default, upstream): set `REACT_APP_FIREBASE_*` in `.env.local` (copy `env.example`). `STORAGE_MODE=firebase` falls through to real SDK.
## Test
### Commands
```bash
npm run test:all # shared + server (fast, no frontend)
npm run shared:test # pure turn logic
npm run server:test # adapter vs live backend
npm test # CRA frontend (src/tests/, slow with scenario)
```
### Suites
| Suite | Location | What | Count |
|---|---|---|---|
| Unit (turn logic) | `shared/tests/` | pure nextTurn, rotation, pause-add | 50 (1 skip) |
| Integration (adapter vs backend) | `server/tests/` | ws adapter through live REST/WS | 23 |
| Characterization (UI) | `src/tests/` | locks current App.js behavior | 62 |
| Scenario | `src/tests/Combat.scenario.test.js` | 100-round full combat (240s) | 289 phases |
Total: 134 green + 1 validated RED (skipped).
### Test types
- **Unit** = pure logic, fast, no I/O. Locks behavior of single functions.
- **Integration** = real backend per test, adapter translation verified.
- **Characterization** = render App via mock, assert current (buggy or not) UI behavior. Not desired-state.
- **Scenario** = end-to-end flow through rendered App, asserts full sequence completes.
- **Contract** = same spec run against every storage impl (memory, ws, firebase). Catches adapter drift.
### Running one file / pattern
```bash
npm test --workspace shared -- --testPathPattern=round-rotation
CI=true npx react-scripts test --watchAll=false src/tests/App.characterization.test.js
```
### Scenario test is slow
`Combat.scenario.test.js` runs 100 combat rounds through rendered App — 240s timeout by design. Skip when iterating:
```bash
CI=true npx react-scripts test --watchAll=false --testPathIgnorePatterns="Combat.scenario"
```
## Demo tool (NOT test)
`scripts/replay-combat.js` = live backend demo. Watch UI react to state changes.
```bash
# start backend + frontend first
node scripts/replay-combat.js [rounds] [delayMs]
# defaults: 100 rounds, 200ms/step
```
Coverage per round: damage, heal, all 22 conditions, toggleActive,
removeParticipant, addParticipant (reinforcements), updateParticipant,
pause/resume, reorderParticipants, endEncounter. Revives dead each round
to sustain full round count.
## Audit tools (NOT unit tests)
`tests/audit/` = exploratory, Math.random, non-deterministic. Manual run.
Unit tests (`{shared,server,src}/tests/`) lock known bugs deterministically.
### audit-rotation.js
Pure turn.js simulation of replay op sequence. Detects rotation violations
(skip/dupe per round). Found BUG-1 (addParticipant + pause corrupts rotation).
```bash
node tests/audit/audit-rotation.js
```
Bisect: comment/uncomment op blocks to isolate triggering combo.
### audit-state.js
Runs pure turn.js combat, audits 9 invariant classes per round:
1. rotation integrity (skip/dupe)
2. HP bounds (0 ≤ hp ≤ max, no NaN)
3. isActive consistency (inactive skipped; monster death auto-inactive; character/NPC death DM-controlled)
4. turnOrder no dup ids
5. turnOrder ids all active
6. currentTurn valid + active
7. deathSave range (0 ≤ saves ≤ 3, reset on revive)
8. removeParticipant orphans
9. undo support
```bash
node tests/audit/audit-state.js [rounds] # default 100
```
Current state (post BUG-1/2 fix): 0 violations / 100 rounds.
See `TODO.md` for known bugs.
## Scratch
`scratch/` = gitignored throwaway. Repro scripts, exploration, debug.
Not committed. Use freely, delete anytime.
## Build
```bash
npm run build # CRA production build -> build/
```
Docker build (existing, frontend-only):
```bash
docker build -t ttrpg-initiative-tracker .
docker run -p 8080:80 --rm ttrpg-initiative-tracker
```
Full-stack docker-compose arrives in M5.
## Storage architecture
### Generic KV doc store
Backend = firebase mirror. Single `docs` table: `path` (PK), `parent`, `data` (JSON), `updated_at`. Opaque JSON at arbitrary path strings. No shape-specific endpoints. App logic stays client-side.
```
Client (browser) Server
| |
|-- storage.setDoc(path,data) -->| REST PUT /api/doc
|<---- 200 ----------------------|
| |
|-- storage.subscribeDoc(path) -->| WS subscribe
|<---- WS {initial} --------------| immediate value
| ... |
|<---- WS {change} --------------| on any write to path
| |
Display / tablet |
|<---- WS {change} --------------| same push
```
### Path normalization
App passes firebase-prefixed paths (`artifacts/{APP_ID}/public/data/campaigns/...`). Adapter `norm()` strips prefix → bare canonical (`campaigns/...`). All impls share identity (contract test).
### STORAGE_MODE flow
`getStorageMode()` reads `REACT_APP_STORAGE` env (default `firebase`).
- `firebase` → real SDK init
- `server` → stub auth + db sentinel, route via `storage.*` adapter
## Test layers
- **Layer 1**: App vs firebase mock. Proves adapter call shape. Never exercises server adapter.
- **Layer 2**: server adapter vs live backend. Proves translation + path identity.
Both required — Layer 1 alone misses adapter bugs (path mismatch, no-op players, ws.on EventEmitter vs browser handlers).
## Local pipeline (pre-push hook)
Private repo = no free GitHub Actions. Tests run locally via git hook.
`.githooks/pre-push` runs `npm run test:all` (shared + server, fast). Frontend tests not gated (slow).
Skip:
```bash
git push --no-verify
```
Already configured on this checkout after `git config core.hooksPath .githooks`.
## Status
| Milestone | State |
|---|---|
| 0 repo/branch | ✅ done |
| 1 backend + tests | ✅ done |
| 2 frontend WS adapter | ✅ done |
| 3 characterization tests | ✅ done (134 green) |
| 4 skip fix + manual override | ⬜ next |
| 5 docker compose | ⬜ |
| 6 undo rework | ⬜ |
| 7 playwright e2e | ⬜ deferred |
See `docs/REWORK_PLAN.md` for full plan, `TODO.md` for known bugs.
## Git
- `origin` = `github.com:keen99/ttrpg-initiative-tracker` (this fork)
- `upstream` = `code.draft13.com/robert/ttrpg-initiative-tracker` (friend's Gitea, read-only)
- work branch: `rework-backend` (off `main`)
```bash
git fetch upstream # pull friend's changes
git merge upstream/main # rebase our branch onto his
```
+326
View File
@@ -0,0 +1,326 @@
# Encounter Builder — DM Interface Guide
How a DM (or LLM automating the DM role) builds and runs encounters via the UI and storage layer. Covers entity model, build flow, combat controls, and the storage paths backing each action.
## Entity model
Three nested entities. All stored as opaque JSON docs in the KV store (generic doc store — see `docs/DEVELOPMENT.md`).
```
Campaign
└─ Encounter(s)
└─ Participant(s)
```
Plus two global docs:
- `activeDisplay/status` — controls player view (which campaign+encounter, hide-HP flag)
- `logs/{id}` — append-only action log entries
### Campaign
Path: `artifacts/{APP_ID}/public/data/campaigns/{campaignId}`
| Field | Type | Notes |
|---|---|---|
| `name` | string | |
| `playerDisplayBackgroundUrl` | string | optional, image URL for player display bg |
| `ownerId` | string | user id |
| `createdAt` | ISO string | |
| `players` | array | campaign-level character roster (templates, NOT combatants) |
Campaign characters = reusable templates. Default HP + init mod. Added to any encounter via ParticipantManager. Not combatants themselves.
### Encounter
Path: `artifacts/{APP_ID}/public/data/campaigns/{campaignId}/encounters/{encounterId}`
| Field | Type | Notes |
|---|---|---|
| `name` | string | |
| `createdAt` | ISO string | |
| `participants` | array | the combatants (see below) |
| `round` | int | 0 = not started |
| `currentTurnParticipantId` | string\|null | who acts now |
| `isStarted` | bool | combat active |
| `isPaused` | bool | frozen turn order (add/remove/edit allowed) |
| `turnOrderIds` | array | participant ids in turn order = participants[] order (1-list model) |
### Participant
Object in `encounter.participants[]`:
| Field | Type | Notes |
|---|---|---|
| `id` | string | `generateId()` |
| `name` | string | |
| `type` | `'character'` \| `'npc'` \| `'monster'` | character/NPC = death saves, monster = dead at 0 HP |
| `originalCharacterId` | string\|null | links back to campaign character if type=character |
| `initiative` | int | rolled once at add (`rollD20() + mod`). Stored value, not re-derived. |
| `maxHp` | int | |
| `currentHp` | int | 0 = dead/dying/stable by `status` |
| `conditions` | array | condition ids from `CONDITIONS` list |
| `isActive` | bool | in turn rotation? false = skipped by nextTurn |
| `status` | `'conscious'` \| `'dying'` \| `'stable'` \| `'dead'` | death-save source of truth |
| `deathSaveSuccesses` | int | character/NPC only, 0-3 successes |
| `deathSaveFailures` | int | character/NPC only, 0-3 failures |
## Path normalization
App passes firebase-prefixed paths (`artifacts/{APP_ID}/public/data/campaigns/...`). Server-mode adapter `norm()` strips prefix → bare canonical (`campaigns/...`).
- **UI / app code** → prefixed paths (`getPath.*` helpers)
- **REST API / direct DB / scripts** → bare canonical paths (`campaigns/...`)
Mixing = empty results. If `GET /api/collection?path=artifacts/.../campaigns` returns `[]`, you used the prefixed form. Drop the prefix.
## Build flow (UI)
Admin view at `/`. Steps:
### 1. Create campaign
- Click **Create Campaign** button
- Enter name + optional background URL
- Submits → `setDoc(campaigns/{id}, { name, playerDisplayBackgroundUrl, ownerId, createdAt, players:[] })`
### 2. Select campaign
- Click campaign card → `setSelectedCampaignId(campaign.id)`
- Now managing: CharacterManager + EncounterManager visible
### 3. Add campaign characters (optional templates)
CharacterManager section. Per character:
- **Name**
- **Default HP** (`DEFAULT_MAX_HP` = 10)
- **Init Mod** (`DEFAULT_INIT_MOD` = 0)
`updateDoc(campaign, { players:[...existing, newChar] })`
These are reusable across encounters. Add to encounter later (auto-rolls initiative).
### 4. Create encounter
- Click **Create Encounter**
- Enter name
`setDoc(campaigns/{cid}/encounters/{eid}, { name, createdAt, participants:[], round:0, currentTurnParticipantId:null, isStarted:false, isPaused:false })`
### 5. Add participants
ParticipantManager section. Two paths:
**Monster/NPC:**
- **Monster Name** (`placeholder: "e.g., Dire Wolf"`)
- **Init Mod** (`MONSTER_DEFAULT_INIT_MOD` = 2)
- **Max HP** (`DEFAULT_MAX_HP` = 10)
- **Is NPC?** checkbox (sets `type: 'npc'`, keeps monster/DM-control color, enables death saves)
- Click **Add to Encounter**
- Initiative auto-rolled: `rollD20() + mod`
**Character (from campaign roster):**
- Select character from dropdown
- Click **Add to Encounter**
- OR **Add All (Roll Init)** — bulk-adds all campaign chars, each rolls own initiative
**Duplicate guard:** same `originalCharacterId` blocked (alerts "already in this encounter"). Monsters no dedup.
Participant object added:
```js
{ id, name, type, originalCharacterId, initiative, maxHp, currentHp:maxHp,
conditions:[], isActive:true, status:'conscious', deathSaveSuccesses:0, deathSaveFailures:0 }
```
### 6. Reorder before start (tie-break)
Pre-combat only (`!isStarted || isPaused`). Drag handles shown for **tied initiative** values only. Drop reorders `participants[]` + `turnOrderIds`.
During active combat, cross-current-pointer drag is blocked to avoid skip/double-act ambiguity. Paused combat can reorder same-initiative ties freely.
## Build flow (API / scripts)
Same logical steps as UI, via REST. For LLM agents or seed scripts.
**Server mode only.** REST endpoints (`/api/*`) exist only when tracker runs
with `REACT_APP_STORAGE=server` (bundled Express backend mounted). Firebase
SDK mode has no HTTP backend — data lives in Firestore via in-browser SDK,
different auth + write paths. This section does not apply to firebase mode.
Confirm server mode before scripting:
```bash
curl -s <HOST>/api/collection?path=campaigns | head -c 200
```
JSON array = server mode. HTML (SPA shell) = firebase mode or backend not
mounted — API path won't work.
### REST endpoints
| Method | Path | Body / Query | Action |
|---|---|---|---|
| `GET` | `/api/doc` | `?path=` | read one doc |
| `PUT` | `/api/doc` | `{path, data}` | replace doc |
| `PATCH` | `/api/doc` | `{path, patch}` | shallow merge, create-on-miss |
| `DELETE` | `/api/doc` | `?path=` | delete one doc |
| `GET` | `/api/collection` | `?path=` | list docs under collection |
| `POST` | `/api/collection` | `{path, data}` | addDoc: auto-id under collection |
| `POST` | `/api/batch` | `{ops:[{type:'set'\|'update'\|'delete', path, data?}]}` | atomic multi-write |
All paths bare canonical (no `artifacts/...` prefix).
### id format
`generateId()` = `crypto.randomUUID()` (fallback `id_{Date.now()}_{rand10}`). Use either. Server `POST /api/collection` generates its own UUID — fine for campaigns/encounters. Participants are inline array items; generate their ids client-side so they're stable across batch writes.
### Object templates
Encounter doc (full, for `PUT` / batch `set`):
```js
{
name: 'Barrowdeep 1 - Antechamber',
createdAt: new Date().toISOString(),
participants: [], // inline, NOT a sub-collection
round: 0,
currentTurnParticipantId: null,
isStarted: false,
isPaused: false,
ruleset: '5e',
}
```
Participant object (item in `encounter.participants[]`):
```js
{
id: generateId(),
name: 'Ankheg',
type: 'monster', // 'character' | 'npc' | 'monster'
originalCharacterId: null, // set only if type==='character'
initiative: rollD20() + initMod, // rolled ONCE at add; stored, not re-derived
maxHp: 45,
currentHp: 45, // start at full
conditions: [],
isActive: true,
status: 'conscious',
deathSaveSuccesses: 0, // character/npc only
deathSaveFailures: 0, // character/npc only
}
```
### Initiative semantics
- Default: `initiative = rollD20() + initMod` (d20 ∈ [1,20], roll fresh per participant).
- Manual override (e.g. pre-rolled values): set `initiative` directly, skip the roll.
- Fixed at add time. NOT re-derived from `initMod` after — `initMod` is not stored on participant.
### Recipe: build encounters for a campaign
1. **Find campaign id by name:**
```bash
curl -s "$HOST/api/collection?path=campaigns" \
| jq -r '.[] | select(.name=="2026 D&D") | .id'
```
2. **Build encounter docs + participants client-side** (object templates above), roll initiative per participant.
3. **Write all encounters in one batch:**
```bash
curl -s -X POST "$HOST/api/batch" \
-H 'Content-Type: application/json' \
-d '{"ops":[{"type":"set","path":"campaigns/CID/encounters/EID1","data":{...}},{"type":"set","path":"campaigns/CID/encounters/EID2","data":{...}}]}'
```
4. **Verify:**
```bash
curl -s "$HOST/api/collection?path=campaigns/CID/encounters" | jq '.[] | {name, count: (.participants|length)}'
```
Participants go inline in the encounter doc — there is no `participants` sub-collection. A single batch `set` per encounter writes the whole roster atomically.
## Combat flow (UI)
InitiativeControls panel (sticky, right side).
### Start
- **Start Combat** button (disabled if no active participants)
- Sorts ALL participants by initiative (1-list: `participants[]` = display + turn order)
- `round=1`, `currentTurnParticipantId` = first active, `isStarted=true`, `isPaused=false`
- Sets `activeDisplay` → this campaign+encounter (player display syncs)
- Initiative fixed at start. NOT re-derived from mod after.
### Next Turn
- **Next Turn** button (disabled if paused)
- Advances to next active participant in `turnOrderIds`
- Wraps at end → `round += 1`
- No re-sort after start; 1-list order remains source of truth
- Inactive (`isActive:false`) participants are skipped, but stay in slot
### Pause / Resume
- **Pause Combat** → `isPaused=true`, Next Turn disabled
- While paused: add/remove participants, adjust HP, edit initiative, reorder ties
- **Resume Combat** → `isPaused=false`, no re-sort (1-list: turnOrderIds already current)
### HP adjustments (combat only)
Per-participant input + buttons:
- Number input
- **Damage** (HeartCrack icon) — `currentHp = max(0, hp - amt)`
- **Heal** (Heart icon) — `currentHp = min(maxHp, hp + amt)` for living/dying/stable participants; normal heal does not affect dead participants
- Character/NPC at 0 HP → `status:'dying'`, death-save controls, unconscious condition
- Monster at 0 HP → `status:'dead'`, `isActive:false`
- Massive damage (`damage >= currentHp + maxHp`) → dead
### Death saves (character/NPC only, at 0 HP)
Action buttons: Success, Fail, Nat1, Nat20, Stabilize.
- Success: +1 success; 3 successes → stable/unconscious at 0 HP
- Fail: +1 failure; 3 failures → dead
- Nat1: +2 failures
- Nat20: 1 HP, conscious, counters reset
- Stabilize: stable/unconscious at 0 HP, counters reset
- Revive: dead → 0 HP, stable/unconscious, active
### Conditions
- Click participant → expand conditions picker (all 22 from `CONDITIONS`)
- Active conditions show as badges, click to remove
### End combat
- **End Combat** button → resets `isStarted:false`, `round:0`, `currentTurn:null`, `turnOrderIds:[]`
- Clears `activeDisplay` (player view goes blank)
## Player display
Separate view at `/display` or `?playerView=true`. Read-only second screen.
What it shows:
- Current encounter name
- Round + current turn participant
- All participants in `participants[]` order (drag order, NOT init-sorted — BUG-15 fix)
- HP bars, conditions, death saves
- Inactive participants hidden (including auto-inactive dead monsters and DM-hidden reserves)
Driven by `activeDisplay/status` doc. Controlled by **Open Player Window** button (sets active campaign+encounter) or Start Combat (auto-sets).
## 1-list turn order model
Key architecture. `turnOrderIds === participants.map(p => p.id)` always. Single source of truth.
- **Display** = `participants[]` order (AdminView + DisplayView, no re-sort)
- **Turn rotation** = `turnOrderIds` (mirrors participants[])
- **Drag** = source of truth, overrides initiative
- **Add mid-combat** = slot by initiative into participants[] + sync
- **Toggle active** = flip `isActive` only, stay in slot
- **Remove** = drop from participants[] + sync, advance current if needed
No re-sort after `startEncounter`.
## Storage paths quick reference
Bare canonical (server mode). App code prefixes these with `artifacts/{APP_ID}/public/data/` — adapter strips it.
```
campaigns/{cid} campaign doc
campaigns/{cid}/encounters/{eid} encounter doc (participants[])
campaigns/{cid}/encounters/{eid}/participants ❌ NOT a path — participants inline
activeDisplay/status player display control
logs/{logId} action log entry
```
REST mapping: `campaigns/{cid}` doc → `GET/PUT/PATCH/DELETE /api/doc?path=campaigns/{cid}`. Collection parent (`campaigns`, `campaigns/{cid}/encounters`) → `GET/POST /api/collection?path=...`.
## DM tips
- Initiative rolled ONCE at add time. Stored. Edit via EditParticipantModal to override.
- Pause before big roster changes (adds/removes). Resume re-syncs cleanly.
- Campaign chars = templates. Edit campaign char doesn't touch encounter participants (already added).
- Dead monsters become inactive automatically and are skipped. Remove via trash icon to clean list.
- Dead characters/NPCs stay active until DM marks inactive, removes, or revives them.
- Player display auto-follows Start Combat. Manual control via Open Player Window.
See `docs/GLOSSARY.md` for domain terms, `TODO.md` for known bugs.
+59
View File
@@ -0,0 +1,59 @@
# Glossary — TTRPG Initiative Tracker
Domain terms used throughout the app, shared turn logic, tests, and docs. Keep
these definitions stable so logs, UI labels, and code agree.
## Combat structure
| Term | Meaning |
|------|---------|
| **Initiative** | The ordered sequence determining who acts next. Rolled once at the start of an encounter; re-rolled only on a new encounter or explicit DM re-roll. |
| **Round** | One full pass through initiative — every active participant gets exactly one turn. N participants (PCs + NPCs + monsters + features) = N turns per round. Round counter increments when initiative wraps back to the first participant. |
| **Turn** | A single participant's initiative slot within a round. One participant acts. As many turns per round as there are participants. |
| **Initiative slot** | Synonym for turn's position in the ordered list. |
| **Top of round** | The first turn of a round (round counter increments here). |
Example: encounter with 8 participants (3 PCs + 4 monsters + 1 NPC).
```
Round 1: turn 1 (Fighter) → turn 2 (Goblin1) → ... → turn 8 (Merchant)
Round 2: turn 1 (Fighter) → ... → turn 8 (Merchant) [round counter +=1 at top]
```
## Participants
| Term | Meaning |
|------|---------|
| **Participant** | Any combatant tracked in initiative. Has HP, initiative roll, conditions, and an `isActive` flag. |
| **PC** (Player Character) | Controlled by a player. On death → death saves (not removed from initiative). |
| **NPC** (Non-Player Character) | DM-controlled ally/neutral (e.g. merchant, quest-giver). May or may not roll initiative. |
| **Monster** | Hostile DM-controlled combatant. On death → typically removed from active initiative or marked dead. |
| **Feature / Lair** | Environmental or legendary effect that occupies an initiative slot (e.g. lair action at initiative 20). |
## Participant state
| Term | Meaning |
|------|---------|
| **HP** (Hit Points) | Current health. `currentHp` / `maxHp`. At 0 → dying or dead (rules differ by type). |
| **Initiative mod** | Bonus added to d20 initiative roll. `defaultInitMod`. |
| **Conditions** | Temporary status effects (stunned, prone, poisoned, etc.) applied/toggled during play. Array on participant. |
| **isActive** | Whether the participant is in the active initiative rotation. Set false on death (CURRENT behavior — see M4 skip-bug fix). |
| **Death save** | PC-only mechanic. Successes/failures tracked at 0 HP. 3 fails → dead; 3 successes → stable. |
## Views
| Term | Meaning |
|------|---------|
| **Admin view** (`/`) | DM interface. Full create/edit/combat control. |
| **Player view** (`/display` or `?playerView=true`) | Read-only second-screen display for players. Shows current turn, HP bars, conditions, round. No DM controls. |
| **Active display** | The single `activeDisplay/status` doc controlling what the player view shows (which campaign/encounter, hide-player-HP flag). |
## Backend / storage
| Term | Meaning |
|------|---------|
| **Adapter** | `src/storage/{firebase,server}.js`. Contract boundary between App and backend. App only calls `storage.*`, never raw SDK/fetch. |
| **Path normalization** (`norm()`) | Strip firebase prefix (`artifacts/{APP_ID}/public/data/`) → bare canonical path (`campaigns/X`). Runs inside every adapter method. |
| **Generic KV doc store** | Backend stores opaque JSON at arbitrary path strings. No shape-specific endpoints. Backend = firebase mirror, not REST API for app entities. |
| **Layer 1 test** | App vs firebase mock. Proves adapter call shape. |
| **Layer 2 test** | server adapter vs live backend. Proves translation + path identity. |
+73
View File
@@ -0,0 +1,73 @@
# Initiative Ordering — Design
## Core Principle
**Slot, never sort.**
Initiative order = a single list. Participants occupy slots determined by
initiative value. The list is mutated by insert/move operations — never
re-sorted wholesale.
## Single Source of Truth
One list: `participants[]`.
- Display order = `participants[]` order (both Player view + DM view).
- Turn order = `participants[]` order.
- No derived/re-sorted copy. `turnOrderIds`, if present, is always an exact
mirror (`participants.map(p => p.id)`) — never an independent ordering.
## When Ordering Changes
| Action | Effect on order |
|--------|-----------------|
| **Add** (roll or manual) | Insert participant into slot by initiative |
| **Edit initiative** | Move participant to new slot |
| **Drag** | Reorder within a tie (same initiative) only |
| **Remove** | Splice out; others keep position |
| **Start encounter** | Freeze current list. No re-sort. |
| **Round wrap** | No rebuild. Continue rotation through existing list. |
| **Damage/heal/death/save** | No order change. |
| **Toggle active** | No position change. Does **not** advance current turn. Skip on next explicit turn advance only. |
## Slotting Rules
- Insert/move positions participants so the list stays initiative-descending.
- **Tie-break = original add order.** Later additions slot *after* existing
same-init participants. Stable insertion.
- Tie order is only changed by explicit DM drag. Never auto-changed.
## Drag (DM Tie-Break Override)
- Only participants with the **same initiative** are draggable onto each other.
- Cross-initiative drag is **not allowed**. (Use edit-initiative field instead.)
- Drag persists in `participants[]`. Survives all subsequent operations.
- **Re-slotting on add/edit must preserve drag-established tie order.**
## Toggle Active Semantics
Toggle active is a roster/status edit, not a turn action.
- Deactivating the current participant leaves `currentTurnParticipantId` unchanged.
- Deactivating the current participant does **not** increment `round`.
- DM must click Next Turn to pass turn after deactivating current.
- Next Turn skips inactive participants during rotation.
- Reactivating a participant restores them to rotation at their existing slot.
- Toggle active never changes participant position or tie order.
Reason: auto-advancing from toggle active makes a status edit count like a turn pass,
can wrap the round accidentally, and causes double-act/skip drift.
## Explicitly Forbidden
- `sort()` on every mutation. Overwrites drag order. Root cause of past drift.
- Separate display order vs turn order. Causes player/DM divergence.
- Re-sort on round wrap. Replays rounds, introduces skips.
- Cross-initiative drag. Contradicts initiative as the primary key.
- Auto-changing tie order on add/edit. Silently loses DM intent.
## Why
Sorting is destructive to manual ordering. A stable slot list with drag for
ties gives deterministic, DM-controllable order that never drifts between
display surfaces or combat rounds.
+133
View File
@@ -0,0 +1,133 @@
# Log System Redesign Plan
**Status:** DRAFT — not started
**Branch:** `feat-turn-writes-logs`
**Goal:** Lean mutation deltas. Kill bloated payload/undo duplication.
## Problem
Current events = 10-16KB each. `add_participant` row = 16KB to say "Fighter added, init 22." Root cause: full participant roster stored 3x (payload + undo_payload.updates + undo_payload.redo).
DB: 2486 docs, most bloated. Replay trace 1.6-5.5MB for ~250 steps.
Old log (pre-refactor) = message string + small undo object. Proven worked for undo/redo + viewer. Just wanted MORE STRUCTURE, not MORE DATA. Overbuilt instead.
## Intention
Each event = mutation delta + lean snapshot. Structured for filter/group/analyzer. No roster arrays.
### Canonical lean event shape
```js
{
id, ts, type, // identity + filter key
message, // human text (viewer displays)
encounterId, encounterName, // which encounter
participantId, participantName, // who (nullable: pause/nextTurn have none)
// type-specific scalar delta (one of these clusters per type):
// add: { init, maxHp, type: 'character'|'monster' }
// remove: { dead: bool }
// damage/heal:{ amount }
// condition: { condition }
// toggleActive:{ revive: bool }
// reorder: { draggedId, targetId }
// update: { changedFields: ['notes', ...] }
// nextTurn: (none — snapshot carries new round/pointer)
// pause/resume/start/end: (none)
snapshot: { // lean, ALREADY GOOD — keep as-is
round, currentTurnParticipantId, turnOrderIds, activeIds
},
undo: <type-specific inverse, id-based> // minimal
}
```
### Undo inverses (id-based, minimal)
| type | undo |
|------|------|
| add_participant | `{ participantId }` → removeParticipant by id |
| remove_participant | full participant obj (must restore — only place full obj needed) |
| damage | `{ amount: -amount }` or heal back |
| heal | `{ amount: -amount }` or damage back |
| condition | `{ condition }` (toggle is its own inverse) |
| toggleActive | `{ revive: !revive }` |
| reorder | `{ draggedId, targetId }` (swap back) |
| update | `{ changedFields: [...], oldValues: {...} }` |
| nextTurn | `{ currentTurnParticipantId, round }` from snapshot delta |
| pause/resume | toggle |
| start/end | full prior scalar state |
**No `redo` field.** Redo = re-apply forward delta from event itself.
**No `undo_payload` wrapper.** Just `undo` object.
## 4 Consumers
1. **Viewer** — shows `message` + `type` + `participantName` + `undone` flag. Legacy logs (no type) display message only.
2. **Undo/redo** — apply `undo` inverse by id. Id-based, minimal.
3. **Replay** — turn.js funcs write own lean events. Trust func return. NO read-back. JSONL trace = lean snapshots + types.
4. **Analyzer** — reads `snapshot` sequence + `type`. Reconstructs roster from event stream (add/remove by id). NO payload arrays needed.
## Legacy Support
- **Viewer:** yes — show old message-only logs, no type parse
- **Undo:** no — old logs unundoable (acceptable, sit inert)
- **Analyzer:** best-effort — snapshot may be absent on legacy → skip invariant check for that run
## Why Lean
- `add_participant` 16KB → ~150 bytes
- Replay trace 5.5MB → ~250KB
- Undo still works (proven old log did same)
- Analyzer unaffected (uses snapshot+type, not payload)
## Migration Plan
### Phase 1: Redesign event writers (shared/turn.js)
- [ ] Rewrite `buildEntry()` to produce lean shape (delta fields per type)
- [ ] Rewrite `commit()` — no patch storage, write lean event
- [ ] Per-func: compute minimal delta + inverse from enc before/after
- [ ] Drop `payload`, `undo_payload`, `undo_payload.redo`
### Phase 2: Update undo executor
- [ ] Find undo consumer (App.js `applyUndo` or similar)
- [ ] Switch to id-based inverse application
- [ ] Handle each type's undo shape
- [ ] Drop `transactionalUndo` full-patch path if exists
### Phase 3: Update replay trace
- [ ] `replay-combat.js` callStep: trust return, snapshot only (no read-back)
- [ ] Trace = `{step, ts, type, pre:leanSnap, post:leanSnap}` — no participant arrays
### Phase 4: Update analyzer
- [ ] `analyze-turns.js` already uses snapshot+type only — verify no payload dep
- [ ] Reconstruct roster from event stream for skip/double-act checks (id-level)
### Phase 5: Legacy compat
- [ ] `logEvent.js normalizeEvent` — lift legacy into lean shape (type='legacy', message only)
- [ ] Viewer handles legacy gracefully
- [ ] Download = lean events only (legacy excluded or message-only)
### Phase 6: Cleanup
- [ ] Delete old bloated docs from DB? Or leave (inert, viewer reads message)?
- [ ] Verify build CI=true passes
- [ ] Verify analyzer clean on fresh replay
- [ ] Verify undo works on new events
## NOT in scope
- Fixing 5 pre-existing initiative bugs (logged in TODO.md, separate)
- Changing snapshot shape (already lean)
- Changing viewer UI (just data source)
## Open Questions
- Undo for `remove_participant`: must restore full obj (only place full obj needed). Acceptable?
- Should legacy logs be deleted from DB or left inert?
- Download format: lean JSON array (no legacy) — confirm
+266
View File
@@ -0,0 +1,266 @@
# Initiative Tracker — Rework Plan
Status: **APPROVED — executing**
Owner: draistrick (fork → `keen99/ttrpg-initiative-tracker`, private)
Upstream: `code.draft13.com/robert/ttrpg-initiative-tracker` (friend's Gitea)
---
## Goals
1. **Replace Firebase with self-hosted backend.** Browser cannot own a DB file (sandbox). Cross-device (DM + tablet + player view) requires a real backend. Backend is the foundation, built first.
2. **Automated test ecosystem as the baseline.** Lock current behavior before changing it.
3. **Remain mergeable upstream.** Default behavior (Firebase) preserved behind flag. Upstream `main` stays clean. Friend keeps Firebase path.
4. **Self-hostable in local Docker** (in-house network). Public exposure = future, only after auth + multiuser safety.
## Non-Goals (this plan)
- Ripping Firebase. Kept as default adapter upstream.
- Public/multiuser deployment. Deferred.
- Rewriting the entire 2935-line `App.js`. Only extract what testability demands.
- Feature/bug work. That lives in `TODO.md`. This plan = infra + backend + test harness only.
---
## Problem Statement
### Why Firebase is wrong here (for this fork)
- Requires Google account + network for a single-user tabletop tool.
- Realtime value (DM view ↔ player display) is real but solvable locally.
- API key baked into client bundle (CRA `REACT_APP_*` at build); security depends entirely on console rules not in repo.
- Vendor lock + quota; `onSnapshot` on collections burns reads.
- Friend keeps it; we fork off it.
### Why a backend is mandatory
Browser sandbox cannot write the filesystem. No sqlite file, no `/data/db.sqlite`, nothing. Browser JS is blocked from disk by design. Therefore cross-device storage (DM ↔ tablet ↔ player view) requires a separate Node process owning the DB file and serving the browser over HTTP/WebSocket. There is no browser-only path. **The backend is step one, not deferred.**
---
## Architecture
### Stack (locked)
- **Node.js** runtime
- **Express** web framework
- **ws** WebSocket lib (realtime push, replaces `onSnapshot`)
- **better-sqlite3** SQLite driver (synchronous, simple, fast)
- **SQLite** DB (single file, docker volume, trivial backup)
- **Jest** test runner (already in CRA deps)
Postgres deferred until public multiuser exposure is real. SQLite schema ports easily if that day comes.
### Backend design
- Owns SQLite file. Only writer.
- Holds authoritative state.
- Generic KV doc store (firebase mirror): single `docs` table (path PK, parent, data JSON, updated_at). Opaque JSON at arbitrary path strings. No shape-specific endpoints. App logic stays client-side.
- WS broadcast on every state change → all connected clients (DM view, player display, tablet) update instantly.
### Three storage impls, one interface (frontend)
The storage interface is the test seam and the upstream-compat layer.
| Impl | When used | Automated-tested? |
|---|---|---|
| `firebase.js` | default (`STORAGE=firebase`) — upstream path | No — requires live Firebase project |
| `server.js` | `STORAGE=server` — our fork, talks to backend | Yes — against running backend |
| `memory.js` | test-only, in-process | Yes — fast, deterministic |
**Frontend interface contract** (all three implement):
- `getDoc(path)`, `setDoc(path, data, opts)`, `updateDoc(path, patch)`
- `deleteDoc(path)`, `batch(ops)`
- `subscribeDoc(path, cb)` / `subscribeCollection(path, cb)` → real-time push
Firebase impl: existing `onSnapshot` + SDK calls, moved verbatim behind interface (M2).
WS impl: thin adapter; generic KV ops, receives **state updates** via WS subscribe (M2).
Memory impl: in-memory Map + EventEmitter, for tests (M3).
### Repo layout (npm workspaces)
```
/
package.json # workspaces root
src/ # React frontend (existing, refactored behind storage interface)
storage/
index.js # factory: pick impl from STORAGE env
firebase.js # extracted from current App.js (verbatim)
server.js # talks to backend (was ws.js)
memory.js # NEW — test only
contract.js # interface spec (runStorageContract)
tests/ # frontend tests
server/ # NEW
index.js # Express + ws bootstrap, generic KV REST
db.js # better-sqlite3, docs table (KV), broadcast
handlers.js # REST handlers
tests/ # adapter vs live backend (Layer 2 test)
shared/ # pure logic, no I/O, importable by client + server + tests
turn.js # turn logic (single source; tests import)
tests/ # turn logic unit tests (characterization + desired)
data/ # gitignored sqlite DB
docker-compose.yml # NEW — M5
docs/
REWORK_PLAN.md # this file
DEVELOPMENT.md
GLOSSARY.md
TODO.md # bugs + features (separate from this plan)
```
### Auth
- **Now:** `AUTH_MODE=none`. App gated by nginx HTTP basic auth (reuse friend's existing pattern). In-house only. Risk acceptable: someone sees your initiative counter.
- **Future:** `AUTH_MODE=token` — real login, real users. Only if/when publicly exposed. Not built this plan.
---
## Milestones
Each milestone = independently mergeable PR upstream (unless marked ❌).
| M | Does | Tests? |
|---|---|---|
| 0 | repo, branch, remotes | no |
| 1 | build backend (Node+Express+WebSocket+better-sqlite3) | unit tests as built |
| 2 | frontend WS adapter — app runs vs backend, cross-device works | yes |
| 3 | characterization tests lock current behavior | yes |
| 4 | resolve initiative rotation corruption (BUG-5) | yes |
| 5 | docker single container (caddy+node) | smoke ✅ |
| 6 | _moved to TODO backlog (feature work)_ | - |
| 7 | playwright multi-window e2e (deferred) | e2e |
| 8 | (future) public exposure | - |
### Milestone 0 — Repo + branch setup ✅
- Fresh branch off `main` (not `dsr-rework`). Name: `rework-backend`.
- `upstream` remote = friend's Gitea (read-only fetch).
- Push origin = `keen99/ttrpg-initiative-tracker` (private).
- npm workspaces root config.
- Commit this plan.
- **Exit criteria:** clean branch, plan committed, remotes set. ✅ DONE.
- **Upstream-PRable:** n/a (fork infra)
### Milestone 1 — Build backend ✅
- `server/`: Express + WebSocket + better-sqlite3.
- Generic KV doc store (firebase mirror): `docs` table (path PK, parent, data JSON, updated_at). REST: GET/PUT/PATCH/DELETE `/api/doc?path=`, GET `/api/collection?path=`, POST `/api/collection`, POST `/api/batch`. WS: subscribe by path.
- Server holds authoritative state. No turn logic server-side (logic stays client-side in `shared/turn.js`).
- **Exit criteria:** backend boots, serves state over WS, persists to SQLite, unit tests green. ✅ DONE.
- **Upstream-PRable:** ❌ divergence (friend stays Firebase).
### Milestone 2 — Frontend WS adapter ✅
- Define `storage/contract.js` interface spec.
- Move all Firestore call sites from `App.js` into `storage/firebase.js` behind interface (verbatim).
- Implement `storage/server.js` per interface, talking to backend. Generic KV ops, subscribes via WebSocket.
- Implement `storage/memory.js` for frontend unit tests.
- `storage/index.js` factory: `STORAGE` env → pick impl. Default `firebase` (upstream unchanged).
- App runs against backend with `STORAGE=server`.
- Cross-device verified manually: DM view + player display + tablet.
- **Exit criteria:** app runs fully against local backend, no Firebase. Multi-device sync works. ✅ DONE.
- **Upstream-PRable:** ⚠️ partial. Storage interface + firebase extract = ✅. WS impl = ❌.
### Milestone 3 — Characterization tests lock current behavior ✅
- Lock current behavior via tests.
- Cover: START, NEXT_TURN, PAUSE, RESUME, ADD_PARTICIPANT, REMOVE_PARTICIPANT, TOGGLE_ACTIVE, REORDER, APPLY_DAMAGE/HEAL, DEATH_SAVE, END.
- Two layers: Layer 1 (App + firebase mock, proves call shape), Layer 2 (server adapter vs live backend, proves translation).
- Iterate until confident: baseline solid, regressions impossible to silently slip.
- **Exit criteria:** characterization suite green. Baseline locked. ✅ DONE.
- **Upstream-PRable:** ✅ if kept storage-agnostic (tests target turn logic shape).
### Milestone 4 — Resolve initiative rotation corruption (BUG-5) ✅
- **Fixed** (commit `494327f`).
- Slot-array turn order model + DRY advance core (`nextActiveAfter`).
Both `nextTurn` + `computeTurnOrderAfterRemoval` delegate → one advance
path, no drift.
- 500-round replay: 0 skips, 0 double-acts.
- Tests: `turn.skip.test.js`, `turn.dry.test.js` (advance parity lock).
- **Upstream-PRable:** ✅ bug fix.
### Milestone 5 — Docker compose ✅
- Single container: caddy (front, static + proxy) + node backend (internal :4001).
- Files in `docker/` tree (kept separate from upstream root Dockerfile):
- `docker/Dockerfile` — build FE + BE, runtime caddy+node
- `docker/Caddyfile` — proxy /api + /ws (WebSocket path) to node, static SPA fallback
- `docker/entrypoint.sh` — node bg + caddy fg
- `docker/docker-compose.yml` — one `app` service, volume for sqlite
- Run: `docker compose -f docker/docker-compose.yml up --build` (or `cd docker && docker compose up --build`). Port 8080.
- No `image:` field => compose auto-names, never pulls service image (private).
- **Exit criteria:** `docker compose up` runs full stack in-house. ✅ DONE.
- Verified: REST roundtrip, WS subscribe+push, replay 20 rounds CLEAN (0 skips/doubles/shifts), UI styled (Tailwind compiles).
- **Upstream-PRable:** ✅ separate docker/ tree, root Dockerfile untouched, firebase default preserved.
### Milestone 6 — Undo rework — _MOVED to TODO backlog_
- Moved: feature work (transactional undo), not infra. Lives in `TODO.md` now.
- Scope: events table `(type, payload, undo_payload, undone, ts)`; undo = apply undo_payload in tx.
### Milestone 7 — Playwright E2E (deferred)
- Multi-window E2E: DM view + display + player view in separate browser contexts against running backend.
- Verify realtime sync end-to-end.
- **Only build if sync regresses or we deviate significantly.** Turn-logic unit + backend integration tests cover most regression risk cheaper.
- **Exit criteria:** e2e green for core combat flow across 3 windows.
- **Upstream-PRable:** ✅ if test infra shared.
### Milestone 8 — (Future) Public exposure
- Real auth (`AUTH_MODE=token`).
- Rate limiting, CSRF, hardening.
- Postgres migration if load warrants.
- Only if we decide to expose publicly + multiuser.
---
## Testing strategy
### Layers
1. **Turn logic unit tests** (Jest, pure functions, `shared/tests/`). Characterization + desired. Cheap, essential.
2. **Backend integration tests** (Jest, `server/tests/`) — spin server on random port, assert WS pushes + SQLite persists + transactional correctness.
3. **Frontend adapter contract tests** (Jest, `src/tests/`) — impl parity against interface (memory). Firebase mock harness for Layer 1 App tests.
### Characterization → desired
1. **Characterization** — capture current behavior exactly (bugs included). Locks extraction/port as provably identical. Lets later fix be provable.
2. **Desired-behavior (red)** — write what *should* happen. Fail today. Fix → green. Bug stays dead. (Bug fixes live in TODO.md, tracked separately.)
### Manual smoke via config flags
- `STORAGE=firebase` → current behavior (friend's path, upstream default).
- `STORAGE=server` → our path, local backend.
- docker-compose profiles mirror the above.
### Accepted test gap
- Firebase adapter untested (requires live project). Accepted cost.
- Mitigated by: interface contract; if firebase impl drifts, integration smoke only.
---
## Mergeability upstream
| Milestone | Upstream-PRable? | Why |
|---|---|---|
| 0 repo setup | n/a | fork infra |
| 1 backend | ❌ | divergence (friend stays Firebase) |
| 2 WS adapter | ⚠️ partial | interface + firebase extract ✅, WS ❌ |
| 3 characterization tests | ✅ | if storage-agnostic |
| 4 BUG-5 rotation fix | ✅ | bug fix |
| 5 docker | ✅ | separate docker/ tree, root Dockerfile untouched, firebase preserved |
| 6 undo (moved to TODO) | - | - |
| 7 playwright | ✅ | if test infra shared |
Default `STORAGE=firebase` + `AUTH_MODE=none` (unset) = upstream sees literally zero change.
---
## Risks
- **CRA + workspaces friction.** Create React App may resist monorepo layout. Mitigation: keep `src/` as CRA root, `server/` + `shared/` as separate workspaces imported via alias. Eject/craco only if forced.
- **Firebase drift untested.** Mitigation: interface contract; friend's path his to maintain.
- **Undo history migration.** Existing log entries use old snapshot format. Mitigation: keep old undo working until cleared, new format for new entries.
- **WS reconnect/state-sync edge cases.** Transient drop mid-combat. Mitigation: client requests full state resync on (re)connect; server is source of truth.
---
## Decisions (locked)
1. **Branch:** `rework-backend` off `main`.
2. **npm workspaces** for `server/` + `shared/` alongside CRA `src/`. Fallback alias if CRA fights.
3. **Backend = generic KV doc store** (firebase mirror), not shape-specific endpoints. Thin adapter passthrough. Opaque JSON at arbitrary path strings.
---
## Current status
- M0 ✅, M1 ✅, M2 ✅, M3 ✅, M4 ✅, M5 ✅
- Backend live: port 4001, db `./data/tracker.sqlite`
- Frontend: port 3999 with `REACT_APP_STORAGE=server`
- Test suite: ~160 tests (shared + server + FE). Bugs tracked in `TODO.md`.
- Next milestones: M5 docker-compose. Undo moved to TODO backlog.
+233
View File
@@ -0,0 +1,233 @@
# Testing & Automation — Operating Guide
How to run tests, demos, audits, docker stack, and understand the test layers. For any LLM session picking up this repo.
## Test commands
```bash
npm run test:all # shared + server (fast, ~2s) — pre-push gate
npm run shared:test # pure turn logic (shared/turn.js)
npm run server:test # ws adapter vs live backend
npm test # CRA frontend (src/tests/, slow w/ scenario)
```
Pre-push hook (`.githooks/pre-push`) runs `npm run test:all`. Frontend not gated (slow). Skip: `git push --no-verify`.
Setup hook once per clone:
```bash
git config core.hooksPath .githooks
```
## Test suites
| Suite | Location | What | Count |
|---|---|---|---|
| Unit (turn logic) | `shared/tests/` | pure nextTurn, rotation, pause-add, dead-skip, reorder, round, invariant, dry | 90 |
| Integration (adapter vs backend) | `server/tests/` | ws adapter through live REST/WS | 24 |
| Characterization (UI) | `src/tests/` | locks current App.js behavior | 66 |
| ESM guard | `src/tests/StorageEsm.test.js` | no CJS in adapters | 4 |
Total: ~184. 1 known RED (BUG-4 HideHpToggle, backlog).
### Run one file / pattern
```bash
npm test --workspace shared -- --testPathPattern=round-rotation
npm run server:test -- tests/ws-reconnect
CI=true npx react-scripts test --watchAll=false --testPathPattern="DisplayView.drag-order"
```
Frontend uses `react-scripts test` (CRA). Always set `CI=true` + `--watchAll=false` for single runs.
## Test layers (important)
Two layers, both required:
- **Layer 1**: App vs firebase mock (`src/__mocks__/firebase/`). Proves adapter call shape. Never exercises ws adapter.
- **Layer 2**: ws adapter vs live backend (`server/tests/`). Proves translation + path identity.
Layer 1 alone misses adapter bugs (path mismatch, no-op players, ws event handler bugs). Layer 2 catches those.
## Test types
| Type | Purpose |
|---|---|
| **Unit** | pure logic, fast, no I/O. Locks single function behavior. |
| **Integration** | real backend per test (port 0 = OS picks free). Adapter translation verified. |
| **Characterization** | render App via mock, assert current UI behavior (buggy or not). NOT desired-state. |
| **Contract** | same spec run against every storage impl (memory, ws, firebase). Catches adapter drift. |
| **Scenario** | end-to-end flow through rendered App. `Combat.scenario.test.js` = 100 rounds, ~240s. Pre-existing crash (BUG-11). |
## TDD discipline
RED first → fix → GREEN. Never change functional code to pass tests for existing state without test driving it.
- Find bug → write failing test (RED)
- Fix code → test passes (GREEN)
- Log confirmed bug in `TODO.md`
- One bug at a time, commit with evidence
## Replay tool (demo, NOT unit test)
`scripts/replay-combat.js` — drives full combat via ws adapter (same contract as App) against live backend. UI updates in real-time if frontend running.
```bash
# start backend + frontend first
node scripts/replay-combat.js [rounds] [delayMs]
# defaults: 100 rounds, 200ms/step
# faster: 20 400 = 20 rounds, 400ms each
# against docker stack:
BACKEND_URL=http://127.0.0.1:8080 node scripts/replay-combat.js 20 400
```
Coverage per round: damage, heal, all 22 conditions, toggleActive, removeParticipant, addParticipant (reinforcements), updateParticipant, pause/resume, reorderParticipants, endEncounter. Revives dead each round to sustain count.
Output → log file, then analyze:
```bash
node scripts/replay-combat.js 20 400 > tmp/run.log 2>&1
node scripts/analyze-turns.js tmp/run.log
```
Exit 0 = clean. Reports skips, double-acts, order shifts.
### analyze-turns.js
Parses replay log. Detects:
- **real skips**: active participant not acted in a round
- **double-acts**: same participant twice in a round
- **order shifts**: turnOrderIds changed unexpectedly
Handles `[pointer X→Y wrap]` events (mutation-driven advance) and `[reorder A→before B]`. Logs `order=[Name:init,...]` + `parts=[Name:init,...]` per turn. Parser blind to DisplayView render (separate concern — FE test covers that).
Round marker: `--- round N starting ---` (top of loop, post-fix).
## Audit tools (NOT unit tests)
`tests/audit/` — exploratory, `Math.random`, non-deterministic. Manual run. NOT jest.
### audit-rotation.js
Pure turn.js simulation of replay op sequence. Detects rotation violations. Found BUG-1.
```bash
node tests/audit/audit-rotation.js
```
### audit-state.js
Runs pure turn.js combat. Audits 9 invariant classes per round:
1. rotation integrity (skip/dupe)
2. HP bounds (0 ≤ hp ≤ max, no NaN)
3. isActive consistency (inactive skipped; monster death auto-inactive; character/NPC death DM-controlled)
4. turnOrder no dup ids
5. turnOrder ids all active
6. currentTurn valid + active
7. deathSave range (0-3, reset on revive)
8. removeParticipant orphans
9. undo support
```bash
node tests/audit/audit-state.js [rounds] # default 100
```
Current state: 0 violations / 100 rounds (post BUG-1/2 fix).
## Docker stack
Single container: caddy (front, static + proxy) + node backend (internal :4001).
```bash
# build + run (from repo root)
docker compose -f docker/docker-compose.yml up --build -d
# → http://127.0.0.1:8080
# logs
docker compose -f docker/docker-compose.yml logs app --tail 20
# stop
docker compose -f docker/docker-compose.yml down
# rebuild after code change
docker compose -f docker/docker-compose.yml up -d --build
```
Files:
- `docker/Dockerfile` — build FE + BE, runtime caddy+node
- `docker/Caddyfile` — proxy /api + /ws to node, static SPA fallback
- `docker/entrypoint.sh` — runs node bg + caddy fg
- `docker/docker-compose.yml` — one `app` service, volume for sqlite
### Verify docker stack
```bash
# REST roundtrip
curl -s -X PUT http://127.0.0.1:8080/api/doc -H 'Content-Type: application/json' \
-d '{"path":"campaigns/test","data":{"name":"X"}}' >/dev/null
curl -s "http://127.0.0.1:8080/api/doc?path=campaigns/test"
# WS subscribe + push (node one-liner, see scripts)
# Full combat: replay against docker
BACKEND_URL=http://127.0.0.1:8080 node scripts/replay-combat.js 20 400 > tmp/docker.log 2>&1
node scripts/analyze-turns.js tmp/docker.log
```
### Inspect docker sqlite
```bash
docker exec docker-app-1 sh -c 'node -e "
const db=require(\"better-sqlite3\")(\"/data/tracker.sqlite\");
const rows=db.prepare(\"SELECT path, substr(data,1,50) as d FROM docs\").all();
console.log(\"count=\"+rows.length);
rows.forEach(r=>console.log(r.path+\" => \"+r.d));
"'
```
## Dev servers (non-docker)
### Backend
```bash
npm run server:dev # :4001, db: ./data/tracker.sqlite
# or:
DB_PATH=./data/tracker.sqlite PORT=4001 node server/index.js
curl http://127.0.0.1:4001/health # → {"ok":true}
```
Never db in `/tmp` (wipe risk). Use `./data/` (gitignored) or docker volume.
### Frontend (server mode)
```bash
REACT_APP_STORAGE=server \
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
REACT_APP_BACKEND_REALTIME_URL=ws://127.0.0.1:4001/ws \
BROWSER=none PORT=3999 \
npm start
```
→ http://127.0.0.1:3999/. Admin `/`, player `/display`.
Firebase mode (default): set `REACT_APP_FIREBASE_*` in `.env.local` (copy `env.example`).
## Storage modes
`STORAGE_MODE = getStorageMode()` reads `REACT_APP_STORAGE`:
- `firebase` (default) → real SDK
- `server` → backend (docker/prod)
All adapters ESM. Adapter contract: `src/storage/contract.js` — same spec vs server/firebase.
## Known RED / backlog
- BUG-4: HideHpToggle RED (setDoc→updateDoc, clobbers activeDisplay)
- BUG-10: deact+reactivate double-act
- BUG-11: Combat.scenario test crash
- BUG-13: reorder cross-pointer semantics
- BUG-14: addParticipant init-insert post-drag
See `TODO.md` for full list + status.
## Scratch
`scratch/` — gitignored throwaway. Repro scripts, exploration, debug. Not committed. Use freely, delete anytime.
## Status
See `docs/REWORK_PLAN.md` for milestones, `TODO.md` for bugs, `docs/DEVELOPMENT.md` for setup, `docs/GLOSSARY.md` for terms, `docs/ENCOUNTER_BUILDER.md` for DM interface.
+8 -1
View File
@@ -6,4 +6,11 @@ REACT_APP_FIREBASE_STORAGE_BUCKET="YOUR_FIREBASE_STORAGE_BUCKET_HERE"
REACT_APP_FIREBASE_MESSAGING_SENDER_ID="YOUR_FIREBASE_MESSAGING_SENDER_ID_HERE"
REACT_APP_FIREBASE_APP_ID="YOUR_FIREBASE_APP_ID_HERE"
REACT_APP_TRACKER_APP_ID="ttrpg-initiative-tracker-default"
REACT_APP_TRACKER_APP_ID="ttrpg-initiative-tracker-default"
# --- Self-hosted backend mode (optional) ---
# Default storage = firebase (SDK). Set REACT_APP_STORAGE=server to use the
# bundled Express + WebSocket backend (server/) instead.
# REACT_APP_STORAGE="server"
# REACT_APP_BACKEND_URL="http://127.0.0.1:4001"
# REACT_APP_BACKEND_REALTIME_URL="ws://127.0.0.1:4001/ws"
Binary file not shown.

After

Width:  |  Height:  |  Size: 658 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

+9
View File
@@ -0,0 +1,9 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
+6720 -1458
View File
File diff suppressed because it is too large Load Diff
+20 -8
View File
@@ -2,25 +2,34 @@
"name": "ttrpg-initiative-tracker",
"version": "0.1.0",
"private": true,
"workspaces": [
"server",
"shared"
],
"dependencies": {
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"autoprefixer": "^10.4.19",
"firebase": "^10.12.2",
"lucide-react": "^0.395.0",
"postcss": "^8.4.38",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3"
"tailwindcss": "^3.4.3",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"build": "CI=true react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
"test:ci": "./scripts/cap.sh 60 npx react-scripts test --watchAll=false",
"test:one": "./scripts/cap.sh 60 npx react-scripts test --watchAll=false --testPathPattern",
"eject": "react-scripts eject",
"server:dev": "npm run dev --workspace server",
"server:test": "npm test --workspace server",
"shared:test": "npm test --workspace shared",
"test:all": "./scripts/run-tests.sh"
},
"eslintConfig": {
"extends": [
@@ -39,5 +48,8 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@testing-library/react": "^14.3.1"
}
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"id": "/display",
"short_name": "TTRPG Display",
"name": "TTRPG Initiative Tracker - Player Display",
"icons": [
{
"src": "player-favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "player-logo192.png",
"note": "this image intentionally missing from filesystem - keeping manfiest and having broken icon allows android to _shortcut_ which user can control name of.",
"type": "image/png",
"sizes": "192x192"
}
],
"start_url": "/display",
"scope": "/display",
"note:" "none of the display stuff actually works, even when it properly installs a pwa (which user cant control name of...)",
"display": "fullscreen",
"orientation": "portrait",
"theme_color": "#1A202C",
"background_color": "#1A202C"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

+4 -1
View File
@@ -10,10 +10,13 @@
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700;900&family=Alegreya+Sans:ital,wght@0,400;0,500;0,700;1,400&display=swap" rel="stylesheet">
<title>TTRPG Initiative Tracker</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+8 -8
View File
@@ -1,4 +1,5 @@
{
"id": "/",
"short_name": "TTRPG Tracker",
"name": "TTRPG Initiative Tracker",
"icons": [
@@ -9,17 +10,16 @@
},
{
"src": "logo192.png",
"note": "this image intentionally missing from filesystem - keeping manfiest and having broken icon allows android to _shortcut_ which user can control name of.",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"start_url": "/",
"scope": "/",
"note:" "none of the display stuff actually works, even when it properly installs a pwa (which user cant control name of...)",
"display": "fullscreen",
"orientation": "portrait",
"theme_color": "#2D3748",
"background_color": "#1A202C"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

+35
View File
@@ -0,0 +1,35 @@
# scripts/
Dev orchestration + manual demo tool. NOT test.
## dev-start.sh / dev-stop.sh
Local dev stack: backend (:4001, sqlite) + frontend (:3999, server mode).
One command. Writes env vars for you. Logs to `tmp/server.log`, `tmp/fe.log`.
```bash
./scripts/dev-start.sh # start (idempotent: leaves running ports as-is)
./scripts/dev-stop.sh # stop both
```
## replay-combat.js
Live backend demo. Drives full combat via server adapter (same contract as App).
Player display live-updates. Watch UI react to state changes.
```bash
# start backend + frontend first (see docs/DEVELOPMENT.md)
node scripts/replay-combat.js [rounds] [delayMs]
# defaults: 100 rounds, 200ms/step
```
Coverage per round: damage, heal, all 22 conditions, toggleActive,
removeParticipant, addParticipant (reinforcements), updateParticipant,
pause/resume, reorderParticipants, endEncounter. Revives dead each round
to sustain full round count.
## See also
- `tests/audit/` — exploratory bug-finders (manual run, non-deterministic)
- `{shared,server,src}/tests/` — jest unit/integration/characterization
- `scratch/` — gitignored throwaway
+436
View File
@@ -0,0 +1,436 @@
// scripts/analyze-turns.js
// Invariant checker for combat rotation. Source-agnostic.
//
// Input (autodetect):
// .jsonl file — replay-combat trace: per-step {step,ts,type,call:{fn,args},
// pre,post}. pre/post = backend read-back snapshots.
// .json array — downloaded log OR exported events. {ts,type,...,snapshot}.
// snapshot = what turn.js logged (lighter: no participants[]).
// .log file — replay stdout: extract trace path from 'trace written:' line.
// stdin — either jsonl or json.
// no arg = usage. User must specify path.
//
// INVARIANTS (define correctness; no prediction):
// 1. round monotonically ascends; +1 only on pointer wrap (last→first active).
// No backward, no double-increment, no skip.
// 2. pointer advances forward in turnOrderIds (mod wrap), skipping inactive.
// Never backward, never stationary except on pause/non-rotation mutations.
// 3. no double-act: in one rotation cycle each active participant becomes
// current ≤1 time.
// 4. no real skip: participant active for full cycle, never removed/deactivated,
// but never became current = skipped.
// 5. order stable across non-reorder mutations. turnOrderIds shift without
// add/remove/reorder = display divergence.
// 6. slot order (initiative desc, tie-break stable) maintained except after
// explicit reorder. Replay-trace only (needs participants[].initiative).
//
// Exit 0 clean, 1 issues found.
'use strict';
const fs = require('fs');
// ---------- input ----------
function readInput() {
const arg = process.argv[2];
// No arg + no stdin = usage.
if (!arg && process.stdin.isTTY) {
console.error('Usage: node scripts/analyze-turns.js <trace.jsonl | logs.json | replay.log>');
console.error(' cat events | node scripts/analyze-turns.js');
process.exit(2);
}
if (!arg) {
const stdin = fs.readFileSync(0, 'utf8');
if (!stdin.trim()) {
console.error('Usage: node scripts/analyze-turns.js <trace.jsonl | logs.json | replay.log>');
console.error(' cat events | node scripts/analyze-turns.js');
process.exit(2);
}
return stdin;
}
// Replay stdout .log: extract trace path from `trace written:` line.
if (/\.log$/i.test(arg)) {
const logText = fs.readFileSync(arg, 'utf8');
const m = logText.match(/trace written: \d+ steps -> (.+)$/m);
if (m) {
const tracePath = m[1].trim();
if (fs.existsSync(tracePath)) {
console.error(`[analyze] trace: ${tracePath}`);
return fs.readFileSync(tracePath, 'utf8');
}
console.error(`[analyze] trace path from .log not found: ${tracePath}`);
process.exit(2);
}
console.error(`[analyze] no 'trace written:' line in ${arg}`);
process.exit(2);
}
return fs.readFileSync(arg, 'utf8');
}
// snake_case log type → camelCase fn (invariant checks match both shapes).
// replay JSONL already camelCase; unchanged.
function normalizeFn(fn) {
if (!fn) return fn;
if (!fn.includes('_')) return fn;
return fn.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
}
// Parse input text → array of step-arrays (one per encounter for downloaded
// logs, one for replay JSONL trace). Each step:
// { step, ts, type, fn, args, pre, post, error }
// JSONL: pre/post from backend read-back. JSON array: post=snapshot, no pre.
function loadSteps(text) {
const trimmed = text.trim();
if (!trimmed) return [];
// JSONL: one JSON obj per line (each starts '{' + parses standalone).
const looksJsonl = (() => {
const lines = trimmed.split('\n');
if (lines.length < 2) return false;
const first = lines[0].trim();
const second = lines[1].trim();
if (!first.startsWith('{') || !second.startsWith('{')) return false;
try { JSON.parse(first); JSON.parse(second); return true; }
catch { return false; }
})();
if (looksJsonl) {
const steps = trimmed.split('\n').filter(l => l.trim()).map(l => {
const r = JSON.parse(l);
return {
step: r.step, ts: r.ts, type: r.type,
fn: normalizeFn(r.call ? r.call.fn : r.type),
args: r.call ? r.call.args : null,
pre: r.pre || null, post: r.post || null, error: r.error || null,
};
});
return [steps]; // single trace
}
// JSON array. Downloaded logs may merge multiple encounters → split by id.
const raw = JSON.parse(trimmed);
const arr = Array.isArray(raw) ? raw : [raw];
const groups = new Map(); // encounterId -> []
for (const e of arr) {
const key = e.encounterId || '_none_';
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(e);
}
const out = [];
const toSteps = (evs) => evs.map((e, i) => ({
step: i + 1, ts: e.ts || 0, type: e.type, fn: normalizeFn(e.type), args: null,
pre: null,
post: e.snapshot ? {
round: e.snapshot.round,
currentTurnParticipantId: e.snapshot.currentTurnParticipantId,
isStarted: true, isPaused: false,
turnOrderIds: e.snapshot.turnOrderIds || [],
activeIds: e.snapshot.activeIds || [],
participants: null, // downloaded logs lack full participant roster
} : null,
error: null,
}));
for (const evs of groups.values()) {
// Same encounterId may span multiple combat runs (restart via
// start_encounter). Sub-split so each rotation cycle analyzed within one
// continuous run. start_encounter = run boundary — BUT only flush if a
// prior run already started (i.e. true restart). First start_encounter
// after pure setup (add_participant etc.) stays with its setup events.
let cur = [];
let started = false;
const flush = () => { if (cur.length) { out.push(toSteps(cur)); cur = []; } started = false; };
for (const e of evs) {
if (e.type === 'start_encounter' && started) flush();
if (e.type === 'start_encounter') started = true;
cur.push(e);
}
flush();
}
return out;
}
// ---------- helpers ----------
const nameMap = new Map(); // id -> name (built lazily from snapshots)
function learnNames(steps) {
for (const s of steps) {
for (const snap of [s.pre, s.post]) {
if (snap && Array.isArray(snap.participants)) {
for (const p of snap.participants) if (p.id && p.name) nameMap.set(p.id, p.name);
}
}
}
}
function nm(id) { return id ? (nameMap.get(id) || id.slice(0, 8)) : '(none)'; }
// next active position after fromPos in order, skipping inactive. Mirrors
// turn.js nextActiveAfter so we know what SHOULD have happened — but this is
// invariant definition, not prediction: we check the ACTUAL post-current.
function expectedAdvance(order, fromPos, isActive) {
const n = order.length;
if (n === 0) return { nextId: null, wrapped: false };
for (let step = 1; step < n; step++) {
const idx = (fromPos + step) % n;
const id = order[idx];
if (isActive(id)) return { nextId: id, wrapped: idx <= fromPos };
}
// solo active = stays itself (turn.js would throw; treat as no-advance)
return { nextId: null, wrapped: false };
}
// ---------- invariant checks ----------
// Split analysis into independent passes. Each invariant = own function.
// Entangling cycle-skip tracking with per-step mutation handling caused
// stale-set false positives (active-set rebuilt on every roster mutation
// discarded the cycle-start snapshot).
function analyze(steps) {
const issues = [];
const rounds = new Map();
function ensureRound(r) {
if (!rounds.has(r)) rounds.set(r, { turnCount: 0, issues: [] });
return rounds.get(r);
}
// ---- per-step: round monotonic, advance direction, order stability ----
for (let i = 0; i < steps.length; i++) {
const s = steps[i];
const pre = s.pre || (i > 0 ? steps[i - 1].post : null);
const post = s.post;
if (!post) continue;
const isNextTurn = s.fn === 'nextTurn' || s.type === 'next_turn';
const isStart = s.fn === 'startEncounter' || s.type === 'start_encounter';
const isEnd = s.fn === 'endEncounter' || s.type === 'end_encounter' || s.fn === 'auto_end' || s.type === 'auto_end';
if (isStart) { ensureRound(post.round || 1).turnCount++; continue; }
if (isEnd) continue;
if (isNextTurn) {
ensureRound(post.round || 0).turnCount++;
if (!pre) continue;
const order = pre.turnOrderIds || [];
const fromPos = order.indexOf(pre.currentTurnParticipantId);
const isActive = id => (pre.activeIds || []).includes(id);
const exp = expectedAdvance(order, fromPos, isActive);
const actual = post.currentTurnParticipantId;
// invariant 2: correct advance target
if (exp.nextId && actual && actual !== exp.nextId) {
issues.push({ step: s.step, round: post.round, kind: 'wrong_advance',
expected: nm(exp.nextId), actual: nm(actual),
detail: `nextTurn → ${nm(actual)}, expected ${nm(exp.nextId)}` });
}
// invariant 1: round monotonic + no phantom/skip
if (pre.round !== undefined && post.round !== undefined) {
if (post.round < pre.round)
issues.push({ step: s.step, kind: 'round_backward', from: pre.round, to: post.round,
detail: `round backward ${pre.round}${post.round}` });
if (post.round > pre.round + 1)
issues.push({ step: s.step, kind: 'round_skip', from: pre.round, to: post.round,
detail: `round jumped ${pre.round}${post.round}` });
if (post.round === pre.round + 1 && !exp.wrapped)
issues.push({ step: s.step, kind: 'round_phantom', from: pre.round, to: post.round,
detail: `round incremented without pointer wrap` });
}
continue;
}
// non-rotation mutation: invariant 5 order stability
if (pre && post && !orderChangedByRosterOrReorder(s.fn)) {
const before = JSON.stringify(pre.turnOrderIds || []);
const after = JSON.stringify(post.turnOrderIds || []);
if (before !== after && pre.turnOrderIds && pre.turnOrderIds.length) {
issues.push({ step: s.step, kind: 'order_shift', fn: s.fn,
detail: `turnOrderIds changed without add/remove/reorder (${s.fn})` });
}
}
}
// ---- dedicated cycle pass: skip + double-act (invariants 3+4) ----
// Cycle = all-act-once between pointer wraps. Snapshot active-set at cycle
// start. Track removals/deactivations as legitimate disqualifications.
// Skip = in start-set, never disqualified, never acted.
issues.push(...checkCycles(steps));
return { issues, rounds };
}
// checkCycles: walk steps, maintain rotation cycle state. On wrap/end,
// finalize: skip = activeAtStart minus (acted disqualified). Double-act =
// current that became current >1 in cycle (excluding the legit starter).
function checkCycles(steps) {
const out = [];
let cycleActive = new Set(); // snapshot at cycle start (immutable for cycle)
let cycleActed = new Set();
let cycleRemoved = new Set(); // removed/disqualified mid-cycle (no skip flag)
let cycleStarter = null;
let cycleRound = null;
let started = false;
// disqualify by active-set delta, not fn name. Log types vary (deactivate,
// reactivate, remove_participant, add_participant...). Any step where an id
// leaves activeIds = disqualified. Any id entering = joins cycle.
function finalize(endStep) {
if (!started) return;
// disqualify: anyone removed/deactivated mid-cycle is gone (legit)
// skip = was active at start, never acted, never disqualified
const skipped = [...cycleActive].filter(id => !cycleActed.has(id));
if (skipped.length) {
out.push({ step: endStep, round: cycleRound, kind: 'real_skip',
actors: skipped.map(nm), detail: `active full cycle, never acted` });
}
}
for (let i = 0; i < steps.length; i++) {
const s = steps[i];
const pre = s.pre || (i > 0 ? steps[i - 1].post : null);
const post = s.post;
if (!post) continue;
const isNextTurn = s.fn === 'nextTurn' || s.type === 'next_turn';
const isStart = s.fn === 'startEncounter' || s.type === 'start_encounter';
const isEnd = s.fn === 'endEncounter' || s.type === 'end_encounter' || s.fn === 'auto_end' || s.type === 'auto_end';
if (isStart) {
finalize(s.step);
cycleRound = post.round || 1;
cycleActive = new Set(post.activeIds || []);
cycleActed = new Set(post.currentTurnParticipantId ? [post.currentTurnParticipantId] : []);
cycleStarter = post.currentTurnParticipantId;
started = true;
continue;
}
if (isEnd) { started = false; continue; } // end: abandon cycle, no skip verdict (incomplete)
// CRITICAL: pointer (currentTurnParticipantId) changes via BOTH nextTurn
// AND mutation-advance (toggleActive/remove of current auto-advances via
// computeTurnOrderAfterRemoval). Any new current = got the turn = acted.
// Only count this on nextTurn (normal) or when a mutation actually moved
// the pointer (pre.current != post.current).
if (started && pre && post.currentTurnParticipantId &&
pre.currentTurnParticipantId !== post.currentTurnParticipantId) {
const wrapped = pre.round !== undefined && post.round !== undefined && post.round !== pre.round;
if (wrapped && isNextTurn) {
finalize(s.step);
cycleRound = post.round;
cycleActive = new Set(post.activeIds || []);
cycleActed = new Set(post.currentTurnParticipantId ? [post.currentTurnParticipantId] : []);
cycleStarter = post.currentTurnParticipantId;
} else {
const c = post.currentTurnParticipantId;
if (cycleActed.has(c) && c !== cycleStarter) {
out.push({ step: s.step, round: post.round, kind: 'double_act', actor: nm(c),
detail: `${nm(c)} acted twice in round ${post.round} (via ${s.fn})` });
}
cycleActed.add(c);
}
}
if (isNextTurn) continue;
// roster mutation mid-cycle: cycleActive = cycle-start snapshot (immutable).
// invariant 4 = active FULL cycle → only removals disqualify (can't have
// been full-cycle if removed). Mid-cycle additions don't qualify for skip
// check, so never enroll them. Revivals: stay out (can act, not skip-flag).
if (pre && post && pre.activeIds && post.activeIds) {
const postSet = new Set(post.activeIds);
for (const id of [...cycleActive]) {
if (!postSet.has(id)) {
cycleRemoved.add(id);
cycleActive.delete(id);
cycleActed.delete(id);
}
}
// no mid-cycle enrollment: new ids weren't active at cycle start.
}
}
// no final finalize: incomplete cycle can't be judged for skips.
return out;
}
// roster/order-affecting fns where turnOrderIds change is EXPECTED.
// Handle both camelCase (replay trace fn) + snake_case (log type).
function orderChangedByRosterOrReorder(fn) {
return [
'addParticipant','addParticipants','removeParticipant','reorderParticipants',
'startEncounter','endEncounter','setup_encounter','setup_campaign',
'add_participant','add_participants','remove_participant','reorder',
'start_encounter','end_encounter',
].includes(fn);
}
// slot order (invariant 6) — replay trace only (needs participants[].initiative)
function checkSlotOrder(steps) {
const violations = [];
let prevOrder = null; // [{id,init}]
let prevStep = 0;
const orderAffecting = new Set(['addParticipant','addParticipants','removeParticipant',
'reorderParticipants','startEncounter','setup_encounter','setup_campaign']);
for (const s of steps) {
if (!s.post || !Array.isArray(s.post.participants)) continue;
const cur = s.post.participants.map(p => ({ id: p.id, init: p.initiative, name: p.name }));
if (prevOrder && prevOrder.length === cur.length) {
const sameIds = prevOrder.every((p, i) => p.id === cur[i].id);
if (sameIds) {
// same roster, same order — check initiative monotonic desc with stable ties
for (let i = 1; i < cur.length; i++) {
if (cur[i].initiative > cur[i - 1].initiative) {
// initiative ascended — only ok if a reorder happened
if (!orderAffecting.has(s.fn)) {
violations.push({ step: s.step, kind: 'slot_violation',
at: i, prev: nm(cur[i-1].id)+':'+cur[i-1].init,
cur: nm(cur[i].id)+':'+cur[i].init,
detail: `initiative ascended without reorder` });
}
}
}
}
}
prevOrder = cur;
prevStep = s.step;
}
return violations;
}
// ---------- reporting ----------
function reportOne(label, steps) {
learnNames(steps);
const { issues, rounds } = analyze(steps);
const slotViolations = checkSlotOrder(steps);
const all = [...issues, ...slotViolations].sort((a, b) => (a.step || 0) - (b.step || 0));
const byKind = {};
for (const it of all) byKind[it.kind] = (byKind[it.kind] || 0) + 1;
console.log(`=== ${label}${steps.length} steps, ${rounds.size} rounds ===`);
if (all.length === 0) {
console.log('CLEAN');
return 0;
}
console.log(`--- ${all.length} issues ---`);
for (const k of Object.keys(byKind)) console.log(` ${k}: ${byKind[k]}`);
for (const it of all.slice(0, 30)) {
const where = it.round != null ? `R${it.round} ` : '';
console.log(` step ${it.step} ${where}${it.kind}: ${it.detail || ''}`);
}
if (all.length > 30) console.log(` ... +${all.length - 30} more`);
return all.length;
}
const text = readInput();
const allSteps = loadSteps(text); // array of step-arrays (one per encounter)
let total = 0;
for (let i = 0; i < allSteps.length; i++) {
const label = allSteps.length > 1 ? `[encounter ${i + 1}/${allSteps.length}]` : 'trace';
if (i > 0) console.log('');
total += reportOne(label, allSteps[i]);
}
console.log(`\n=== ${allSteps.length} source(s), ${total} total issues ===`);
process.exit(total === 0 ? 0 : 1);
Executable
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# scripts/cap.sh — wrap a command with hard timeout. Hang = FAIL.
# Picks gtimeout (mac coreutils) or timeout (linux).
# Usage: cap.sh <seconds> <cmd> [args...]
set -euo pipefail
TIMEOUT_BIN=gtimeout
command -v gtimeout >/dev/null 2>&1 || TIMEOUT_BIN=timeout
if ! command -v "$TIMEOUT_BIN" >/dev/null 2>&1; then
echo "ERROR: need 'timeout' or 'gtimeout'" >&2
exit 2
fi
secs="$1"; shift
exec "$TIMEOUT_BIN" --signal=KILL "$secs" "$@"
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env node
// scripts/combat.js
// ONE tool. Replaces replay-combat.js + analyze-turns.js + replay-from-logs.js.
//
// Two modes (same binary):
//
// combat replay — drive a fresh combat through LIVE backend, write log
// combat verify — read any log file, check combat rotated correctly
//
// WHY ONE TOOL:
// Same data, same words. Old 3-tool split = format + naming nightmare.
// Replay writes the log, verify reads it. Same log shape both ends.
//
// LOG FORMAT:
// JSON array. Same as app download. One mental model. NOT jsonl.
// Each entry = canonical lean event (shared/logEvent.js shape):
// { id, ts, type, message, encounterId, encounterName, encounterPath,
// participantId, participantName, delta, undo, undone, snapshot }
// snapshot = { round, currentTurnParticipantId, turnOrderIds, activeIds }
//
// USAGE:
// combat replay [rounds] [delayMs] --out <log.json> [-v]
// combat verify <log.json>
// cat events.json | combat verify
//
// EXIT:
// replay: 0 success, 1 error, 2 bad args
// verify: 0 all checks pass, 1 bugs found, 2 bad args/no input
//
// WHAT VERIFY CHECKS (in DM words, not internals):
// - Round count go up correctly (no skip, no jump, no backward)
// - Turn pass to right person each step
// - Nobody act twice in same round
// - Nobody get skipped who was active whole round
// - Turn order stay stable (no random reshuffle)
// - Initiative slot order hold (replay logs only — need full roster)
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const shared = require('../shared');
const { normalizeEvent, serializeEvents } = shared.logEvent;
// =============================================================================
// ARGS
// =============================================================================
function parseArgs(argv) {
const mode = argv[0];
const rest = argv.slice(1);
if (mode !== 'replay' && mode !== 'verify') return { mode: null };
const out = { mode, verbose: false, out: null, positional: [] };
for (let i = 0; i < rest.length; i++) {
const a = rest[i];
if (a === '-v' || a === '--verbose') { out.verbose = true; continue; }
if (a === '--out' && rest[i + 1]) { out.out = rest[i + 1]; i++; continue; }
out.positional.push(a);
}
return out;
}
function usageReplay() {
console.error('Usage: combat replay [rounds] [delayMs] --out <log.json> [-v]');
console.error(' --out <log.json> REQUIRED. Path you control.');
console.error(' rounds default 20, delayMs default 200');
console.error(' -v / --verbose log every action per turn');
process.exit(2);
}
function usageVerify() {
console.error('Usage: combat verify <log.json>');
console.error(' cat events.json | combat verify');
process.exit(2);
}
const args = parseArgs(process.argv.slice(2));
if (!args.mode) {
console.error('combat — unified replay + verify for the TTRPG combat tracker');
console.error('');
console.error('Usage:');
console.error(' combat replay [rounds] [delayMs] --out <log.json> [-v]');
console.error(' combat verify <log.json>');
console.error(' cat events.json | combat verify');
process.exit(2);
}
// =============================================================================
// REPLAY MODE
// =============================================================================
// Drive fresh combat through live backend. Each mutation = one lean log entry
// written to --out path. DM-facing stdout: round headers + per-turn actions.
// Same log shape as app download. verify reads it back.
if (args.mode === 'replay') {
if (!args.out) usageReplay();
require('./combat/replay.js')(args).catch(err => {
console.error('replay failed:', err);
process.exit(1);
});
}
// =============================================================================
// VERIFY MODE
// =============================================================================
// Read a combat log (app download OR combat replay output). Check rotation
// correctness. DM-facing output: combat name, rounds, per-round turn list,
// checks pass/fail. No "mutation", "pointer", "invariant" in output.
if (args.mode === 'verify') {
const logPath = args.positional[0];
let text;
if (logPath) {
if (!fs.existsSync(logPath)) {
console.error(`combat verify: file not found: ${logPath}`);
process.exit(2);
}
text = fs.readFileSync(logPath, 'utf8');
} else if (!process.stdin.isTTY) {
text = fs.readFileSync(0, 'utf8');
}
if (!text || !text.trim()) usageVerify();
try {
const result = require('./combat/verify.js')(text, { verbose: args.verbose });
process.exit(result);
} catch (err) {
console.error('verify failed:', err);
process.exit(1);
}
}
+415
View File
@@ -0,0 +1,415 @@
// scripts/combat/replay.js
// Drive fresh combat through LIVE backend. Write lean log to --out path.
// Same log shape as app download (JSON array of canonical events).
//
// DM-facing stdout: round headers + per-turn actions. No internals leak.
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const shared = require('../../shared');
const { normalizeEvent } = shared.logEvent;
const {
buildCharacterParticipant, buildMonsterParticipant,
startEncounter, nextTurn, togglePause, endEncounter,
addParticipant, updateParticipant, removeParticipant,
toggleParticipantActive, applyHpChange, deathSave,
stabilizeParticipant, reviveParticipant,
toggleCondition, reorderParticipants,
} = shared;
const { createServerStorage } = require('../../src/storage/server');
const BACKEND = process.env.BACKEND_URL || 'http://127.0.0.1:4001';
const WS_URL = process.env.BACKEND_REALTIME_URL || BACKEND.replace(/^http/, 'ws') + '/ws';
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
const PUB = `artifacts/${APP_ID}/public/data`;
const getPath = {
campaigns: () => `${PUB}/campaigns`,
campaign: (id) => `${PUB}/campaigns/${id}`,
encounters: (cid) => `${PUB}/campaigns/${cid}/encounters`,
encounter: (cid, eid) => `${PUB}/campaigns/${cid}/encounters/${eid}`,
activeDisplay: () => `${PUB}/activeDisplay/status`,
logs: () => `${PUB}/logs`,
};
const storage = createServerStorage({ baseUrl: BACKEND, realtimeUrl: WS_URL });
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
function buildRoster() {
return [
{ id: 'char-fighter', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 },
{ id: 'char-cleric', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 },
{ id: 'char-rogue', name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 },
];
}
function buildMonsters() {
return [
{ name: 'Goblin1', maxHp: 30, initMod: 2 },
{ name: 'Goblin2', maxHp: 30, initMod: 2 },
{ name: 'OrcBoss', maxHp: 120, initMod: 1 },
{ name: 'Wolf', maxHp: 40, initMod: 3 },
{ name: 'Merchant', maxHp: 30, initMod: 0, asNpc: true },
];
}
const CONDITIONS = [
'alchemist_fire','bardic_inspiration','blinded','charmed','deafened',
'frightened','grappled','incapacitated','invisible','paralyzed',
'petrified','poisoned','prone','restrained','sapped','shield',
'slowed','stunned','unconscious','vexed',
];
const CUSTOM_CONDITIONS = ['hexed','rager','marked_for_death','shield_blessed'];
const ALL_CONDITIONS = [...CONDITIONS, ...CUSTOM_CONDITIONS];
let condIdx = 0;
// Lean snapshot — verify needs rotation fields only.
function snapshot(enc) {
if (!enc) return null;
return {
round: enc.round ?? 0,
currentTurnParticipantId: enc.currentTurnParticipantId ?? null,
turnOrderIds: [...(enc.turnOrderIds || [])],
activeIds: (enc.participants || []).filter(p => p.isActive).map(p => p.id),
};
}
function nameOf(enc, id) {
if (!id || !enc) return '(none)';
const p = (enc.participants || []).find(x => x.id === id);
return p ? p.name : '(missing)';
}
// resolve participantId + name from call args + encounter state
let _encCache = null;
function resolveId(a) {
if (!a) return null;
const name = a.target || a.participant || a.dragged || a.name;
if (!name || !_encCache) return null;
const p = (_encCache.participants || []).find(x => x.name === name);
return p ? p.id : null;
}
function resolveName(a) {
if (!a) return null;
return a.target || a.participant || a.dragged || a.name || null;
}
module.exports = async function replay(args) {
// In pipelines, Ctrl-C often kills downstream (e.g. timestamper) first.
// Then any later console.log hits EPIPE and process dies before cleanup.
// Ignore broken output pipe so SIGINT cleanup can still end combat + verify.
for (const s of [process.stdout, process.stderr]) {
s.on('error', (err) => {
if (err && err.code === 'EPIPE') return;
throw err;
});
}
const ROUNDS = parseInt(args.positional[0], 10);
const DELAY = parseInt(args.positional[1], 10);
const rounds = Number.isNaN(ROUNDS) ? 20 : ROUNDS;
const delay = Number.isNaN(DELAY) ? 200 : DELAY;
// Safety only. Roster grows via reinforcements, so fixed rounds*30 was too low
// and killed long replays around 6000 events. Keep generous hard stop.
const MAX_STEPS = Math.max(rounds * 250, 10000);
const VERBOSE = args.verbose;
const runStamp = new Date().toISOString().slice(0, 19).replace('T', '_').replace(/:/g, '-');
const campaignId = crypto.randomUUID();
const encounterId = crypto.randomUUID();
const encounterPath = getPath.encounter(campaignId, encounterId);
const activeDisplayPath = getPath.activeDisplay();
const ctx = { storage, encPath: encounterPath, logPath: getPath.logs(), displayPath: activeDisplayPath };
// ensure parent dir exists
const traceDir = path.dirname(args.out);
if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
const events = []; // collected lean events, flushed to --out at end
let stepN = 0;
let prevEnc = null;
let interrupted = false;
let finishing = false;
const onSigint = () => {
interrupted = true;
console.error('\ninterrupted — ending combat and verifying partial log...');
finishAndExit().catch(err => {
console.error('interrupt cleanup failed:', err);
process.exit(1);
});
};
process.once('SIGINT', onSigint);
// callStep: trust func return (post), no per-step getDoc. Build lean event
// shape = same as app download. Pre/post snapshot = ground truth.
async function callStep(fn, argsObj, runner) {
stepN++;
const encBefore = prevEnc !== null ? prevEnc : await storage.getDoc(encounterPath);
const preSnap = snapshot(encBefore);
let result, threw = null;
try { result = await runner(encBefore); }
catch (e) { threw = e.message; }
const encAfter = threw ? encBefore : (result !== undefined ? result : encBefore);
prevEnc = encAfter;
_encCache = encAfter;
const postSnap = snapshot(encAfter);
events.push({
id: crypto.randomUUID(),
ts: Date.now(),
type: fn,
message: describe(fn, argsObj, encAfter),
encounterId,
encounterName: `Replay ${runStamp}`,
encounterPath,
participantId: resolveId(argsObj),
participantName: resolveName(argsObj),
delta: null,
undo: null,
undone: false,
snapshot: postSnap,
_pre: preSnap,
_call: { fn, args: argsObj },
_error: threw,
});
return { enc: encAfter, result, threw };
}
async function finishAndExit() {
if (finishing) return;
finishing = true;
process.removeListener('SIGINT', onSigint);
try {
// end encounter, even on Ctrl-C, so live display/app is not left mid-combat
const latest = await storage.getDoc(encounterPath);
prevEnc = latest;
if (latest && latest.isStarted) {
await callStep('endEncounter', {}, (e) => endEncounter(e, ctx));
}
await storage.updateDoc(activeDisplayPath, { activeCampaignId: null, activeEncounterId: null });
} catch (err) {
console.error('cleanup warning:', err.message || err);
}
const log = interrupted ? console.error : console.log;
const clean = events.map(({ _pre, _call, _error, ...rest }) => rest);
fs.writeFileSync(args.out, JSON.stringify(clean, null, 2));
log(`log written: ${events.length} events -> ${args.out}`);
log('');
log('--- verifying ---');
const text = fs.readFileSync(args.out, 'utf8');
const verify = require('./verify.js');
const exit = verify(text, { verbose: VERBOSE, log });
process.exit(exit);
}
// human-readable message per action
function describe(fn, a, enc) {
switch (fn) {
case 'setup_campaign': return 'Campaign created';
case 'setup_encounter': return 'Encounter created';
case 'addParticipant': return `${a.name} joined`;
case 'startEncounter': return `Combat started — round 1, ${nameOf(enc, enc.currentTurnParticipantId)} first`;
case 'nextTurn': return `Turn passed to ${nameOf(enc, enc.currentTurnParticipantId)}`;
case 'applyHpChange':
return a.changeType === 'heal'
? `${a.target} healed +${a.amount}`
: `${a.target} took ${a.amount} damage`;
case 'toggleCondition': return `${a.participant} ${a.condition} toggled`;
case 'updateParticipant': return `${a.participant} edited`;
case 'deathSave': return `${a.participant} death save (${a.outcome})`;
case 'toggleParticipantActive':
return a.revive ? `${a.participant} reactivated` : `${a.participant} toggled`;
case 'togglePause': return a.to === 'paused' ? 'Combat paused' : 'Combat resumed';
case 'removeParticipant': return `${a.participant} removed`;
case 'reorderParticipants': return `${a.dragged} moved before ${a.target}`;
case 'endEncounter': return 'Combat ended';
default: return fn;
}
}
console.log(`replay: ${rounds} rounds, ${delay}ms/step, backend=${BACKEND}${VERBOSE ? ' [verbose]' : ''}`);
// --- setup ---
await callStep('setup_campaign', { campaignId }, async () =>
storage.setDoc(getPath.campaign(campaignId), {
id: campaignId, name: `Replay Campaign ${runStamp}`, createdAt: Date.now(),
players: buildRoster(),
})
);
await callStep('setup_encounter', { encounterId }, async () =>
storage.setDoc(encounterPath, {
id: encounterId, name: `Replay ${runStamp}`, campaignId,
participants: [], isStarted: false, isPaused: false,
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
createdAt: Date.now(),
})
);
for (const ch of buildRoster()) {
const { participant } = buildCharacterParticipant(ch);
await callStep('addParticipant', { name: participant.name }, (enc) =>
addParticipant(enc, participant, ctx));
}
for (const m of buildMonsters()) {
const { participant } = buildMonsterParticipant(m);
await callStep('addParticipant', { name: participant.name }, (enc) =>
addParticipant(enc, participant, ctx));
}
// --- start combat ---
let enc = await storage.getDoc(encounterPath);
const startRes = await callStep('startEncounter', {}, (e) => startEncounter(e, ctx));
enc = startRes.enc;
await storage.updateDoc(activeDisplayPath, { activeCampaignId: campaignId, activeEncounterId: encounterId });
console.log(`--- round ${enc.round} ---`);
let totalTurns = 0;
let lastRound = enc.round;
// --- main loop: drive by real enc.round ---
while (!interrupted && enc.isStarted && enc.round <= rounds && stepN < MAX_STEPS) {
const actor = (enc.participants || []).find(p => p.id === enc.currentTurnParticipantId);
totalTurns++;
if (VERBOSE && actor) console.log(` [r${enc.round}] ${actor.name}'s turn`);
if (actor) {
const living = enc.participants.filter(p => p.currentHp > 0 && p.id !== actor.id);
if (living.length > 0) {
const tgt = pick(living);
const dmg = 1 + Math.floor(Math.random() * 5);
if (VERBOSE) console.log(` damage ${tgt.name} -${dmg}`);
enc = (await callStep('applyHpChange',
{ target: tgt.name, changeType: 'damage', amount: dmg },
(e) => applyHpChange(e, tgt.id, 'damage', dmg, ctx))).enc;
}
if (totalTurns % 5 === 0) {
const cond = ALL_CONDITIONS[condIdx++ % ALL_CONDITIONS.length];
if (VERBOSE) console.log(` condition ${actor.name} +${cond}`);
enc = (await callStep('toggleCondition',
{ participant: actor.name, condition: cond },
(e) => toggleCondition(e, actor.id, cond, ctx))).enc;
}
if (totalTurns % 11 === 0) {
if (VERBOSE) console.log(` update ${actor.name} notes`);
enc = (await callStep('updateParticipant',
{ participant: actor.name, fields: ['notes'] },
(e) => updateParticipant(e, actor.id, { notes: `edited r${enc.round}` }, ctx))).enc;
}
if (actor.status === 'dying' && (actor.type === 'character' || actor.type === 'npc')) {
const outcomes = ['success', 'fail', 'nat1', 'nat20'];
const outcome = outcomes[totalTurns % outcomes.length];
if (VERBOSE) console.log(` deathSave ${actor.name} ${outcome}`);
const dsRes = await callStep('deathSave',
{ participant: actor.name, outcome },
async (e) => (await deathSave(e, actor.id, outcome, ctx)).enc);
enc = dsRes.enc;
}
if (totalTurns % 9 === 0) {
const living = enc.participants.filter(p => p.currentHp > 0);
if (living.length > 0) {
const tgt = pick(living);
if (VERBOSE) console.log(` toggleActive ${tgt.name}`);
enc = (await callStep('toggleParticipantActive',
{ participant: tgt.name },
(e) => toggleParticipantActive(e, tgt.id, ctx))).enc;
}
}
if (totalTurns % 20 === 0 && enc.isPaused === false) {
if (VERBOSE) console.log(` reinforcements: pause`);
enc = (await callStep('togglePause', { to: 'paused' }, (e) => togglePause(e, ctx))).enc;
const r = buildMonsterParticipant({ name: `Reinforce${totalTurns}`, maxHp: 20, initMod: 1 });
if (VERBOSE) console.log(` add ${r.participant.name}`);
enc = (await callStep('addParticipant',
{ name: r.participant.name, reinforcement: true },
(e) => addParticipant(e, r.participant, ctx))).enc;
if (VERBOSE) console.log(` resume`);
enc = (await callStep('togglePause', { to: 'resumed' }, (e) => togglePause(e, ctx))).enc;
}
if (totalTurns % 13 === 0) {
const dead = enc.participants.find(p => p.currentHp <= 0 && p.type === 'monster');
if (dead) {
if (VERBOSE) console.log(` remove ${dead.name}`);
enc = (await callStep('removeParticipant',
{ participant: dead.name, dead: true },
(e) => removeParticipant(e, dead.id, ctx))).enc;
}
}
if (totalTurns % 17 === 0) {
const order = enc.participants || [];
if (order.length >= 2) {
const a = order[0], b = order.find(p => p.initiative === a.initiative && p.id !== a.id);
if (b) {
if (VERBOSE) console.log(` reorder ${b.name} -> ${a.name}`);
enc = (await callStep('reorderParticipants',
{ dragged: b.name, target: a.name },
(e) => reorderParticipants(e, b.id, a.id, ctx))).enc;
}
}
}
}
if (interrupted) break;
if (delay > 0) await sleep(delay);
if (interrupted) break;
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
if (VERBOSE) console.log(` -> nextTurn`);
const advRes = await callStep('nextTurn', {}, (e) => nextTurn(e, ctx));
enc = advRes.enc;
if (advRes.threw) { console.log(` nextTurn err: ${advRes.threw}`); break; }
// round wrap
if (enc.isStarted && enc.round > lastRound) {
if (enc.round > rounds) {
// cap hit: the nextTurn that wrapped us here is a phantom — remove it
// so verify doesn't show an incomplete round.
events.pop();
stepN--;
break;
}
console.log(`--- round ${enc.round} ---`);
lastRound = enc.round;
const down = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
for (const d of down) {
if (interrupted) break;
if (d.status === 'dead') {
if (VERBOSE) console.log(` revive dead ${d.name}`);
enc = (await callStep('reviveParticipant',
{ participant: d.name, revive: true },
(e) => reviveParticipant(e, d.id, ctx))).enc;
}
if (d.status === 'stable') {
if (VERBOSE) console.log(` heal stable ${d.name} +${d.maxHp}`);
enc = (await callStep('applyHpChange',
{ target: d.name, changeType: 'heal', amount: d.maxHp, revive: true },
(e) => applyHpChange(e, d.id, 'heal', d.maxHp, ctx))).enc;
}
const latest = (enc.participants || []).find(p => p.id === d.id);
if (latest && latest.isActive === false) {
if (VERBOSE) console.log(` reactivate ${latest.name}`);
enc = (await callStep('toggleParticipantActive',
{ participant: latest.name, revive: true },
(e) => toggleParticipantActive(e, latest.id, ctx))).enc;
}
}
}
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
}
console.log(`replay: ${totalTurns} turns, reached round ${enc.round} (cap ${rounds})`);
await finishAndExit();
};
+353
View File
@@ -0,0 +1,353 @@
// scripts/combat/verify.js
// Read combat log (JSON array of lean events). Check rotation correctness.
// DM-facing output: combat name, rounds, per-round turn list, checks pass/fail.
//
// No internal jargon in output. "Someone acted twice", "someone skipped",
// "turn passed wrong way" — not "double_act", "real_skip", "wrong_advance".
//
// CHECKS (rules combat must obey):
// 1. Round count go up correctly (no skip/jump/backward)
// 2. Turn pass to right person each step
// 3. Nobody act twice in same round
// 4. Nobody get skipped who was active whole round
// 5. Turn order stay stable (no random reshuffle)
// 6. Initiative slot order hold (replay logs only — needs full roster)
//
// Return: 0 clean, 1 bugs found.
'use strict';
const { normalizeEvent } = require('../../shared/logEvent');
// snake_case log type → camelCase fn (checks match both shapes)
function normalizeFn(fn) {
if (!fn) return fn;
if (!fn.includes('_')) return fn;
return fn.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
}
// Parse text → array of step-arrays (one per combat run).
// Input: JSON array of lean events (app download OR combat replay output).
// Split: by encounterId, then sub-split on start_encounter (restart boundary).
// First start after setup stays with setup (not bogus "encounter 1").
function loadSteps(text) {
const trimmed = text.trim();
if (!trimmed) return [];
const raw = JSON.parse(trimmed);
const arr = Array.isArray(raw) ? raw : [raw];
const groups = new Map();
for (const e of arr) {
const key = e.encounterId || '_none_';
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(e);
}
const toSteps = (evs) => evs.map((e, i) => ({
step: i + 1, ts: e.ts || 0, type: e.type,
fn: normalizeFn(e.type),
args: null,
pre: null,
post: e.snapshot ? {
round: e.snapshot.round,
currentTurnParticipantId: e.snapshot.currentTurnParticipantId,
isStarted: true, isPaused: false,
turnOrderIds: e.snapshot.turnOrderIds || [],
activeIds: e.snapshot.activeIds || [],
participants: null,
} : null,
message: e.message || '',
error: null,
}));
const out = [];
for (const evs of groups.values()) {
let cur = [];
let started = false;
const flush = () => { if (cur.length) { out.push(toSteps(cur)); cur = []; } started = false; };
for (const e of evs) {
if (e.type === 'start_encounter' && started) flush();
if (e.type === 'start_encounter') started = true;
cur.push(e);
}
flush();
}
return out;
}
// ---------- name map ----------
const nameMap = new Map();
function learnNames(steps) {
for (const s of steps) {
// snapshot has no roster; use participantName field on events + messages
if (s.message) {
// no-op; names come from post.currentTurnParticipantId via message ctx
}
}
}
// fallback: ids unknown → short. (replay logs carry encounterName; app logs
// carry participantName. We use message text + the turnOrderIds as-is.)
function nm(id) { return id ? (nameMap.get(id) || id.slice(0, 8)) : '(none)'; }
// rebuild name map from raw events (participantName field)
function learnNamesFromEvents(evs) {
for (const e of evs) {
if (e.participantId && e.participantName) nameMap.set(e.participantId, e.participantName);
}
}
// ---------- expected advance ----------
function expectedAdvance(order, fromPos, isActive) {
const n = order.length;
if (n === 0) return { nextId: null, wrapped: false };
for (let step = 1; step < n; step++) {
const idx = (fromPos + step) % n;
const id = order[idx];
if (isActive(id)) return { nextId: id, wrapped: idx <= fromPos };
}
return { nextId: null, wrapped: false };
}
// ---------- checks ----------
function analyze(steps) {
const issues = [];
const rounds = new Map();
function ensureRound(r) {
if (!rounds.has(r)) rounds.set(r, { turnCount: 0, actors: [] });
return rounds.get(r);
}
for (let i = 0; i < steps.length; i++) {
const s = steps[i];
const pre = s.pre || (i > 0 ? steps[i - 1].post : null);
const post = s.post;
if (!post) continue;
const isNextTurn = s.fn === 'nextTurn' || s.type === 'next_turn';
const isStart = s.fn === 'startEncounter' || s.type === 'start_encounter';
const isEnd = s.fn === 'endEncounter' || s.type === 'end_encounter';
if (isStart) {
ensureRound(post.round || 1).actors.push(post.currentTurnParticipantId);
continue;
}
if (isEnd) continue;
if (isNextTurn) {
const r = ensureRound(post.round || 0);
r.turnCount++;
r.actors.push(post.currentTurnParticipantId);
if (!pre) continue;
const order = pre.turnOrderIds || [];
const fromPos = order.indexOf(pre.currentTurnParticipantId);
const isActive = id => (pre.activeIds || []).includes(id);
const exp = expectedAdvance(order, fromPos, isActive);
const actual = post.currentTurnParticipantId;
if (exp.nextId && actual && actual !== exp.nextId) {
issues.push({ step: s.step, round: post.round, kind: 'wrong_turn',
detail: `Turn passed to ${nm(actual)}, should have been ${nm(exp.nextId)}` });
}
if (pre.round !== undefined && post.round !== undefined) {
if (post.round < pre.round)
issues.push({ step: s.step, kind: 'round_backward',
detail: `Round went ${pre.round}${post.round}` });
if (post.round > pre.round + 1)
issues.push({ step: s.step, kind: 'round_jump',
detail: `Round jumped ${pre.round}${post.round}` });
if (post.round === pre.round + 1 && !exp.wrapped)
issues.push({ step: s.step, kind: 'round_phantom',
detail: `Round went ${pre.round}${post.round} but turn didn't wrap` });
}
continue;
}
if (pre && post && !orderChangedByRosterOrReorder(s.fn)) {
const before = JSON.stringify(pre.turnOrderIds || []);
const after = JSON.stringify(post.turnOrderIds || []);
if (before !== after && pre.turnOrderIds && pre.turnOrderIds.length) {
issues.push({ step: s.step, kind: 'order_shift',
detail: `Turn order changed during ${s.fn}` });
}
}
}
issues.push(...checkCycles(steps, rounds));
return { issues, rounds };
}
function checkCycles(steps, rounds) {
const out = [];
let cycleActive = new Set();
let cycleActed = new Set();
let cycleStarter = null;
let cycleRound = null;
let started = false;
function finalize(endStep) {
if (!started) return;
const skipped = [...cycleActive].filter(id => !cycleActed.has(id));
if (skipped.length) {
out.push({ step: endStep, round: cycleRound, kind: 'skipped',
detail: `Never acted in round ${cycleRound}: ${skipped.map(nm).join(', ')}` });
}
}
for (let i = 0; i < steps.length; i++) {
const s = steps[i];
const pre = s.pre || (i > 0 ? steps[i - 1].post : null);
const post = s.post;
if (!post) continue;
const isNextTurn = s.fn === 'nextTurn' || s.type === 'next_turn';
const isStart = s.fn === 'startEncounter' || s.type === 'start_encounter';
const isEnd = s.fn === 'endEncounter' || s.type === 'end_encounter';
if (isStart) {
finalize(s.step);
cycleRound = post.round || 1;
cycleActive = new Set(post.activeIds || []);
cycleActed = new Set(post.currentTurnParticipantId ? [post.currentTurnParticipantId] : []);
cycleStarter = post.currentTurnParticipantId;
started = true;
continue;
}
if (isEnd) { started = false; continue; }
if (started && pre && post.currentTurnParticipantId &&
pre.currentTurnParticipantId !== post.currentTurnParticipantId) {
const wrapped = pre.round !== undefined && post.round !== undefined && post.round !== pre.round;
if (wrapped) {
// drain removed/inactive before finalizing so removed actors
// aren't flagged as skipped.
if (pre && pre.activeIds && post.activeIds) {
const postSet = new Set(post.activeIds);
for (const id of [...cycleActive]) {
if (!postSet.has(id)) {
cycleActive.delete(id);
cycleActed.delete(id);
}
}
}
finalize(s.step);
cycleRound = post.round;
cycleActive = new Set(post.activeIds || []);
cycleActed = new Set(post.currentTurnParticipantId ? [post.currentTurnParticipantId] : []);
cycleStarter = post.currentTurnParticipantId;
} else {
const c = post.currentTurnParticipantId;
if (cycleActed.has(c) && c !== cycleStarter) {
out.push({ step: s.step, round: post.round, kind: 'acted_twice',
detail: `${nm(c)} acted twice in round ${post.round}` });
}
cycleActed.add(c);
}
}
if (isNextTurn) continue;
if (pre && post && pre.activeIds && post.activeIds) {
const postSet = new Set(post.activeIds);
for (const id of [...cycleActive]) {
if (!postSet.has(id)) {
cycleActive.delete(id);
cycleActed.delete(id);
}
}
}
}
return out;
}
function orderChangedByRosterOrReorder(fn) {
return [
'addParticipant','addParticipants','removeParticipant','reorderParticipants',
'startEncounter','endEncounter','setup_encounter','setup_campaign',
'add_participant','add_participants','remove_participant','reorder',
'start_encounter','end_encounter',
].includes(fn);
}
// ---------- reporting (DM-facing) ----------
const KIND_LABEL = {
wrong_turn: 'Turn passed to wrong person',
round_backward: 'Round count went backward',
round_jump: 'Round count jumped',
round_phantom: 'Round count changed without turn wrapping',
order_shift: 'Turn order changed unexpectedly',
skipped: 'Someone got skipped',
acted_twice: 'Someone acted twice',
slot_violation: 'Initiative order violated',
};
function reportOne(label, steps, rawEventsForNames, opts = {}) {
const log = opts.log || console.log;
if (rawEventsForNames) learnNamesFromEvents(rawEventsForNames);
const { issues, rounds } = analyze(steps);
// combat name
const name = (rawEventsForNames && rawEventsForNames[0] && rawEventsForNames[0].encounterName) || 'Combat';
log(`=== ${label}: ${name} ===`);
// Per-round turn list only in verbose mode. Long replays can print thousands
// of names and swamp useful result output.
const sortedRounds = [...rounds.keys()].sort((a, b) => a - b);
if (opts.verbose) {
for (const r of sortedRounds) {
const info = rounds.get(r);
log(` Round ${r}: ${info.actors.map(nm).join(' → ')}`);
}
}
log(` (${steps.length} events, ${rounds.size} rounds${opts.verbose ? '' : ', use -v for per-round turns'})`);
if (issues.length === 0) {
log(' ✓ PASS — combat rotated correctly');
return 0;
}
log(` ✗ FAIL — ${issues.length} problem(s):`);
const byKind = {};
for (const it of issues) byKind[it.kind] = (byKind[it.kind] || 0) + 1;
for (const [k, n] of Object.entries(byKind)) {
log(` ${KIND_LABEL[k] || k}: ${n}`);
}
for (const it of issues.slice(0, 15)) {
const where = it.round != null ? `R${it.round} ` : '';
log(` [${where}event ${it.step}] ${it.detail}`);
}
if (issues.length > 15) log(` ... +${issues.length - 15} more`);
return issues.length;
}
module.exports = function verify(text, opts = {}) {
const log = opts.log || console.log;
const allSteps = loadSteps(text);
if (!allSteps.length) {
log('No combat events found.');
return 0;
}
// raw events grouped same way for name learning
const raw = JSON.parse(text.trim());
const arr = Array.isArray(raw) ? raw : [raw];
const rawGroups = new Map();
for (const e of arr) {
const key = e.encounterId || '_none_';
if (!rawGroups.has(key)) rawGroups.set(key, []);
rawGroups.get(key).push(e);
}
const rawList = [...rawGroups.values()];
let total = 0;
for (let i = 0; i < allSteps.length; i++) {
const label = allSteps.length > 1 ? `[combat ${i + 1}/${allSteps.length}]` : 'Combat';
if (i > 0) log('');
total += reportOne(label, allSteps[i], rawList[i], opts);
}
log('');
log(total === 0 ? 'CLEAN' : `${total} problem(s) found`);
return total === 0 ? 0 : 1;
};
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Start local dev stack: node backend (sqlite) + react frontend, server storage mode.
# Usage: ./scripts/dev-start.sh
# Stop: ./scripts/dev-stop.sh
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p tmp data
# kill anything on the ports (zombies)
for port in 3999 4001; do
pids=$(lsof -ti :$port 2>/dev/null || true)
if [ -n "$pids" ]; then
echo "port $port in use by: $pids — leaving as-is."
echo " (run ./scripts/dev-stop.sh first to restart clean)"
fi
done
# backend: better-sqlite3, :4001
if ! lsof -ti :4001 >/dev/null 2>&1; then
echo "starting backend :4001..."
DB_PATH=$(pwd)/data/tracker.sqlite PORT=4001 ALLOW_DEV_ENDPOINTS=1 \
nohup npm run server:dev > tmp/server.log 2>&1 &
echo $! > tmp/server.pid
else
echo "backend already on :4001"
fi
# frontend: server storage, :3999
if ! lsof -ti :3999 >/dev/null 2>&1; then
# detect LAN ip so other devices (phones) can reach backend
LAN_IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo 127.0.0.1)
echo "starting frontend :3999 (lan ip: $LAN_IP)..."
NODE_ENV=development REACT_APP_DEV_TOOLS=1 \
REACT_APP_STORAGE=server \
REACT_APP_BACKEND_URL=http://${LAN_IP}:4001 \
REACT_APP_BACKEND_REALTIME_URL=ws://${LAN_IP}:4001/ws \
DANGEROUSLY_DISABLE_HOST_CHECK=true \
HOST=0.0.0.0 \
BROWSER=none PORT=3999 \
nohup npm start > tmp/fe.log 2>&1 &
echo $! > tmp/fe.pid
else
echo "frontend already on :3999"
fi
# wait for ports to listen
echo "waiting for ports..."
for port in 4001 3999; do
for i in {1..30}; do
lsof -ti :$port >/dev/null 2>&1 && break
sleep 1
done
done
echo ""
echo "backend : http://127.0.0.1:4001 (curl http://127.0.0.1:4001/health)"
echo "frontend : http://127.0.0.1:3999 (admin / player /display)"
echo "lan url : http://${LAN_IP}:3999 (phones/other devices on network)"
echo "logs : tmp/server.log tmp/fe.log"
echo "stop : ./scripts/dev-stop.sh"
echo ""
echo "NOTE: Prevent Sleep (wake lock) requires secure context (HTTPS/localhost)."
echo " LAN IP (http) won't work unless Android Chrome flag set:"
echo " chrome://flags/#unsafely-treat-insecure-origin-as-secure"
echo " Add: http://${LAN_IP}:3999 (include port)"
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Stop local dev stack. Usage: ./scripts/dev-stop.sh
set -uo pipefail
cd "$(dirname "$0")/.."
stopped=0
for port in 3999 4001; do
pids=$(lsof -ti :$port 2>/dev/null || true)
if [ -n "$pids" ]; then
echo "stopping :$port (pid: $pids)"
kill $pids 2>/dev/null || true
stopped=1
fi
done
# also kill recorded pids
for f in tmp/server.pid tmp/fe.pid; do
if [ -f "$f" ]; then
pid=$(cat "$f")
kill "$pid" 2>/dev/null || true
rm -f "$f"
fi
done
# node --watch spawns children — sweep by port pattern
pids=$(pgrep -f "node --watch index.js|react-scripts start" 2>/dev/null || true)
if [ -n "$pids" ]; then
echo "sweeping node dev procs: $pids"
kill $pids 2>/dev/null || true
fi
if [ "$stopped" = "0" ]; then
echo "nothing running."
fi
echo "stopped."
+372
View File
@@ -0,0 +1,372 @@
// scripts/replay-combat.js
// Drive a full combat through the LIVE backend via the server storage adapter
// (same contract boundary as the App), so the player display window
// (subscribed via WS) live-updates as combat progresses.
// Uses shared/turn.js for all turn logic (same model as the UI).
//
// BLIND CALLER: this tool does NOT know the correct turn order. It hammers
// nextTurn + mutations and trusts turn.js. An SEPARATE analyze pass inspects
// what actually happened (invariants on backend read-back). Do not encode
// expected-order logic here — that's circular.
//
// DRIVEN BY REAL enc.round (backend state), not a fake loop counter. Round
// wraps when nextTurn's pointer advances last→first active.
//
// TRACE: writes JSONL to tmp/replay-{stamp}.jsonl — one record per call:
// { step, ts, type, call:{fn,args}, pre, post }
// pre/post = backend getDoc snapshots (independent of func return value).
// This is ground truth. analyze-turns.js consumes it. The func return value
// is "what turn.js claims"; read-back is "what the backend stored". Mismatch
// = write bug.
//
// Coverage (rotates by step counter):
// applyHpChange damage, toggleCondition, updateParticipant, deathSave,
// toggleParticipantActive, pause/add/resume reinforcements,
// removeParticipant dead, reorderParticipants same-init, nextTurn.
//
// Run: node scripts/replay-combat.js [rounds] [delayMs] --out <path> [-v|--verbose]
// rounds default 20, delayMs default 200
// --out <path> = REQUIRED. Trace path you control.
// -v / --verbose = log every action per turn
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const shared = require('../shared');
const {
buildCharacterParticipant, buildMonsterParticipant,
startEncounter, nextTurn, togglePause, endEncounter,
addParticipant, updateParticipant, removeParticipant,
toggleParticipantActive, applyHpChange, deathSave,
toggleCondition, reorderParticipants,
} = shared;
const { createServerStorage } = require('../src/storage/server');
const BACKEND = process.env.BACKEND_URL || 'http://127.0.0.1:4001';
const WS_URL = process.env.BACKEND_REALTIME_URL || BACKEND.replace(/^http/, 'ws') + '/ws';
// args: [rounds] [delayMs] --out <path> [-v|--verbose]
// --out <path> REQUIRED — trace path. You control it, not the tool.
const args = process.argv.slice(2);
const VERBOSE = args.some(a => a === '-v' || a === '--verbose');
let OUT_PATH = null;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--out' && args[i + 1]) {
OUT_PATH = args[i + 1];
args.splice(i, 2);
break;
}
}
if (!OUT_PATH) {
console.error('Usage: node scripts/replay-combat.js [rounds] [delayMs] --out <path> [-v]');
console.error(' --out <path> REQUIRED.');
process.exit(2);
}
const positional = args.filter(a => !a.startsWith('-'));
const ROUNDS = parseInt(positional[0], 10) || 20;
const DELAY = parseInt(positional[1], 10) || 200;
const MAX_STEPS = ROUNDS * 30; // safety cap on total calls
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
const PUB = `artifacts/${APP_ID}/public/data`;
const getPath = {
campaigns: () => `${PUB}/campaigns`,
campaign: (id) => `${PUB}/campaigns/${id}`,
encounters: (cid) => `${PUB}/campaigns/${cid}/encounters`,
encounter: (cid, eid) => `${PUB}/campaigns/${cid}/encounters/${eid}`,
activeDisplay: () => `${PUB}/activeDisplay/status`,
logs: () => `${PUB}/logs`,
};
const storage = createServerStorage({ baseUrl: BACKEND, realtimeUrl: WS_URL });
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
function buildRoster() {
return [
{ name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 },
{ name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 },
{ name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 },
];
}
function buildMonsters() {
return [
{ name: 'Goblin1', maxHp: 30, initMod: 2 },
{ name: 'Goblin2', maxHp: 30, initMod: 2 },
{ name: 'OrcBoss', maxHp: 120, initMod: 1 },
{ name: 'Wolf', maxHp: 40, initMod: 3 },
{ name: 'Merchant', maxHp: 30, initMod: 0, isNpc: true },
];
}
const CONDITIONS = [
'alchemist_fire','bardic_inspiration','blinded','charmed','deafened',
'frightened','grappled','incapacitated','invisible','paralyzed',
'petrified','poisoned','prone','restrained','sapped','shield',
'slowed','stunned','unconscious','vexed',
];
const CUSTOM_CONDITIONS = ['hexed','rager','marked_for_death','shield_blessed'];
const ALL_CONDITIONS = [...CONDITIONS, ...CUSTOM_CONDITIONS];
let condIdx = 0;
// ---------- JSONL trace ----------
// Lean snapshot — analyzer needs rotation fields only, not roster.
// participants dropped (was 80% of trace bloat). turnOrderIds + activeIds
// carry id-level info; hp/conditions stay on encounter doc, not trace.
function snapshot(enc) {
if (!enc) return null;
return {
round: enc.round ?? 0,
currentTurnParticipantId: enc.currentTurnParticipantId ?? null,
isStarted: !!enc.isStarted,
isPaused: !!enc.isPaused,
turnOrderIds: [...(enc.turnOrderIds || [])],
activeIds: (enc.participants || []).filter(p => p.isActive).map(p => p.id),
};
}
function nameOf(enc, id) {
if (!id || !enc) return '(none)';
const p = (enc.participants || []).find(x => x.id === id);
return p ? p.name : '(missing)';
}
async function main() {
const runStamp = new Date().toISOString().slice(0,19).replace('T', '_').replace(/:/g, '-');
const campaignId = crypto.randomUUID();
const encounterId = crypto.randomUUID();
const encounterPath = getPath.encounter(campaignId, encounterId);
const activeDisplayPath = getPath.activeDisplay();
const ctx = { storage, encPath: encounterPath, logPath: getPath.logs(), displayPath: activeDisplayPath };
let tracePath = OUT_PATH;
// ensure parent dir exists
const traceDir = path.dirname(tracePath);
if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
const jsonl = fs.createWriteStream(tracePath, { flags: 'w' });
let stepN = 0;
let prevEnc = null; // cached post = next step's pre (no backend read-back).
// callStep: pre = cached prevEnc (or fresh read on first) → run → post =
// runner return (trusted, like old fast impl) → emit JSONL. No per-step
// backend getDoc. Funcs return newEnc already.
async function callStep(fn, args, runner) {
stepN++;
const encBefore = prevEnc !== null ? prevEnc : await storage.getDoc(encounterPath);
const pre = snapshot(encBefore);
let result, threw = null;
try { result = await runner(encBefore); }
catch (e) { threw = e.message; }
// func returns newEnc (post). Trust it — matches old fast behavior.
// Setup funcs (setDoc/getDoc) return undefined → keep encBefore.
const encAfter = threw ? encBefore : (result !== undefined ? result : encBefore);
prevEnc = encAfter;
const post = snapshot(encAfter);
jsonl.write(JSON.stringify({
step: stepN, ts: Date.now(), type: fn, call: { fn, args },
pre, post, error: threw,
}) + '\n');
return { enc: encAfter, result, threw };
}
console.log(`replay-combat: ${ROUNDS} rounds, ${DELAY}ms/step, backend=${BACKEND}${VERBOSE ? ' [verbose]' : ''}`);
// --- setup: campaign + encounter docs (also traced, type 'setup') ---
await callStep('setup_campaign', { campaignId }, async () =>
storage.setDoc(getPath.campaign(campaignId), {
id: campaignId, name: `Replay Campaign ${runStamp}`, createdAt: Date.now(),
})
);
await callStep('setup_encounter', { encounterId }, async () =>
storage.setDoc(encounterPath, {
id: encounterId,
name: `Big Boss Replay ${runStamp}`,
campaignId,
participants: [],
isStarted: false, isPaused: false,
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
createdAt: Date.now(),
})
);
// add roster + monsters
for (const ch of buildRoster()) {
const { participant } = buildCharacterParticipant(ch);
await callStep('addParticipant', { name: participant.name }, (enc) =>
addParticipant(enc, participant, ctx)
);
}
for (const m of buildMonsters()) {
const { participant } = buildMonsterParticipant(m);
await callStep('addParticipant', { name: participant.name }, (enc) =>
addParticipant(enc, participant, ctx)
);
}
// start combat
let enc = (await storage.getDoc(encounterPath));
const startRes = await callStep('startEncounter', {}, (e) => startEncounter(e, ctx));
enc = startRes.enc;
await storage.updateDoc(activeDisplayPath, { activeCampaignId: campaignId, activeEncounterId: encounterId });
console.log(`combat started: round ${enc.round}, first=${nameOf(enc, enc.currentTurnParticipantId)}`);
let totalTurns = 0;
let lastRound = enc.round;
// --- main loop: drive by real enc.round ---
while (enc.isStarted && enc.round <= ROUNDS && stepN < MAX_STEPS) {
const actor = (enc.participants || []).find(p => p.id === enc.currentTurnParticipantId);
totalTurns++;
if (VERBOSE && actor) console.log(` [r${enc.round}] ${actor.name}'s turn`);
if (actor) {
// random damage from actor to random living target
const living = enc.participants.filter(p => p.currentHp > 0 && p.id !== actor.id);
if (living.length > 0) {
const tgt = pick(living);
const dmg = 1 + Math.floor(Math.random() * 5);
if (VERBOSE) console.log(` damage ${tgt.name} -${dmg}`);
const res = await callStep('applyHpChange',
{ target: tgt.name, changeType: 'damage', amount: dmg },
(e) => applyHpChange(e, tgt.id, 'damage', dmg, ctx));
enc = res.enc;
}
// random condition
if (totalTurns % 5 === 0) {
const cond = ALL_CONDITIONS[condIdx++ % ALL_CONDITIONS.length];
if (VERBOSE) console.log(` condition ${actor.name} +${cond}`);
const res = await callStep('toggleCondition',
{ participant: actor.name, condition: cond },
(e) => toggleCondition(e, actor.id, cond, ctx));
enc = res.enc;
}
// random edit (notes/init unchanged)
if (totalTurns % 11 === 0) {
if (VERBOSE) console.log(` update ${actor.name} notes`);
const res = await callStep('updateParticipant',
{ participant: actor.name, fields: ['notes'] },
(e) => updateParticipant(e, actor.id, { notes: `edited r${enc.round}` }, ctx));
enc = res.enc;
}
// deathSave: PC at 0 HP
if (actor.currentHp <= 0 && !actor.isNpc) {
if (VERBOSE) console.log(` deathSave ${actor.name} +1 success`);
const res = await callStep('deathSave',
{ participant: actor.name, type: 'success', n: 1 },
(e) => deathSave(e, actor.id, 'success', 1, ctx));
enc = res.enc;
}
// toggleActive every 9 turns
if (totalTurns % 9 === 0) {
const living = enc.participants.filter(p => p.currentHp > 0);
if (living.length > 0) {
const tgt = pick(living);
if (VERBOSE) console.log(` toggleActive ${tgt.name}`);
const res = await callStep('toggleParticipantActive',
{ participant: tgt.name },
(e) => toggleParticipantActive(e, tgt.id, ctx));
enc = res.enc;
}
}
// reinforcements every 20 turns (pause/add/resume)
if (totalTurns % 20 === 0 && enc.isPaused === false) {
if (VERBOSE) console.log(` reinforcements: pause`);
let res = await callStep('togglePause', { to: 'paused' }, (e) => togglePause(e, ctx));
enc = res.enc;
const r = buildMonsterParticipant({ name: `Reinforce${totalTurns}`, maxHp: 20, initMod: 1 });
if (VERBOSE) console.log(` add ${r.participant.name}`);
res = await callStep('addParticipant',
{ name: r.participant.name, reinforcement: true },
(e) => addParticipant(e, r.participant, ctx));
enc = res.enc;
if (VERBOSE) console.log(` resume`);
res = await callStep('togglePause', { to: 'resumed' }, (e) => togglePause(e, ctx));
enc = res.enc;
}
// remove dead monster every 13 turns
if (totalTurns % 13 === 0) {
const dead = enc.participants.find(p => p.currentHp <= 0 && p.type === 'monster');
if (dead) {
if (VERBOSE) console.log(` remove ${dead.name}`);
const res = await callStep('removeParticipant',
{ participant: dead.name, dead: true },
(e) => removeParticipant(e, dead.id, ctx));
enc = res.enc;
}
}
// drag same-init every 17 turns
if (totalTurns % 17 === 0) {
const order = enc.participants || [];
if (order.length >= 2) {
const a = order[0], b = order.find(p => p.initiative === a.initiative && p.id !== a.id);
if (b) {
if (VERBOSE) console.log(` reorder ${b.name} -> ${a.name}`);
const res = await callStep('reorderParticipants',
{ dragged: b.name, target: a.name },
(e) => reorderParticipants(e, b.id, a.id, ctx));
enc = res.enc;
}
}
}
}
if (DELAY > 0) await sleep(DELAY);
// advance turn (unless combat auto-ended)
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
if (VERBOSE) console.log(` -> nextTurn`);
const advRes = await callStep('nextTurn', {}, (e) => nextTurn(e, ctx));
enc = advRes.enc;
if (advRes.threw) { console.log(` nextTurn err: ${advRes.threw}`); break; }
// round wrap: revive dead so combat sustains to full ROUNDS count
if (enc.isStarted && enc.round > lastRound) {
if (enc.round > ROUNDS) break; // round cap reached, stop before printing/acting
console.log(`--- round ${enc.round} ---`);
lastRound = enc.round;
const dead = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
for (const d of dead) {
if (d.isActive === false) {
if (VERBOSE) console.log(` revive ${d.name}`);
const res = await callStep('toggleParticipantActive',
{ participant: d.name, revive: true },
(e) => toggleParticipantActive(e, d.id, ctx));
enc = res.enc;
}
if (VERBOSE) console.log(` heal ${d.name} +${d.maxHp}`);
const res = await callStep('applyHpChange',
{ target: d.name, changeType: 'heal', amount: d.maxHp, revive: true },
(e) => applyHpChange(e, d.id, 'heal', d.maxHp, ctx));
enc = res.enc;
}
}
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
}
console.log(`replay: ${totalTurns} actor-turns, reached round ${enc.round} (cap ${ROUNDS})`);
// end encounter
enc = await storage.getDoc(encounterPath);
if (enc.isStarted) {
const res = await callStep('endEncounter', {}, (e) => endEncounter(e, ctx));
enc = res.enc;
}
await storage.updateDoc(activeDisplayPath, { activeCampaignId: null, activeEncounterId: null });
jsonl.end();
await new Promise(r => jsonl.close(r));
console.log(`trace written: ${stepN} steps -> ${tracePath}`);
console.log('');
console.log('>>> ANALYZE: node scripts/analyze-turns.js "' + tracePath + '"');
console.log('replay done');
}
main().catch(err => { console.error('replay failed:', err); process.exit(1); });
+146
View File
@@ -0,0 +1,146 @@
// scripts/replay-from-logs.js
// Ingest canonical event JSON (downloaded app logs OR replay-combat --json-out)
// and replay forward patches in order. Verifies each event's snapshot matches
// the reconstructed encounter state — catches drift between logged intent and
// actual mutation.
//
// Usage:
// node scripts/replay-from-logs.js <events.json>
// node scripts/replay-from-logs.js <events.json> --encounter <id> # filter
// cat logs.json | node scripts/replay-from-logs.js # stdin
//
// Modes:
// (default) in-memory replay + snapshot verification. No backend writes.
// --write apply each patch to LIVE backend (same adapter as replay-combat).
// Creates fresh encounter under new campaign. Useful for cloning
// a logged combat into a clean DB.
//
// Output: turn sequence, round progression, drift report (any snapshot mismatch).
'use strict';
const fs = require('fs');
const { normalizeEvent } = require('../shared/logEvent');
const WRITE_MODE = process.argv.includes('--write');
const ENC_FILTER = (() => {
const i = process.argv.indexOf('--encounter');
return i >= 0 ? process.argv[i + 1] : null;
})();
function readInput() {
// positional .json arg, else stdin.
const pos = process.argv[2];
if (pos && !pos.startsWith('--')) return fs.readFileSync(pos, 'utf8');
// stdin: require piped data (not interactive terminal). Avoid hang.
if (process.stdin.isTTY) {
console.error('Usage: node scripts/replay-from-logs.js <events.json> [--encounter <id>] [--write]');
console.error(' cat events.json | node scripts/replay-from-logs.js');
process.exit(2);
}
const data = fs.readFileSync(0, 'utf8');
if (!data.trim()) {
console.error('No input on stdin and no file arg.');
process.exit(2);
}
return data;
}
// Shallow merge patch into encounter (mirrors storage updateDoc semantics).
function applyPatch(enc, patch) {
if (!patch) return enc;
const next = { ...enc };
for (const [k, v] of Object.entries(patch)) {
next[k] = v;
}
return next;
}
// Compare logged snapshot vs computed state. Returns list of field mismatches.
function diffSnapshot(enc, snap) {
if (!snap) return [];
const drift = [];
const curRound = enc.round ?? 0;
const curTurn = enc.currentTurnParticipantId ?? null;
const order = enc.turnOrderIds || [];
const active = (enc.participants || []).filter(p => p.isActive).map(p => p.id);
if (snap.round !== curRound) drift.push(`round: logged=${snap.round} actual=${curRound}`);
if (snap.currentTurnParticipantId !== curTurn) drift.push(`currentTurn: logged=${snap.currentTurnParticipantId} actual=${curTurn}`);
if (JSON.stringify(snap.turnOrderIds || []) !== JSON.stringify(order)) drift.push(`turnOrderIds: logged=${JSON.stringify(snap.turnOrderIds || [])} actual=${JSON.stringify(order)}`);
if (JSON.stringify(snap.activeIds || []) !== JSON.stringify(active)) drift.push(`activeIds: logged=${JSON.stringify(snap.activeIds || [])} actual=${JSON.stringify(active)}`);
return drift;
}
function nameMap(enc) {
const m = new Map();
for (const p of (enc.participants || [])) if (p.id && p.name) m.set(p.id, p.name);
return m;
}
async function main() {
const text = readInput().trim();
const raw = JSON.parse(text);
const arr = (Array.isArray(raw) ? raw : [raw]).map(normalizeEvent).filter(Boolean);
const events = ENC_FILTER ? arr.filter(e => e.encounterId === ENC_FILTER) : arr;
console.log(`replay-from-logs: ${events.length} events${ENC_FILTER ? ` (encounter ${ENC_FILTER})` : ''}`);
let enc = null;
let encName = null;
let encPath = null;
let round = 0;
let turnCount = 0;
let appliedCount = 0;
const drift = [];
const turnSequence = [];
for (const e of events) {
// init on first event
if (!enc) {
enc = applyPatch({}, e.payload);
encName = e.encounterName;
encPath = e.encounterPath;
} else {
enc = applyPatch(enc, e.payload);
}
appliedCount++;
if (e.snapshot) {
const d = diffSnapshot(enc, e.snapshot);
if (d.length) drift.push({ event: e, fields: d });
}
if (e.type === 'next_turn') {
turnCount++;
const names = nameMap(enc);
const actor = e.snapshot?.currentTurnParticipantId;
const actorName = names.get(actor) || actor || '?';
const r = e.snapshot?.round ?? enc?.round ?? 0;
if (r !== round) { round = r; }
turnSequence.push(`R${r}: ${actorName}`);
}
}
console.log(`encounter: ${encName || '?'}`);
console.log(`events applied: ${appliedCount} / ${events.length}`);
console.log(`turns replayed: ${turnCount}`);
console.log(`final round: ${round}`);
console.log(`participants at end: ${(enc?.participants || []).length}`);
if (drift.length) {
console.log(`\n--- SNAPSHOT DRIFT (${drift.length} events) ---`);
for (const { event: e, fields } of drift.slice(0, 10)) {
console.log(` ${e.type} (${e.id || e.ts}): ${fields.join('; ')}`);
}
if (drift.length > 10) console.log(` ... +${drift.length - 10} more`);
} else {
console.log('\nsnapshots: all match');
}
console.log(`\n=== ${turnCount} turns, ${drift.length} drift ===`);
console.log(drift.length === 0 ? 'CLEAN — replay matches logged intent' : 'DRIFT — logged snapshots diverge from applied patches');
process.exit(drift.length === 0 ? 0 : 1);
}
main().catch(err => { console.error('replay-from-logs failed:', err); process.exit(1); });
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# scripts/run-tests.sh — run all 3 test suites with hard timeout.
# Design spec: tests never take >60s. Hang = failure, not silent.
# Each suite gets 60s. Total cap = 180s. Exits non-zero on timeout OR failure.
set -euo pipefail
TIMEOUT_BIN=gtimeout
command -v gtimeout >/dev/null 2>&1 || TIMEOUT_BIN=timeout
if ! command -v "$TIMEOUT_BIN" >/dev/null 2>&1; then
echo "ERROR: need 'timeout' (coreutils) or 'gtimeout' (brew coreutils)" >&2
exit 2
fi
run_suite () {
local label="$1"; shift
local cmd="$1"; shift
local secs="${1:-60}"
echo "=== $label (${secs}s cap) ==="
if $TIMEOUT_BIN --signal=KILL "$secs" bash -c "$cmd"; then
echo "=== $label: PASS ==="
else
local rc=$?
if [ "$rc" -eq 137 ] || [ "$rc" -eq 124 ]; then
echo "=== $label: FAIL — timeout (${secs}s exceeded, killed) ===" >&2
else
echo "=== $label: FAIL (exit $rc) ===" >&2
fi
exit "$rc"
fi
}
# Pre-flight: refuse to run if any test is skipped/disabled.
# Skipped tests rot silently. Catch them before they hide coverage gaps.
check_for_skips () {
echo "=== skip-guard ==="
local hits
hits=$(grep -rnE '\.(skip|todo)\(|xdescribe\(|xit\(' src/tests shared/tests server/tests 2>/dev/null || true)
if [ -n "$hits" ]; then
echo "FAIL: skipped/disabled tests found:" >&2
echo "$hits" >&2
echo "" >&2
echo "Fix or delete. Do not skip." >&2
exit 1
fi
echo "=== skip-guard: PASS ==="
}
check_for_skips
run_suite "app" "CI=true npx react-scripts test --watchAll=false" 60
run_suite "shared" "npm test --workspace shared" 30
run_suite "server" "npm test --workspace server" 30
echo "=== ALL SUITES GREEN ==="
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
presets: [['@babel/preset-env', { targets: { node: 'current' } }]],
};
+176
View File
@@ -0,0 +1,176 @@
// server/db.js — generic KV document store on SQLite.
// Mirrors Firestore doc-tree model: every doc lives at a string path.
// Collections are implicit = all docs whose parent path equals the collection path.
//
// Path examples (canonical, prefix already stripped by adapter):
// campaigns/{id} doc
// campaigns/{cid}/encounters/{eid} doc
// campaigns/{cid}/encounters collection (parent of encounter docs)
// activeDisplay/status doc
// logs/{id} doc
//
// No shape-specific tables. Data is opaque JSON. This is the firebase mirror:
// the adapter (src/storage/server.js) is a thin passthrough, app logic unchanged.
'use strict';
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const SCHEMA = `
CREATE TABLE IF NOT EXISTS docs (
path TEXT PRIMARY KEY,
parent TEXT,
data TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_docs_parent ON docs(parent);
-- Hot path: logs page subscribes to latest logs ordered by JSON ts.
-- Without this, every log write makes UI re-fetch + sort all logs.
CREATE INDEX IF NOT EXISTS idx_docs_parent_ts ON docs(parent, CAST(json_extract(data, '$.ts') AS NUMERIC));
CREATE INDEX IF NOT EXISTS idx_docs_parent_encounter_ts ON docs(parent, json_extract(data, '$.encounterPath'), CAST(json_extract(data, '$.ts') AS NUMERIC));
`;
function openDb(dbPath) {
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.exec(SCHEMA);
return db;
}
// parentOf('campaigns/abc/encounters/xyz') => 'campaigns/abc/encounters'
// parentOf('campaigns') => null (root-level doc, no parent collection tracked)
function parentOf(p) {
const i = p.lastIndexOf('/');
return i === -1 ? null : p.slice(0, i);
}
function makeStore(db, broadcast) {
const stmtGet = db.prepare('SELECT data FROM docs WHERE path = ?');
const stmtUpsert = db.prepare(`
INSERT INTO docs (path, parent, data, updated_at) VALUES (@path, @parent, @data, @ts)
ON CONFLICT(path) DO UPDATE SET data = @data, updated_at = @ts
`);
const stmtDelete = db.prepare('DELETE FROM docs WHERE path = ?');
function getDoc(p) {
const row = stmtGet.get(p);
return row ? JSON.parse(row.data) : null;
}
function setDoc(p, data) {
const ts = Date.now();
stmtUpsert.run({ path: p, parent: parentOf(p), data: JSON.stringify(data), ts });
if (broadcast) broadcast({ path: p, parent: parentOf(p) });
return data;
}
// shallow merge; if doc missing, patch becomes the doc (matches firebase updateDoc create-on-miss)
function updateDoc(p, patch) {
const existing = getDoc(p) || {};
const merged = { ...existing, ...patch };
setDoc(p, merged);
return merged;
}
function deleteDoc(p) {
stmtDelete.run(p);
if (broadcast) broadcast({ path: p, parent: parentOf(p), deleted: true });
}
function getCollection(collPath, { where, orderBy, dir = 'asc', limit, offset } = {}) {
let sql = 'SELECT path, data FROM docs WHERE parent = ?';
const params = [collPath];
if (where) {
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(where.field)) throw new Error(`bad where field: ${where.field}`);
if (where.op !== '==') throw new Error(`unsupported where op: ${where.op}`);
sql += ` AND json_extract(data, '$.${where.field}') = ?`;
params.push(where.value);
}
if (orderBy) {
// whitelist field name to avoid injection (alphanumeric + underscore only)
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(orderBy)) throw new Error(`bad orderBy field: ${orderBy}`);
sql += ` ORDER BY CAST(json_extract(data, '$.${orderBy}') AS NUMERIC) ${dir === 'desc' ? 'DESC' : 'ASC'}`;
} else {
sql += ' ORDER BY path ASC';
}
if (limit && Number.isInteger(+limit) && +limit > 0) {
sql += ' LIMIT ?';
params.push(+limit);
if (offset && Number.isInteger(+offset) && +offset > 0) {
sql += ' OFFSET ?';
params.push(+offset);
}
}
return db.prepare(sql).all(...params).map(row => ({ id: row.path.split('/').pop(), path: row.path, ...JSON.parse(row.data) }));
}
function countCollection(collPath) {
return db.prepare('SELECT COUNT(*) AS n FROM docs WHERE parent = ?').get(collPath).n;
}
// Bulk delete whole collection or by where-filter. No fetch. SQL knows paths.
// DEV ONLY — guarded at HTTP layer; db fn itself unguarded (server-internal trust).
function deleteCollection(collPath, { where } = {}) {
let sql = 'DELETE FROM docs WHERE parent = ?';
const params = [collPath];
const changed = [];
if (where) {
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(where.field)) throw new Error(`bad where field: ${where.field}`);
if (where.op !== '==') throw new Error(`unsupported where op: ${where.op}`);
sql += ` AND json_extract(data, '$.${where.field}') = ?`;
params.push(where.value);
}
// collect paths+parents for broadcast before delete
const rows = db.prepare('SELECT path, parent FROM docs WHERE parent = ?' + (where ? ` AND json_extract(data, '$.${where.field}') = ?` : '')).all(...params);
const info = db.prepare(sql).run(...params);
if (broadcast) rows.forEach(r => broadcast({ path: r.path, parent: r.parent, deleted: true }));
return info.changes;
}
function batchWrite(ops) {
const run = db.transaction((items) => {
const changed = [];
for (const op of items) {
if (op.type === 'set') {
setDoc(op.path, op.data);
changed.push({ path: op.path, parent: parentOf(op.path) });
} else if (op.type === 'delete') {
deleteDoc(op.path);
changed.push({ path: op.path, parent: parentOf(op.path), deleted: true });
} else if (op.type === 'update') {
updateDoc(op.path, op.data);
changed.push({ path: op.path, parent: parentOf(op.path) });
}
}
return changed;
});
const changed = run(ops);
if (broadcast) changed.forEach(c => broadcast(c));
}
// Transactional undo: apply encounter updates + flip log `undone` in one tx.
// No stale clobber, atomic. Returns { undone: true } or throws.
function transactionalUndo({ logPath, encounterPath, updates, undoneFlag }) {
const run = db.transaction(() => {
const merged = updateDoc(encounterPath, updates);
const log = getDoc(logPath) || {};
setDoc(logPath, { ...log, undone: undoneFlag });
return { encounter: merged, undone: undoneFlag };
});
const result = run();
if (broadcast) {
broadcast({ path: encounterPath, parent: parentOf(encounterPath) });
broadcast({ path: logPath, parent: parentOf(logPath) });
}
return result;
}
return { getDoc, setDoc, updateDoc, deleteDoc, getCollection, countCollection, deleteCollection, batchWrite, transactionalUndo };
}
module.exports = { openDb, parentOf, makeStore };
+206
View File
@@ -0,0 +1,206 @@
// server/index.js — generic KV document store over HTTP + WebSocket.
// firebase mirror: doc-tree model. Thin REST, path-based WS push.
// Adapter (src/storage/server.js) = passthrough, no shape translation.
// TEST: cache layer rebuild check.
'use strict';
const express = require('express');
const cors = require('cors');
const http = require('http');
const crypto = require('crypto');
const { WebSocketServer } = require('ws');
const { openDb, makeStore } = require('./db');
function createServer({ dbPath, port, corsOrigin, allowDevEndpoints = false } = {}) {
const db = openDb(dbPath || './data/tracker.sqlite');
const app = express();
app.use(cors({ origin: corsOrigin || '*' }));
app.use(express.json({ limit: '1mb' }));
// WS subscribers: path -> Set<ws>.
// Subscribers register a path (doc or collection). On change, notify:
// - doc subscribers at the changed path
// - collection subscribers at the changed doc's parent path
const docSubscribers = new Map(); // path -> Set<ws>
const collSubscribers = new Map(); // collPath -> Set<ws>
function addSub(map, key, ws) {
if (!map.has(key)) map.set(key, new Set());
map.get(key).add(ws);
}
function removeSub(map, key, ws) {
const set = map.get(key);
if (set) { set.delete(ws); if (set.size === 0) map.delete(key); }
}
function dropWs(ws) {
for (const [key, set] of docSubscribers) { set.delete(ws); if (set.size === 0) docSubscribers.delete(key); }
for (const [key, set] of collSubscribers) { set.delete(ws); if (set.size === 0) collSubscribers.delete(key); }
}
function broadcast(change) {
const payload = JSON.stringify({ type: 'change', change });
// doc subscriber at exact path
const docSet = docSubscribers.get(change.path);
if (docSet) [...docSet].forEach(ws => ws.readyState === ws.OPEN && ws.send(payload));
// collection subscribers at parent path (collection contains this doc)
if (change.parent) {
const collSet = collSubscribers.get(change.parent);
if (collSet) [...collSet].forEach(ws => ws.readyState === ws.OPEN && ws.send(payload));
}
}
const store = makeStore(db, broadcast);
// --- generic REST ---
app.get('/health', (req, res) => res.json({ ok: true }));
// GET /api/doc?path=campaigns/abc/encounters/xyz
app.get('/api/doc', (req, res) => {
const { path: p } = req.query;
if (!p) return res.status(400).json({ error: 'path required' });
res.json({ path: p, data: store.getDoc(p) });
});
// GET /api/collection?path=campaigns/abc/encounters
app.get('/api/collection', (req, res) => {
const { path: p, whereField, whereOp, whereValue, orderBy, dir, limit, offset } = req.query;
if (!p) return res.status(400).json({ error: 'path required' });
const opts = {};
if (whereField) opts.where = { field: whereField, op: whereOp || '==', value: whereValue };
if (orderBy) opts.orderBy = orderBy;
if (dir) opts.dir = dir;
if (limit) opts.limit = limit;
if (offset) opts.offset = offset;
res.json(store.getCollection(p, opts));
});
app.get('/api/collection/count', (req, res) => {
const { path: p } = req.query;
if (!p) return res.status(400).json({ error: 'path required' });
res.json({ count: store.countCollection(p) });
});
// PUT /api/doc body: { path, data } (replace)
app.put('/api/doc', (req, res) => {
const { path: p, data } = req.body || {};
if (!p) return res.status(400).json({ error: 'path required' });
res.json({ path: p, data: store.setDoc(p, data) });
});
// PATCH /api/doc body: { path, patch } (shallow merge, create-on-miss)
app.patch('/api/doc', (req, res) => {
const { path: p, patch } = req.body || {};
if (!p) return res.status(400).json({ error: 'path required' });
res.json({ path: p, data: store.updateDoc(p, patch) });
});
// DELETE /api/doc?path=...
app.delete('/api/doc', (req, res) => {
const { path: p } = req.query;
if (!p) return res.status(400).json({ error: 'path required' });
store.deleteDoc(p);
res.json({ ok: true });
});
// DELETE /api/collection?path=...&whereField=...&whereValue=...
// Bulk delete whole collection or filtered. No fetch. SQL DELETE.
// DEV ONLY: requires ALLOW_DEV_ENDPOINTS=1 env on server.
app.delete('/api/collection', (req, res) => {
if (!allowDevEndpoints && process.env.ALLOW_DEV_ENDPOINTS !== '1') {
return res.status(403).json({ error: 'bulk delete disabled (dev only)' });
}
const { path: p, whereField, whereOp, whereValue } = req.query;
if (!p) return res.status(400).json({ error: 'path required' });
const opts = {};
if (whereField) opts.where = { field: whereField, op: whereOp || '==', value: whereValue };
const deleted = store.deleteCollection(p, opts);
res.json({ ok: true, deleted });
});
// POST /api/collection body: { path, data } (addDoc: auto-id under collection)
app.post('/api/collection', (req, res) => {
const { path: collPath, data } = req.body || {};
if (!collPath) return res.status(400).json({ error: 'path required' });
const id = crypto.randomUUID();
const docPath = `${collPath}/${id}`;
res.json({ id, path: docPath, data: store.setDoc(docPath, data) });
});
// POST /api/batch body: { ops: [{type:'set'|'update'|'delete', path, data?}] }
app.post('/api/batch', (req, res) => {
const { ops } = req.body || {};
if (!Array.isArray(ops)) return res.status(400).json({ error: 'ops array required' });
store.batchWrite(ops);
res.json({ ok: true });
});
// POST /api/undo body: { logPath, undo } where undo={encounterPath, updates, redo}
// Transactional: apply updates + flip undone in one SQLite tx.
// If `redo` true, applies redo patch instead + flips undone:false.
app.post('/api/undo', (req, res) => {
const { logPath, undo } = req.body || {};
if (!logPath || !undo || !undo.encounterPath) {
return res.status(400).json({ error: 'logPath + undo.encounterPath required' });
}
if (!store.transactionalUndo) {
return res.status(501).json({ error: 'transactionalUndo not supported by store' });
}
try {
const isRedo = !!req.body.redo;
const updates = isRedo ? (undo.redo || undo.updates) : undo.updates;
const result = store.transactionalUndo({
logPath,
encounterPath: undo.encounterPath,
updates,
undoneFlag: !isRedo,
});
res.json({ ok: true, ...result });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// --- WebSocket: subscribe by path ---
const server = http.createServer(app);
const wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', (ws) => {
ws.on('message', (raw) => {
let msg;
try { msg = JSON.parse(raw.toString()); } catch { ws.send(JSON.stringify({ type: 'error', error: 'bad json' })); return; }
if (msg.type === 'subscribe' && msg.path) {
if (msg.kind === 'collection') addSub(collSubscribers, msg.path, ws);
else addSub(docSubscribers, msg.path, ws);
ws.send(JSON.stringify({ type: 'subscribed', path: msg.path, kind: msg.kind || 'doc' }));
} else if (msg.type === 'unsubscribe' && msg.path) {
if (msg.kind === 'collection') removeSub(collSubscribers, msg.path, ws);
else removeSub(docSubscribers, msg.path, ws);
}
});
ws.on('close', () => dropWs(ws));
ws.on('error', () => {});
});
return {
app, server, wss, store, db,
close(done) {
wss.clients.forEach(c => { try { c.terminate(); } catch {} });
wss.close();
server.close(() => { db.close(); if (done) done(); });
},
};
}
// Boot standalone if run directly.
if (require.main === module) {
const port = parseInt(process.env.PORT, 10) || 4001;
const dbPath = process.env.DB_PATH || './data/tracker.sqlite';
const { server } = createServer({ dbPath, port });
server.listen(port, () => {
console.log(`ttrpg backend listening on :${port} (db: ${dbPath})`);
});
}
module.exports = { createServer };
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
rootDir: '.',
testEnvironment: 'node',
testMatch: ['<rootDir>/tests/**/*.test.js'],
testTimeout: 10000,
};
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@ttrpg/server",
"version": "0.1.0",
"private": true,
"description": "Self-hosted backend: Express + ws + better-sqlite3. Server-authoritative turn logic.",
"main": "index.js",
"scripts": {
"dev": "node --watch index.js",
"start": "node index.js",
"test": "../scripts/cap.sh 30 jest --forceExit"
},
"dependencies": {
"@ttrpg/shared": "*",
"better-sqlite3": "^12.0.0",
"cors": "^2.8.5",
"express": "^4.19.2",
"nanoid": "^5.0.7",
"ws": "^8.18.0"
},
"devDependencies": {
"jest": "^29.7.0",
"supertest": "^7.0.0"
}
}
+115
View File
@@ -0,0 +1,115 @@
// Layer 2 test: exercise server.js storage adapter against a LIVE backend.
// Complements Layer 1 (App + firebase mock) which proves App call shape but
// never touches ws.js. This catches translation bugs in the adapter.
//
// Runs the shared storage contract (same spec memory/firebase satisfy) against
// createServerStorage pointed at an ephemeral backend instance. A FRESH backend is
// spun up per test to guarantee isolation (backend has no reset endpoint yet).
'use strict';
const path = require('path');
const os = require('os');
const { createServer } = require('../index');
const { createServerStorage } = require('../../src/storage/server');
const { runStorageContract } = require('../../src/storage/contract');
// Factory: fresh backend (unique sqlite file) + storage pointed at it.
// Disposing the storage closes the backend so each test is fully isolated.
async function makeStorage({ allowDevEndpoints = false } = {}) {
const dbPath = path.join(os.tmpdir(), `ws-contract-${Date.now()}-${Math.random().toString(36).slice(2)}.sqlite`);
const handle = createServer({ dbPath, port: 0, allowDevEndpoints });
await new Promise((resolve, reject) => {
handle.server.on('error', reject);
handle.server.listen(0, resolve);
});
const port = handle.server.address().port;
const baseUrl = `http://127.0.0.1:${port}`;
const wsUrl = `ws://127.0.0.1:${port}/ws`;
const storage = createServerStorage({ baseUrl, realtimeUrl: wsUrl });
storage.dispose = (done) => handle.close(done);
return storage;
}
runStorageContract('server (live backend)', () => makeStorage({ allowDevEndpoints: true }));
describe('server-side query constraints', () => {
let storage;
beforeEach(async () => {
storage = await makeStorage();
});
afterEach((done) => storage.dispose(done));
test('GET /api/collection honors orderBy + limit (server-side, not client)', async () => {
// 5 docs with distinct ts
for (let i = 1; i <= 5; i++) {
await storage.addDoc('logs', { type: 'next_turn', ts: i * 100, n: i });
}
const res = await storage._api('GET', '/api/collection',
{ path: 'logs', orderBy: 'ts', dir: 'desc', limit: '2' });
expect(Array.isArray(res)).toBe(true);
expect(res.length).toBe(2);
// desc: highest ts first
expect(res[0].data ? res[0].data.n : res[0].n).toBe(5);
expect(res[1].data ? res[1].data.n : res[1].n).toBe(4);
});
test('GET /api/collection honors offset for pagination', async () => {
for (let i = 1; i <= 5; i++) {
await storage.addDoc('logs', { type: 'next_turn', ts: i * 100, n: i });
}
// page 2 of size 2, desc by ts: should be n=3,2
const res = await storage._api('GET', '/api/collection',
{ path: 'logs', orderBy: 'ts', dir: 'desc', limit: '2', offset: '2' });
expect(res.length).toBe(2);
expect(res[0].data ? res[0].data.n : res[0].n).toBe(3);
expect(res[1].data ? res[1].data.n : res[1].n).toBe(2);
});
test('GET /api/collection/count returns total', async () => {
for (let i = 1; i <= 5; i++) {
await storage.addDoc('logs', { type: 'next_turn', ts: i * 100, n: i });
}
const res = await storage._api('GET', '/api/collection/count', { path: 'logs' });
expect(res).toEqual({ count: 5 });
});
});
describe('DELETE /api/collection (bulk delete)', () => {
let storage;
beforeEach(async () => {
storage = await makeStorage({ allowDevEndpoints: true });
});
afterEach((done) => storage.dispose(done));
test('deletes all docs in collection, no fetch', async () => {
for (let i = 0; i < 3; i++) await storage.addDoc('logs', { type: 'x', n: i });
const res = await storage._api('DELETE', '/api/collection', { path: 'logs' });
expect(res.ok).toBe(true);
expect(res.deleted).toBe(3);
const after = await storage._api('GET', '/api/collection/count', { path: 'logs' });
expect(after.count).toBe(0);
});
test('honors whereField filter', async () => {
await storage.addDoc('logs', { type: 'keep', n: 1 });
await storage.addDoc('logs', { type: 'drop', n: 2 });
await storage.addDoc('logs', { type: 'drop', n: 3 });
const res = await storage._api('DELETE', '/api/collection',
{ path: 'logs', whereField: 'type', whereValue: 'drop' });
expect(res.deleted).toBe(2);
const after = await storage._api('GET', '/api/collection/count', { path: 'logs' });
expect(after.count).toBe(1);
});
test('403 when ALLOW_DEV_ENDPOINTS not set', async () => {
const gated = await makeStorage({ allowDevEndpoints: false });
let errMsg = null;
try {
await gated._api('DELETE', '/api/collection', { path: 'logs' });
} catch (e) { errMsg = e.message; }
expect(errMsg).toMatch(/403/);
await new Promise((resolve) => gated.dispose(resolve));
});
});
+56
View File
@@ -0,0 +1,56 @@
// BUG-8: ws adapter has NO reconnect. WS dies (idle/error/close) → wsReady=null,
// subscribers dead forever, no re-subscribe. Display frozen until full reload.
// Test: subscribe, write (cb fires), force-drop WS, write again (must still fire).
// RED on current.
'use strict';
const path = require('path');
const os = require('os');
const { createServer } = require('../index');
const { createServerStorage } = require('../../src/storage/server');
const flush = (ms = 150) => new Promise(r => setTimeout(r, ms));
async function makeStorage() {
const dbPath = path.join(os.tmpdir(), `ws-recon-${Date.now()}-${Math.random().toString(36).slice(2)}.sqlite`);
const handle = createServer({ dbPath, port: 0 });
await new Promise((resolve, reject) => {
handle.server.on('error', reject);
handle.server.listen(0, resolve);
});
const port = handle.server.address().port;
const baseUrl = `http://127.0.0.1:${port}`;
const wsUrl = `ws://127.0.0.1:${port}/ws`;
const storage = createServerStorage({ baseUrl, realtimeUrl: wsUrl });
storage.dispose = (done) => handle.close(done);
return storage;
}
describe('BUG-8: ws adapter reconnect after drop', () => {
test('subscribe fires cb after WS dropped + restored', async () => {
const storage = await makeStorage();
try {
await storage.setDoc('campaigns/a', { name: 'V1' });
const calls = [];
storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc));
await flush();
expect(calls.length).toBeGreaterThanOrEqual(1);
// force-drop WS (simulates idle timeout / network blip)
storage._test.forceDrop();
await flush(300);
// wsReady should be null now
expect(storage._test.getReady()).toBeNull();
// write again — subscriber must re-fire after reconnect
await storage.setDoc('campaigns/a', { name: 'V2' });
await flush(1000);
const last = calls[calls.length - 1];
expect(last).toEqual({ id: 'a', name: 'V2' });
} finally {
await new Promise(r => storage.dispose(r));
}
}, 15000);
});
+3
View File
@@ -0,0 +1,3 @@
// @ttrpg/shared — barrel export.
const turn = require('./turn.js');
module.exports = { ...turn, logEvent: require('./logEvent.js') };
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
rootDir: '.',
testEnvironment: 'node',
testMatch: ['<rootDir>/tests/**/*.test.js'],
collectCoverageFrom: ['turn.js'],
testTimeout: 10000,
};
+76
View File
@@ -0,0 +1,76 @@
// shared/logEvent.js — canonical event shape for UI/download/replay/analyze.
// One source of truth. All four consumers import this.
//
// Canonical event:
// {
// id, ts, type, message,
// encounterId, encounterName, encounterPath,
// payload, // forward patch (null for no-op)
// undo_payload, // { encounterPath, updates, redo } or null
// undone, // bool
// snapshot // { round, currentTurnParticipantId, turnOrderIds, activeIds } or null
// }
//
// Old logs (pre-refactor): { timestamp, message, encounterName, undo }.
// normalizeEvent fills defaults + lifts legacy undo into undo_payload.
const DEFAULTS = {
ts: 0,
type: 'unknown',
message: '',
encounterId: null,
encounterName: null,
encounterPath: null,
payload: null,
undo_payload: null,
undone: false,
snapshot: null,
};
// Canonical lean event shape:
// {
// id, ts, type, message,
// encounterId, encounterName, encounterPath,
// participantId, participantName, // nullable (pause/nextTurn have none)
// delta, // type-specific scalars (nullable)
// undo, // id-based inverse (nullable)
// undone, // bool
// snapshot // {round, currentTurnParticipantId, turnOrderIds, activeIds}
// }
// Legacy logs (pre-refactor, no type): display message only. Not undoable.
// Old bloated logs (payload/undo_payload): normalized to lean — payload dropped,
// undo_payload lifted to undo. Both display fine; undo expands via expandUndo.
function normalizeEvent(raw) {
if (!raw) return null;
if (!raw.type || raw.type === 'unknown') return null;
const id = raw.id || (raw.path ? raw.path.split('/').pop() : null);
return {
id,
ts: raw.ts || raw.timestamp || 0,
type: raw.type,
message: raw.message || '',
encounterId: raw.encounterId || null,
encounterName: raw.encounterName || null,
encounterPath: raw.encounterPath || null,
participantId: raw.participantId || null,
participantName: raw.participantName || null,
delta: raw.delta || null,
undo: raw.undo || raw.undo_payload || null,
undone: !!raw.undone,
snapshot: raw.snapshot || null,
};
}
// Serialize array of raw log entries (DB rows) → canonical JSON string.
// Legacy logs (no type) dropped by normalizeEvent. Sorted ascending by ts.
function serializeEvents(rawLogs) {
return JSON.stringify(
(rawLogs || [])
.map(normalizeEvent)
.filter(Boolean)
.sort((a, b) => a.ts - b.ts),
null, 2
);
}
module.exports = { normalizeEvent, serializeEvents, DEFAULTS };
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@ttrpg/shared",
"version": "0.1.0",
"private": true,
"description": "Pure logic shared by client + server + tests. No I/O.",
"main": "index.js",
"scripts": {
"test": "../scripts/cap.sh 30 jest"
},
"devDependencies": {
"jest": "^29.7.0"
}
}
+131
View File
@@ -0,0 +1,131 @@
# Test Rewrite Spec — async turn.js writes own logs
## New turn.js API
ALL mutating funcs now `async`, take `ctx` last param, write encounter + log internally, return `newEnc`.
```js
// OLD
const r = nextTurn(enc);
enc = { ...enc, ...r.patch };
// caller had to write log separately
// NEW
enc = await nextTurn(enc, ctx);
// encounter + log already written inside func
```
### Func signatures (all async unless noted)
- `startEncounter(enc, ctx)` → newEnc
- `nextTurn(enc, ctx)` → newEnc
- `togglePause(enc, ctx)` → newEnc
- `addParticipant(enc, participant, ctx)` → newEnc
- `addParticipants(enc, newParticipants[], ctx)` → newEnc
- `updateParticipant(enc, id, updatedData, ctx)` → newEnc
- `removeParticipant(enc, id, ctx)` → newEnc
- `toggleParticipantActive(enc, id, ctx)` → newEnc
- `applyHpChange(enc, id, changeType, amount, ctx)` → newEnc (no-op = returns enc unchanged, no write)
- `deathSave(enc, id, type, n, ctx)``{ enc, status, isDying }` (object now!)
- `toggleCondition(enc, id, conditionId, ctx)` → newEnc
- `reorderParticipants(enc, draggedId, targetId, ctx)` → newEnc (no-op = returns enc, no write)
- `endEncounter(enc, ctx)` → newEnc
### Pure helpers (sync, unchanged)
- `makeParticipant`, `buildCharacterParticipant`, `buildMonsterParticipant`
- `generateId`, `rollD20`, `formatInitMod`
- `sortParticipantsByInitiative`, `syncTurnOrder`, `computeTurnOrderAfterRemoval`
- `snapshotOf`, `buildEntry`
### Display lifecycle (async, no combat log)
- `activateDisplay({campaignId, encounterId}, ctx)` → void
- `clearDisplay(ctx)` → void
- `toggleHidePlayerHp(currentValue, ctx)` → void
## Test helper: shared/tests/_helpers.js
```js
const { createMockStorage, mockCtx, readyEnc } = require('./_helpers');
const { storage, ctx } = mockCtx(); // ctx = {storage, encPath:'encounters/e1', logPath:'logs', displayPath:'activeDisplay/status'}
const enc = readyEnc(); // {id:'e1', name:'TestEnc', round:0, ... 2 participants p1(Bob,init10) p2(Mob,init5)}
```
### Mock storage methods
- `storage.calls` — array of `{fn, path, data}` ordered
- `storage.logs()` — array of log entries written (addDoc data)
- `storage.updatesFor(prefix)` — updateDoc calls matching path prefix
- `storage.docs` — Map path→data
## Transform patterns
### Pattern 1: state assertion
```js
// OLD
const r = startEncounter(enc);
expect(r.patch.round).toBe(1);
// NEW
const newEnc = await startEncounter(enc, ctx);
expect(newEnc.round).toBe(1);
```
### Pattern 2: log assertion
```js
// OLD
const r = startEncounter(enc);
expect(r.log.type).toBe('start_encounter');
// NEW
const newEnc = await startEncounter(enc, ctx);
expect(storage.logs()).toHaveLength(1);
expect(storage.logs()[0].type).toBe('start_encounter');
```
### Pattern 3: chained calls (sequence)
```js
// OLD
let enc = readyEnc();
enc = { ...enc, ...startEncounter(enc).patch };
enc = { ...enc, ...nextTurn(enc).patch };
// NEW
let enc = readyEnc();
enc = await startEncounter(enc, ctx);
enc = await nextTurn(enc, ctx);
```
### Pattern 4: no-op
```js
// OLD (returned {patch:null, log:null})
const r = applyHpChange(enc, 'p1', 'damage', 0);
expect(r.patch).toBeNull();
// NEW (returns enc unchanged, no write)
const newEnc = await applyHpChange(enc, 'p1', 'damage', 0, ctx);
expect(newEnc).toBe(enc); // same ref = no write
expect(storage.calls.filter(c => c.fn === 'updateDoc')).toHaveLength(0);
```
### Pattern 5: deathSave (returns object now)
```js
// OLD
const r = deathSave(enc, 'p1', 'success', 1);
expect(r.status).toBe('pending');
// NEW
const { enc: newEnc, status, isDying } = await deathSave(enc, 'p1', 'success', 1, ctx);
expect(status).toBe('pending');
```
## Static guards
- `static.no-sort.test.js` — KEEP. Still valid (scans turn.js for stray `.sort(`).
- `static.no-unlogged.test.js` — DELETE. Old `{patch, log}` shape gone. Every func writes log internally via `commit()`. No way to "forget".
- `static.eslint.test.js` — KEEP.
## Constraints
- KEEP test names (describe/it blocks) — same coverage intent.
- KEEP assertions — same values checked, just via newEnc or storage.logs().
- Fresh `mockCtx()` per test (isolation).
- For seeded-RNG combat tests: keep the LCG seed + rand helpers, just swap call pattern.
- `await` every mutating func call.
+149
View File
@@ -0,0 +1,149 @@
// Test helper: in-memory mock storage. Records all writes (addDoc/updateDoc).
// Stores docs so getDoc/getCollection return real state. undo() actually
// applies patches + flips log `undone` flag (mirrors firebase/server contract).
function createMockStorage() {
const docs = new Map(); // path -> data
const calls = []; // ordered {fn, path, data}
const applyPatch = (path, patch) => {
if (!docs.has(path)) docs.set(path, {});
const cur = docs.get(path);
// Firestore shallow-merge: arrays replaced, not merged. Patch arrays are
// the FULL new value (participants, turnOrderIds, conditions).
docs.set(path, { ...cur, ...patch });
};
return {
calls,
docs,
async getDoc(path) { return docs.has(path) ? { ...docs.get(path) } : null; },
async setDoc(path, data) {
calls.push({ fn: 'setDoc', path, data });
docs.set(path, { ...data });
},
async addDoc(path, data) {
calls.push({ fn: 'addDoc', path, data });
// Persist log entry so getCollection + undo can read it back.
const id = data.id || `log-${calls.length}`;
const stored = { ...data, id, _path: `${path}/${id}` };
docs.set(`${path}/${id}`, stored);
return id;
},
async updateDoc(path, patch) {
calls.push({ fn: 'updateDoc', path, data: patch });
applyPatch(path, patch);
},
async deleteDoc(path) {
calls.push({ fn: 'deleteDoc', path });
docs.delete(path);
},
async getCollection(path, queryConstraints = []) {
// Return docs whose _path starts with `${path}/` (Firestore collection).
const out = [];
for (const [p, d] of docs.entries()) {
if (p.startsWith(`${path}/`)) out.push({ ...d });
}
// Best-effort query support: where/orderBy/limit.
let filtered = out;
for (const qc of queryConstraints) {
if (!filtered.length) break;
const op = qc[0];
if (op === 'where') {
const [, field, , value] = qc;
filtered = filtered.filter(d => d[field] === value);
} else if (op === 'orderBy') {
const [, field, dir = 'asc'] = qc;
filtered.sort((a, b) => {
const av = a[field], bv = b[field];
if (av < bv) return dir === 'asc' ? -1 : 1;
if (av > bv) return dir === 'asc' ? 1 : -1;
return 0;
});
} else if (op === 'limit') {
filtered = filtered.slice(0, qc[1]);
}
}
return filtered;
},
async batchWrite(ops) {
for (const op of ops) {
if (op.type === 'delete') await this.deleteDoc(op.path);
else await this.updateDoc(op.path, op.data);
}
},
// Transactional undo — mirrors firebase.js / server.js contract:
// redo=false: apply undo.updates to encounterPath, flip log undone=true
// redo=true: apply undo.redo to encounterPath, flip log undone=false
async undo({ logPath, undo, redo = false }) {
calls.push({ fn: 'undo', path: logPath, data: { undo, redo } });
const patch = redo ? (undo.redo || undo.updates) : undo.updates;
applyPatch(undo.encounterPath, patch);
if (docs.has(logPath)) {
const log = docs.get(logPath);
docs.set(logPath, { ...log, undone: !redo });
}
},
// filters
updatesFor(prefix) { return calls.filter(c => c.fn === 'updateDoc' && c.path.startsWith(prefix)); },
logs() { return calls.filter(c => c.fn === 'addDoc').map(c => c.data); },
getLogs() { return calls.filter(c => c.fn === 'addDoc').map(c => c.data); },
};
}
// Undo/redo harness: apply op via shared func (writes enc doc + log) then
// exercise the REAL undo mechanism (storage.undo applies patch + flips flag).
// Reads doc back so assertions reflect actual persisted state.
// applyThenUndo(ctx, encAfterOp) -> enc after undo applied
// applyThenRedo(ctx, encAfterUndo, lastLogId) -> enc after redo applied
async function undoLast(ctx, encAfterOp) {
const { storage, encPath, logPath } = ctx;
const last = storage.logs()[storage.logs().length - 1];
const { expandUndo } = require('./..');
const expanded = expandUndo(last, encAfterOp);
if (!expanded) throw new Error('expandUndo returned null');
const logEntryPath = `${logPath}/${last.id}`;
await storage.undo({ logPath: logEntryPath, undo: expanded, redo: false });
return await storage.getDoc(encPath);
}
async function redoLast(ctx, encAfterUndo, last) {
const { storage, encPath, logPath } = ctx;
const { expandUndo } = require('./..');
const expanded = expandUndo(last, encAfterUndo);
if (!expanded) throw new Error('expandUndo returned null (redo)');
const logEntryPath = `${logPath}/${last.id}`;
await storage.undo({ logPath: logEntryPath, undo: expanded, redo: true });
return await storage.getDoc(encPath);
}
// Standard ctx for tests. encPath + logPath + displayPath preset.
// Returns { storage, ctx } so tests destructure both in one call:
// const { storage, ctx } = mockCtx();
function mockCtx(storage) {
storage = storage || createMockStorage();
const ctx = {
storage,
encPath: 'encounters/e1',
logPath: 'logs',
displayPath: 'activeDisplay/status',
};
return { storage, ctx };
}
// Standard 2-participant ready encounter.
function readyEnc(opts = {}) {
return {
id: 'e1',
name: opts.name || 'TestEnc',
round: 0,
isStarted: false,
isPaused: false,
currentTurnParticipantId: null,
turnOrderIds: [],
participants: opts.participants || [
{ id: 'p1', name: 'Bob', type: 'character', initiative: 10, maxHp: 10, currentHp: 10, isActive: true, conditions: [], status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0 },
{ id: 'p2', name: 'Mob', type: 'monster', initiative: 5, maxHp: 10, currentHp: 10, isActive: true, conditions: [], status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0 },
],
};
}
module.exports = { createMockStorage, mockCtx, readyEnc, undoLast, redoLast };
+81
View File
@@ -0,0 +1,81 @@
// Test logEvent normalizer — canonical shape for UI/download/replay/analyze.
// New design: legacy logs (no type) DROPPED. Not in pipeline. Kept only for
// human scroll in LogsView (raw). download/copy/replay/analyze = typed only.
const { normalizeEvent, serializeEvents } = require('../logEvent.js');
describe('logEvent normalizer', () => {
test('new schema passes through', () => {
const raw = {
id: 'l1', ts: 1000, type: 'next_turn', message: 'Bob turn',
encounterId: 'e1', encounterName: 'Enc', encounterPath: 'encounters/e1',
participantId: 'p1', participantName: 'Bob',
delta: { wrapped: true, prevRound: 1 },
undo: { currentTurnParticipantId: 'p0', round: 1, turnOrderIds: ['p0','p1'] },
undone: false, snapshot: { round: 2, currentTurnParticipantId: 'p1', turnOrderIds: ['p1'], activeIds: ['p1'] },
};
const e = normalizeEvent(raw);
expect(e.id).toBe('l1');
expect(e.type).toBe('next_turn');
expect(e.snapshot.round).toBe(2);
expect(e.delta.wrapped).toBe(true);
expect(e.undo.round).toBe(1);
expect(e.participantName).toBe('Bob');
});
test('old bloated schema (payload/undo_payload) lifts undo, drops payload', () => {
const raw = {
id: 'l1b', ts: 1100, type: 'damage', message: 'dmg',
encounterPath: 'encounters/e1',
payload: { participants: [{ id: 'p1', currentHp: 30 }] },
undo_payload: { encounterPath: 'encounters/e1', updates: { round: 1 }, redo: { round: 2 } },
snapshot: { round: 2, currentTurnParticipantId: 'p1', turnOrderIds: ['p1'], activeIds: ['p1'] },
};
const e = normalizeEvent(raw);
expect(e.type).toBe('damage');
expect(e.undo).toBeTruthy();
expect(e.delta).toBeNull();
});
test('legacy (no type) dropped → null', () => {
const raw = { id: 'l2', timestamp: 500, message: 'old', encounterName: 'Enc', undo: { encounterPath: 'enc/e1', updates: {} } };
expect(normalizeEvent(raw)).toBeNull();
});
test('explicit unknown type dropped → null', () => {
expect(normalizeEvent({ id: 'l3', type: 'unknown' })).toBeNull();
});
test('no type at all dropped → null', () => {
expect(normalizeEvent({ id: 'l4' })).toBeNull();
expect(normalizeEvent({ path: 'logs/abc' })).toBeNull();
});
test('null in null out', () => {
expect(normalizeEvent(null)).toBeNull();
});
test('serialize drops all-legacy input → empty', () => {
const raw = [
{ id: 'c', timestamp: 300 },
{ id: 'a', timestamp: 100 },
{ id: 'b', timestamp: 200 },
];
expect(JSON.parse(serializeEvents(raw))).toEqual([]);
});
test('serialize sorts typed events ascending by ts', () => {
const raw = [
{ id: 'c', type: 'next_turn', ts: 300 },
{ id: 'a', type: 'start_encounter', ts: 100 },
{ id: 'b', type: 'damage', ts: 200 },
];
const arr = JSON.parse(serializeEvents(raw));
expect(arr.map(e => e.id)).toEqual(['a', 'b', 'c']);
});
test('serialize filters nulls + legacy, keeps typed', () => {
const arr = JSON.parse(serializeEvents([null, { id: 'x', type: 'damage' }, { id: 'legacy' }, null]));
expect(arr).toHaveLength(1);
expect(arr[0].id).toBe('x');
});
});
+65
View File
@@ -0,0 +1,65 @@
// STATIC GUARD: prod source must pass eslint with zero errors/warnings.
// Scans ALL .js/.jsx in src/ + shared/ (excl tests/mocks). Catches unused imports,
// dead code, undefined vars before they hit the browser console.
// Run via: npx eslint <files> --format json -> parse -> fail on any problem.
const { execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const ROOT = path.resolve(__dirname, '..', '..');
function walkJs(dir) {
let out = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === 'tests' || entry.name === '__mocks__') continue;
out = out.concat(walkJs(full));
} else if (/\.(js|jsx)$/.test(entry.name)) {
out.push(full);
}
}
return out;
}
const TARGETS = [
...walkJs(path.join(ROOT, 'src')),
...walkJs(path.join(ROOT, 'shared')),
];
function runEslint(files) {
const abs = files.map(f => `"${path.join(ROOT, f)}"`).join(' ');
// execSync throws on non-zero exit (eslint returns 1 when problems found).
// Capture stdout regardless.
let stdout;
try {
stdout = execSync(`npx eslint ${abs} --format json`, {
encoding: 'utf8',
cwd: ROOT,
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch (err) {
stdout = err.stdout || '';
}
return JSON.parse(stdout || '[]');
}
describe('static guard: eslint clean on prod source', () => {
test('zero eslint errors or warnings', () => {
const results = runEslint(TARGETS);
const problems = [];
let totalErrors = 0;
let totalWarnings = 0;
for (const file of results) {
totalErrors += file.errorCount;
totalWarnings += file.warningCount;
for (const msg of file.messages) {
problems.push(` ${path.relative(ROOT, file.filePath)}:${msg.line}:${msg.column} ${msg.ruleId}${msg.message}`);
}
}
if (totalErrors + totalWarnings > 0) {
const summary = `eslint found ${totalErrors} error(s), ${totalWarnings} warning(s):\n${problems.join('\n')}`;
throw new Error(summary);
}
}, 30000); // eslint spawn can be slow
});
+70
View File
@@ -0,0 +1,70 @@
// STATIC GUARD: no `.sort(` introduced in shared/turn.js outside the ONE
// allowed function (sortParticipantsByInitiative, used by startEncounter to
// freeze the list once). Slot-not-sort design (docs/INITIATIVE_ORDERING.md):
// mutations = insert/move, never wholesale re-sort. A stray `.sort(` after
// start destroys drag tie-break order.
//
// This test errs the moment someone adds `.sort(` anywhere but the allowlist.
// Maintenance: add a function to ALLOWED only if it runs at startEncounter
// (one-time freeze), NOT in add/update/reorder/nextTurn paths.
'use strict';
const fs = require('fs');
const path = require('path');
const SRC = fs.readFileSync(path.join(__dirname, '..', 'turn.js'), 'utf8');
// Functions permitted to call .sort(. Listed so intent is explicit + reviewed.
const ALLOWED_SORT_FUNCTIONS = new Set([
'sortParticipantsByInitiative',
]);
// Collect top-level function bodies by brace counting.
// Matches `function NAME(` decls AND `const NAME = ... =>` arrow fns.
// Returns [{ name, body }].
function collectBody(src, fromIdx) {
let i = fromIdx;
while (i < src.length && src[i] !== '{') i++;
let depth = 0;
const start = i;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') { depth--; if (depth === 0) break; }
}
return src.slice(start, i + 1);
}
function extractFunctions(src) {
const out = [];
const fnRe = /\bfunction\s+([A-Za-z0-9_$]+)\s*\(/g;
const arrowRe = /(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*[^=;]*?=>/g;
let m;
while ((m = fnRe.exec(src)) !== null) {
out.push({ name: m[1], body: collectBody(src, m.index + m[0].length) });
}
while ((m = arrowRe.exec(src)) !== null) {
out.push({ name: m[1], body: collectBody(src, m.index + m[0].length) });
}
return out;
}
describe('STATIC: no .sort( outside allowlist (slot-not-sort design)', () => {
test('every .sort( call lives in an allowed function', () => {
const fns = extractFunctions(SRC);
const offenders = [];
for (const { name, body } of fns) {
if (!ALLOWED_SORT_FUNCTIONS.has(name) && /\.sort\(/.test(body)) {
offenders.push(name);
}
}
expect(offenders).toEqual([]);
});
test('allowed sort function is declared (no silent allowlist drift)', () => {
const declared = new Set(extractFunctions(SRC).map(f => f.name));
for (const name of ALLOWED_SORT_FUNCTIONS) {
expect(declared.has(name)).toBe(true);
}
});
});
+35
View File
@@ -0,0 +1,35 @@
// AC field on participant + builders.
const shared = require('@ttrpg/shared');
const { makeParticipant, buildMonsterParticipant, buildCharacterParticipant, rollHpFormula } = shared;
describe('AC field', () => {
test('makeParticipant accepts ac', () => {
const p = makeParticipant({ name: 'Orc', type: 'monster', initiative: 10, maxHp: 15, currentHp: 15, ac: 13 });
expect(p.ac).toBe(13);
});
test('makeParticipant ac defaults null', () => {
const p = makeParticipant({ name: 'Orc', type: 'monster', initiative: 10, maxHp: 15, currentHp: 15 });
expect(p.ac).toBeNull();
});
test('buildMonsterParticipant accepts ac', () => {
const { participant } = buildMonsterParticipant({ name: 'Goblin', maxHp: 7, initMod: 2, ac: 15 });
expect(participant.ac).toBe(15);
});
test('buildMonsterParticipant ac defaults null', () => {
const { participant } = buildMonsterParticipant({ name: 'Goblin', maxHp: 7 });
expect(participant.ac).toBeNull();
});
test('buildCharacterParticipant accepts character ac', () => {
const { participant } = buildCharacterParticipant({ id: 'c1', name: 'Fighter', defaultMaxHp: 30, defaultInitMod: 2, defaultAc: 18 });
expect(participant.ac).toBe(18);
});
test('buildCharacterParticipant ac defaults null when unset', () => {
const { participant } = buildCharacterParticipant({ id: 'c1', name: 'Fighter', defaultMaxHp: 30 });
expect(participant.ac).toBeNull();
});
});
+86
View File
@@ -0,0 +1,86 @@
// BUG-10: deact+reactivate same round double-acts participant.
// OLD bug: reactivation re-inserted by initiative (front of pointer) →
// participant acted again before round end.
//
// 1-list model: toggleActive keeps slot position. Reactivation does NOT
// grant second turn. Each participant acts at most once per round.
//
// Test: walk rounds, count visits. Deactivate+reactivate mid-round must
// not cause any participant to act twice in same round.
//
// New API: mutating funcs are async, take ctx, return newEnc.
'use strict';
const {
makeParticipant,
startEncounter, nextTurn, toggleParticipantActive,
} = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers');
function p(id, init) {
return makeParticipant({ id, name: id, type: 'monster',
initiative: init, maxHp: 100, currentHp: 100 });
}
function enc(ps) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
}
describe('BUG-10: deact+reactivate same round', () => {
test('no participant acts twice in a round after deact+reactivate', async () => {
const { ctx } = mockCtx();
// [a(10), b(7), c(3)]
let e = enc([p('a',10),p('b',7),p('c',3)]);
e = await startEncounter(e, ctx); // a current, r1
const r1 = [];
r1.push(e.currentTurnParticipantId); // a acts
e = await nextTurn(e, ctx); r1.push(e.currentTurnParticipantId); // b acts
// b already acted and is still current. Deactivate b: status edit only,
// no turn advance. DM clicks Next Turn explicitly; nextTurn skips inactive b.
e = await toggleParticipantActive(e, 'b', ctx); // b off, current still b
expect(e.currentTurnParticipantId).toBe('b');
e = await nextTurn(e, ctx); // skips inactive b → c
r1.push(e.currentTurnParticipantId);
// reactivate b same round; b must not re-act before round wraps.
e = await toggleParticipantActive(e, 'b', ctx); // b on
// nextTurn from c → round 2 (a). b must NOT re-act in round 1.
e = await nextTurn(e, ctx);
expect(e.round).toBe(2);
expect(e.currentTurnParticipantId).toBe('a');
// b acted once in r1, must act once in r2
e = await nextTurn(e, ctx);
expect(e.currentTurnParticipantId).toBe('b');
e = await nextTurn(e, ctx);
expect(e.currentTurnParticipantId).toBe('c');
// per-round visit count
const bCountR1 = r1.filter(id => id === 'b').length;
expect(bCountR1).toBe(1);
});
test('deactivate+reactivate non-current who already acted: no re-act', async () => {
const { ctx } = mockCtx();
// [a(10), b(7), c(3)]. a acts, b acts, c acts (round 1 done).
// Then deactivate a (already acted, not current since c is current).
// Reactivate a. a must not act again until round 2.
let e = enc([p('a',10),p('b',7),p('c',3)]);
e = await startEncounter(e, ctx); // a
e = await nextTurn(e, ctx); // b
e = await nextTurn(e, ctx); // c (r1 full: a,b,c)
// c is current. deactivate a (not current, already acted).
e = await toggleParticipantActive(e, 'a', ctx); // a off, pointer stays c
expect(e.currentTurnParticipantId).toBe('c');
e = await toggleParticipantActive(e, 'a', ctx); // a on
expect(e.currentTurnParticipantId).toBe('c');
// nextTurn from c → a (round 2). a acts in r2, once.
e = await nextTurn(e, ctx);
expect(e.round).toBe(2);
expect(e.currentTurnParticipantId).toBe('a');
});
});
+79
View File
@@ -0,0 +1,79 @@
// BUG-13: cross-pointer reorder blocked (skip/double-act otherwise).
// nextTurn walks turnOrderIds forward from current pointer. Dragging across
// pointer = ambiguous who-acts-next:
// - upcoming dragged ahead of pointer → walk wraps past → SKIPPED
// - acted dragged behind pointer → acts again → DOUBLE-ACT
// Full fix needs actedThisRound tracking. Pragmatic now: block both dirs
// during active encounter. Pre-combat: free reorder (no pointer).
//
// New API: reorderParticipants is async, takes ctx, returns newEnc.
// Blocked drag = no-op (returns same enc ref, no write).
'use strict';
const {
makeParticipant,
startEncounter, nextTurn, reorderParticipants,
} = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers');
function p(id, init) {
return makeParticipant({ id, name: id, type: 'monster',
initiative: init, maxHp: 100, currentHp: 100 });
}
function enc(ps) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
}
describe('BUG-13: cross-pointer reorder blocked during active encounter', () => {
test('dragging not-yet-acted participant ahead of pointer = no-op', async () => {
const { ctx } = mockCtx();
// [a(10), b(10), c(10)]. a acts -> b current. c not yet acted.
let e = enc([p('a',10),p('b',10),p('c',10)]);
e = await startEncounter(e, ctx); // a (idx0, pointer)
e = await nextTurn(e, ctx); // b (idx1, pointer)
expect(e.currentTurnParticipantId).toBe('b');
// Drag c (idx2, behind pointer) before b (idx1, pointer). Crosses pointer.
// Would let c act by initiative-reinsert but nextTurn forward-walk skips.
// Blocked instead.
const newEnc = await reorderParticipants(e, 'c', 'b', ctx);
expect(newEnc).toBe(e); // no-op: same ref, no write
expect(e.turnOrderIds).toEqual(['a','b','c']); // unchanged
});
test('dragging already-acted participant behind pointer = no-op', async () => {
const { ctx } = mockCtx();
// [a(10), b(10), c(10)]. a acts (idx0, ahead of pointer).
let e = enc([p('a',10),p('b',10),p('c',10)]);
e = await startEncounter(e, ctx); // a (pointer idx0)
e = await nextTurn(e, ctx); // b (pointer idx1)
// Drag a (already acted, ahead of pointer) after c (behind pointer).
// Crosses pointer. Would let a act again. Blocked.
const newEnc = await reorderParticipants(e, 'a', 'c', ctx);
expect(newEnc).toBe(e); // no-op: same ref, no write
});
test('reorder within same side of pointer = allowed', async () => {
const { storage, ctx } = mockCtx();
// [a(10), b(10), c(10), d(10)]. a current (pointer idx0).
// b,c,d all behind pointer (upcoming). Reorder among them = safe.
let e = enc([p('a',10),p('b',10),p('c',10),p('d',10)]);
e = await startEncounter(e, ctx); // a (idx0)
// swap b,c (both idx1,2 — behind pointer). No cross.
const newEnc = await reorderParticipants(e, 'c', 'b', ctx);
// startEncounter logs too; assert the reorder write specifically.
expect(storage.logs().filter(l => l.type === 'reorder')).toHaveLength(1);
expect(newEnc.participants.map(p => p.id)).toEqual(['a','c','b','d']);
});
test('pre-combat: free reorder (no pointer)', async () => {
const { storage, ctx } = mockCtx();
// isStarted=false. No pointer. Reorder allowed freely.
let e = enc([p('a',10),p('b',10),p('c',10)]);
const newEnc = await reorderParticipants(e, 'c', 'a', ctx);
expect(storage.logs().filter(l => l.type === 'reorder')).toHaveLength(1);
expect(newEnc.participants.map(p => p.id)).toEqual(['c','a','b']);
});
});
+61
View File
@@ -0,0 +1,61 @@
// BUG-7: reorderParticipants not logged.
// Every mutating func is now async, takes ctx last, and writes encounter +
// log internally via commit(). reorderParticipants must emit a log entry on a
// real move (so the drag shows up in the combat log + carries an undo payload)
// and stay a silent no-op (no write) when blocked.
//
// RED was: returned { patch, log } with log:null → handler skipped logAction.
// Now: a successful reorder writes exactly one log entry; no-ops write none.
'use strict';
const { makeParticipant, reorderParticipants, expandUndo } = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers');
function p(id, init) {
return makeParticipant({ id, name: id, type: 'monster',
initiative: init, maxHp: 100, currentHp: 100 });
}
function enc(ps) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
}
describe('BUG-7: reorderParticipants logged', () => {
test('returns log object (not null) with message + undo', async () => {
const { storage, ctx } = mockCtx();
const e = enc([p('a', 10), p('b', 10), p('c', 10)]);
await reorderParticipants(e, 'c', 'b', ctx);
expect(storage.logs()).toHaveLength(1);
const entry = storage.logs()[0];
expect(typeof entry.message).toBe('string');
expect(entry.message.length).toBeGreaterThan(0);
expect(entry.undo).toBeDefined();
});
test('undo restores original participants order', async () => {
const { storage, ctx } = mockCtx();
const e = enc([p('a', 10), p('b', 10), p('c', 10)]);
const orig = e.participants.map(p => p.id);
await reorderParticipants(e, 'c', 'b', ctx);
const entry = storage.logs()[0];
const restored = { ...e, ...expandUndo(entry, e).updates };
expect(restored.participants.map(p => p.id)).toEqual(orig);
});
test('no-op (same id) returns null log', async () => {
const { storage, ctx } = mockCtx();
const e = enc([p('a', 10), p('b', 10)]);
const newEnc = await reorderParticipants(e, 'a', 'a', ctx);
expect(newEnc).toBe(e); // same ref = no write
expect(storage.logs()).toHaveLength(0);
});
test('no-op (cross-init blocked) returns null log', async () => {
const { storage, ctx } = mockCtx();
const e = enc([p('a', 10), p('b', 5)]);
const newEnc = await reorderParticipants(e, 'a', 'b', ctx);
expect(newEnc).toBe(e); // same ref = no write
expect(storage.logs()).toHaveLength(0);
});
});
+49
View File
@@ -0,0 +1,49 @@
// addParticipants (bulk) must slot by initiative, not append.
const shared = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers');
const { buildCharacterParticipant, buildMonsterParticipant, addParticipants, makeParticipant } = shared;
function enc(ps = []) {
return { name:'t', participants: ps, isStarted: false, isPaused: false,
round: 0, currentTurnParticipantId: null, turnOrderIds: [] };
}
function p(id, init) {
return makeParticipant({ id, name: id, type: 'monster', initiative: init, maxHp: 10, currentHp: 10 });
}
describe('addParticipants slot order', () => {
test('bulk add slots by initiative desc', async () => {
const { ctx } = mockCtx();
let e = enc([]);
e = await addParticipants(e, [p('a', 5), p('b', 20), p('c', 10)], ctx);
expect(e.participants.map(x => x.id)).toEqual(['b', 'c', 'a']);
});
test('bulk add preserves existing order + slots new', async () => {
const { ctx } = mockCtx();
let e = enc([p('a', 20), p('b', 5)]);
e = await addParticipants(e, [p('c', 10)], ctx);
expect(e.participants.map(x => x.id)).toEqual(['a', 'c', 'b']);
});
test('bulk add same-init appends after existing (stable tie)', async () => {
const { ctx } = mockCtx();
let e = enc([p('a', 10)]);
e = await addParticipants(e, [p('b', 10), p('c', 10)], ctx);
expect(e.participants.map(x => x.id)).toEqual(['a', 'b', 'c']);
});
test('bulk add mixed inits into existing list', async () => {
const { ctx } = mockCtx();
let e = enc([p('a', 15), p('b', 5)]);
e = await addParticipants(e, [p('c', 20), p('d', 10), p('e', 15)], ctx);
expect(e.participants.map(x => x.id)).toEqual(['c', 'a', 'e', 'd', 'b']);
});
test('bulk add updates turnOrderIds to match', async () => {
const { ctx } = mockCtx();
let e = enc([]);
e = await addParticipants(e, [p('a', 5), p('b', 20)], ctx);
expect(e.turnOrderIds).toEqual(['b', 'a']);
});
});
+386
View File
@@ -0,0 +1,386 @@
// Characterization tests for shared/turn.js.
// Lock CURRENT behavior (bugs included). M3 will extend, M4 will fix.
// These tests assert what the code does NOW, not what it SHOULD do.
//
// New API: every mutating func is async, takes ctx last, writes encounter +
// log internally, returns newEnc. We assert against newEnc (merged state) or
// the raw persisted patch (storage.calls) for "field not written" checks.
const shared = require('@ttrpg/shared');
const {
sortParticipantsByInitiative,
computeTurnOrderAfterRemoval,
startEncounter,
nextTurn,
togglePause,
addParticipant,
removeParticipant,
toggleParticipantActive,
applyHpChange,
deathSave,
toggleCondition,
reorderParticipants,
endEncounter,
makeParticipant,
} = shared;
const { mockCtx } = require('./_helpers');
// Helper: minimal encounter with given participants.
function enc(participants = [], extra = {}) {
return {
name: 'Test Encounter',
participants,
isStarted: false,
isPaused: false,
round: 0,
currentTurnParticipantId: null,
turnOrderIds: [],
...extra,
};
}
function p(id, initiative, extra = {}) {
return makeParticipant({
id, name: id, type: 'monster',
initiative, maxHp: 20, currentHp: 20,
...extra,
});
}
// Last updateDoc patch written to storage — for "field absent from patch"
// assertions (field === undefined means func didn't write it).
const lastPatch = (storage) => {
const u = storage.calls.filter(c => c.fn === 'updateDoc').pop();
return u ? u.data : {};
};
describe('sortParticipantsByInitiative', () => {
test('higher initiative first', () => {
const ps = [p('a', 5), p('b', 15), p('c', 10)];
const sorted = sortParticipantsByInitiative(ps, ps);
expect(sorted.map(x => x.id)).toEqual(['b', 'c', 'a']);
});
test('ties broken by original order', () => {
const ps = [p('a', 10), p('b', 10), p('c', 10)];
const sorted = sortParticipantsByInitiative(ps, ps);
expect(sorted.map(x => x.id)).toEqual(['a', 'b', 'c']);
});
});
describe('startEncounter', () => {
test('throws if no participants', async () => {
const { ctx } = mockCtx();
await expect(startEncounter(enc([]), ctx)).rejects.toThrow('participants');
});
test('throws if no active participants', async () => {
const { ctx } = mockCtx();
const e = enc([p('a', 10, { isActive: false })]);
await expect(startEncounter(e, ctx)).rejects.toThrow('active');
});
test('sets round 1, turn order sorted, current = highest init', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 5), p('b', 15), p('c', 10)];
const e = enc(ps);
const newEnc = await startEncounter(e, ctx);
expect(newEnc.isStarted).toBe(true);
expect(newEnc.round).toBe(1);
expect(newEnc.turnOrderIds).toEqual(['b', 'c', 'a']);
expect(newEnc.currentTurnParticipantId).toBe('b');
});
test('inactive stays in turn order slot (1-list model)', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 5), p('b', 15, { isActive: false }), p('c', 10)];
const newEnc = await startEncounter(enc(ps), ctx);
// 1-list: all participants sorted by init (active+inactive), inactive stays in slot
expect(newEnc.turnOrderIds).toEqual(['b', 'c', 'a']);
expect(newEnc.currentTurnParticipantId).toBe('c'); // b inactive, skipped
});
});
describe('nextTurn', () => {
test('throws if not started', async () => {
const { ctx } = mockCtx();
await expect(nextTurn(enc([p('a', 10)], { isStarted: false }), ctx)).rejects.toThrow();
});
test('throws if paused', async () => {
const { ctx } = mockCtx();
await expect(nextTurn(enc([p('a', 10)], { isStarted: true, isPaused: true, currentTurnParticipantId: 'a', turnOrderIds: ['a'] }), ctx)).rejects.toThrow();
});
test('advances to next in order, no round bump', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 5), p('b', 15), p('c', 10)];
const e = enc(ps, {
isStarted: true,
round: 1,
currentTurnParticipantId: 'b',
turnOrderIds: ['b', 'c', 'a'],
});
const newEnc = await nextTurn(e, ctx);
expect(newEnc.currentTurnParticipantId).toBe('c');
expect(newEnc.round).toBe(1);
});
test('wraps round when last in order', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 5), p('b', 15), p('c', 10)];
const e = enc(ps, {
isStarted: true,
round: 1,
currentTurnParticipantId: 'a',
turnOrderIds: ['b', 'c', 'a'],
});
const newEnc = await nextTurn(e, ctx);
expect(newEnc.currentTurnParticipantId).toBe('b');
expect(newEnc.round).toBe(2);
});
test('ends encounter if no active participants', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10, { isActive: false })];
const e = enc(ps, {
isStarted: true,
round: 1,
currentTurnParticipantId: 'a',
turnOrderIds: ['a'],
});
const newEnc = await nextTurn(e, ctx);
expect(newEnc.isStarted).toBe(false);
expect(newEnc.currentTurnParticipantId).toBe(null);
});
});
describe('togglePause', () => {
test('pauses started encounter', async () => {
const { ctx } = mockCtx();
const e = enc([p('a', 10)], { isStarted: true, isPaused: false });
const newEnc = await togglePause(e, ctx);
expect(newEnc.isPaused).toBe(true);
});
test('resume preserves turn order (no re-sort)', async () => {
// BUG-5 fix: resume no longer re-sorts. Re-sort displaced current pointer
// and caused skips. Order frozen at startEncounter, patched incrementally.
const { ctx } = mockCtx();
const ps = [p('a', 5), p('b', 15)];
const e = enc(ps, { isStarted: true, isPaused: true, turnOrderIds: ['a', 'b'] });
const newEnc = await togglePause(e, ctx);
expect(newEnc.isPaused).toBe(false);
expect(newEnc.turnOrderIds).toEqual(['a', 'b']);
});
});
describe('removeParticipant', () => {
test('removes from participants array', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10), p('b', 5)];
const newEnc = await removeParticipant(enc(ps), 'a', ctx);
expect(newEnc.participants.map(x => x.id)).toEqual(['b']);
});
test('not started: no turn order mutation', async () => {
const { storage, ctx } = mockCtx();
const ps = [p('a', 10), p('b', 5)];
await removeParticipant(enc(ps), 'a', ctx);
expect(lastPatch(storage).turnOrderIds).toBeUndefined();
});
test('started: removes from turnOrderIds', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10), p('b', 5)];
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'b' });
const newEnc = await removeParticipant(e, 'a', ctx);
expect(newEnc.turnOrderIds).toEqual(['b']);
});
test('started: removing current picks next active', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10), p('b', 5), p('c', 3)];
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b', 'c'], currentTurnParticipantId: 'a' });
const newEnc = await removeParticipant(e, 'a', ctx);
expect(newEnc.currentTurnParticipantId).toBe('b');
});
});
describe('toggleParticipantActive', () => {
test('deactivates participant', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10, { isActive: true })];
const newEnc = await toggleParticipantActive(enc(ps), 'a', ctx);
expect(newEnc.participants[0].isActive).toBe(false);
});
test('started: deactivating current does not advance turn or round', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10), p('b', 5)];
const e = enc(ps, { isStarted: true, round: 1, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' });
const newEnc = await toggleParticipantActive(e, 'a', ctx);
expect(newEnc.currentTurnParticipantId).toBe('a');
expect(newEnc.round).toBe(1);
expect(newEnc.participants.find(p => p.id === 'a').isActive).toBe(false);
});
test('started: reactivating inserts by initiative', async () => {
// BUG-5 fix: reactivated participant slots by initiative (not appended
// to end). Preserves correct rotation order.
const { ctx } = mockCtx();
const ps = [p('a', 10, { isActive: false }), p('b', 5)];
const e = enc(ps, { isStarted: true, turnOrderIds: ['b'], currentTurnParticipantId: 'b' });
const newEnc = await toggleParticipantActive(e, 'a', ctx);
// a init=10 > b init=5 → a slots before b
expect(newEnc.turnOrderIds).toEqual(['a', 'b']);
});
});
describe('applyHpChange', () => {
test('damage reduces hp, clamps 0', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10, { currentHp: 15, maxHp: 20 })];
const newEnc = await applyHpChange(enc(ps), 'a', 'damage', 5, ctx);
expect(newEnc.participants[0].currentHp).toBe(10);
});
test('damage to 0 marks dying, keeps active, keeps turn order', async () => {
const { storage, ctx } = mockCtx();
const ps = [p('a', 10, { type: 'character', currentHp: 3 }), p('b', 5)];
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' });
const newEnc = await applyHpChange(e, 'a', 'damage', 5, ctx);
expect(newEnc.participants[0].currentHp).toBe(0);
expect(newEnc.participants[0].status).toBe('dying');
expect(newEnc.participants[0].isActive).not.toBe(false);
expect(lastPatch(storage).turnOrderIds).toBeUndefined();
expect(lastPatch(storage).currentTurnParticipantId).toBeUndefined();
});
test('heal from dying makes conscious and resets death-save counters', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10, { currentHp: 0, status: 'dying', deathSaveSuccesses: 2, deathSaveFailures: 1 })];
const newEnc = await applyHpChange(enc(ps), 'a', 'heal', 5, ctx);
expect(newEnc.participants[0].currentHp).toBe(5);
expect(newEnc.participants[0].status).toBe('conscious');
expect(newEnc.participants[0].deathSaveSuccesses).toBe(0);
expect(newEnc.participants[0].deathSaveFailures).toBe(0);
});
test('heal clamps to maxHp', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10, { currentHp: 18, maxHp: 20 })];
const newEnc = await applyHpChange(enc(ps), 'a', 'heal', 10, ctx);
expect(newEnc.participants[0].currentHp).toBe(20);
});
test('zero amount = no-op', async () => {
const { storage, ctx } = mockCtx();
const e = enc([p('a', 10, { currentHp: 10 })]);
const newEnc = await applyHpChange(e, 'a', 'damage', 0, ctx);
expect(newEnc).toBe(e); // same ref = no write
expect(storage.calls.filter(c => c.fn === 'updateDoc')).toHaveLength(0);
});
});
describe('deathSave', () => {
test('fail increments failure counter while dying', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10, { currentHp: 0, status: 'dying', deathSaveFailures: 0, deathSaveSuccesses: 0 })];
const { enc: newEnc, status } = await deathSave(enc(ps), 'a', 'fail', ctx);
expect(status).toBe('dying');
expect(newEnc.participants[0].deathSaveFailures).toBe(1);
});
test('third fail makes dead and resets counters', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10, { currentHp: 0, status: 'dying', deathSaveFailures: 2, deathSaveSuccesses: 0 })];
const { enc: newEnc, status } = await deathSave(enc(ps), 'a', 'fail', ctx);
expect(status).toBe('dead');
expect(newEnc.participants[0].status).toBe('dead');
expect(newEnc.participants[0].deathSaveFailures).toBe(0);
});
});
describe('toggleCondition', () => {
test('adds condition', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10, { conditions: [] })];
const newEnc = await toggleCondition(enc(ps), 'a', 'poisoned', ctx);
expect(newEnc.participants[0].conditions).toEqual(['poisoned']);
});
test('removes condition', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10, { conditions: ['poisoned', 'blinded'] })];
const newEnc = await toggleCondition(enc(ps), 'a', 'poisoned', ctx);
expect(newEnc.participants[0].conditions).toEqual(['blinded']);
});
});
describe('reorderParticipants', () => {
test('drag downward onto target moves after target (same-init tie)', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10), p('b', 10), p('c', 10)];
const newEnc = await reorderParticipants(enc(ps), 'a', 'c', ctx);
// drag a downward onto c: remove a → [b,c], insert after c → [b,c,a]
expect(newEnc.participants.map(x => x.id)).toEqual(['b', 'c', 'a']);
});
test('cross-init drag blocked (no-op)', async () => {
const { storage, ctx } = mockCtx();
const e = enc([p('a', 10), p('b', 5)]);
const newEnc = await reorderParticipants(e, 'a', 'b', ctx);
expect(newEnc).toBe(e); // same ref = no write
expect(storage.calls.filter(c => c.fn === 'updateDoc')).toHaveLength(0);
});
});
describe('endEncounter', () => {
test('resets all combat state', async () => {
const { ctx } = mockCtx();
const e = enc([p('a', 10)], {
isStarted: true, round: 5, currentTurnParticipantId: 'a', turnOrderIds: ['a'],
});
const newEnc = await endEncounter(e, ctx);
expect(newEnc.isStarted).toBe(false);
expect(newEnc.round).toBe(0);
expect(newEnc.currentTurnParticipantId).toBe(null);
expect(newEnc.turnOrderIds).toEqual([]);
});
});
describe('computeTurnOrderAfterRemoval', () => {
test('not started = empty', () => {
const out = computeTurnOrderAfterRemoval(enc([]), 'a', []);
expect(out).toEqual({});
});
test('removing non-current: no turnOrderIds patch (1-list syncs at call site)', () => {
const e = enc([], { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'b' });
const out = computeTurnOrderAfterRemoval(e, 'a', []);
// 1-list: removal syncs turnOrderIds via participants[] at call site.
// Helper only handles current-advance. Non-current = empty patch.
expect(out).toEqual({});
});
});
describe('addParticipant', () => {
test('appends participant', async () => {
const { ctx } = mockCtx();
const np = p('z', 7);
const newEnc = await addParticipant(enc([p('a', 10)]), np, ctx);
expect(newEnc.participants.map(x => x.id)).toEqual(['a', 'z']);
});
test('rejects duplicate id (skip-bug root cause)', async () => {
// Two participants with same id → togglePause resume rebuilds order with
// dup id twice → nextTurn gets stuck repeating that id forever.
// Audit found this in 100-round replay (addParticipant fired while paused
// because nextTurn threw, loop spun, same totalTurns %10 → re-added).
const { ctx } = mockCtx();
const existing = p('x', 5);
const dup = makeParticipant({ id: 'x', name: 'x2', type: 'monster', initiative: 10, maxHp: 100, currentHp: 100 });
await expect(addParticipant(enc([p('a', 10), existing]), dup, ctx)).rejects.toThrow();
});
});
+292
View File
@@ -0,0 +1,292 @@
// Combat integrity test: replay exact op sequence through async turn.js,
// assert rotation + state invariants per round. This IS the test the audit
// was supposed to be. Deterministic (seeded RNG). RED on current code = BUG-5.
//
// Mirrors scripts/replay-combat.js op order:
// damage, heal (cleric), conditions, toggleActive, deathSave,
// removeParticipant, addParticipant, updateParticipant, pause/resume,
// reorderParticipants, revive-between-rounds.
//
// Rewritten for async turn.js API: funcs are awaited, take ctx, write own logs.
const shared = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers');
const {
makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
startEncounter, nextTurn, togglePause,
addParticipant, updateParticipant, removeParticipant,
toggleParticipantActive, applyHpChange, deathSave,
toggleCondition, reorderParticipants, endEncounter,
} = shared;
// ---- seeded RNG (deterministic, reproducible) ----
let _seed = 12345;
function rand() {
// LCG
_seed = (_seed * 1103515245 + 12345) & 0x7fffffff;
return _seed / 0x7fffffff;
}
const rnd = (n) => Math.floor(rand() * n);
const pick = (arr) => arr[rnd(arr.length)];
const CONDITIONS = [
'alchemist_fire','bardic_inspiration','blinded','charmed','deafened',
'exhaustion','frightened','grappled','grazed','incapacitated',
'invisible','paralyzed','petrified','poisoned','prone','restrained',
'sapped','shield','slowed','stunned','unconscious','vexed',
];
// Custom (non-built-in) condition ids: DM-added freeform strings.
// toggleCondition must accept ANY string (UI custom-condition contract).
const CUSTOM_CONDITIONS = ['hexed', 'rager', 'marked_for_death', '🛡️blessed'];
function p(id, init, extra = {}) {
return makeParticipant({
id, name: id, type: 'monster',
initiative: init, maxHp: 200, currentHp: 200,
...extra,
});
}
function setupEncounter() {
const ps = [
buildCharacterParticipant({ id:'c1', name:'Fighter', defaultMaxHp:200, defaultInitMod:2 }).participant,
buildCharacterParticipant({ id:'c2', name:'Cleric', defaultMaxHp:180, defaultInitMod:1 }).participant,
buildCharacterParticipant({ id:'c3', name:'Rogue', defaultMaxHp:160, defaultInitMod:3 }).participant,
buildMonsterParticipant({ name:'Goblin1', maxHp:100, initMod:2 }).participant,
buildMonsterParticipant({ name:'Goblin2', maxHp:100, initMod:2 }).participant,
buildMonsterParticipant({ name:'OrcBoss', maxHp:500, initMod:1 }).participant,
buildMonsterParticipant({ name:'Wolf', maxHp:120, initMod:3 }).participant,
buildMonsterParticipant({ name:'Merchant', maxHp:150, initMod:0, asNpc:true }).participant,
];
// give deterministic ids to monsters for assertions
const idMap = { Goblin1:'m1', Goblin2:'m2', OrcBoss:'m3', Wolf:'m4', Merchant:'n1' };
ps.forEach((part) => { if (idMap[part.name]) part.id = idMap[part.name]; });
return {
name: 'combat-test', participants: ps,
isStarted: false, isPaused: false,
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
};
}
function currentParticipant(e) {
if (!e.currentTurnParticipantId) return null;
return (e.participants || []).find(x => x.id === e.currentTurnParticipantId) || null;
}
describe('combat integrity (100 rounds, full op coverage)', () => {
jest.setTimeout(30000);
const ROUNDS = 100;
const violations = [];
test('every round visits each active participant exactly once', async () => {
const { ctx } = mockCtx();
_seed = 12345; // reset for reproducibility
let e = setupEncounter();
e = await startEncounter(e, ctx);
let totalTurns = 0;
let lastPaused = false;
let lastReorder = 0;
let reinforcementsAdded = 0;
// built-ins first, then custom (proves arbitrary string accepted)
const condQueue = [...CONDITIONS, ...CUSTOM_CONDITIONS];
for (let roundN = 1; roundN <= ROUNDS; roundN++) {
const startRound = e.round;
const seenThisRound = [];
const cap = (e.participants.length + 2) * 2;
let guard = 0;
while (e.round === startRound && guard < cap) {
// resume if paused (must precede nextTurn)
if (lastPaused) { e = await togglePause(e, ctx); lastPaused = false; }
// advance
try {
e = await nextTurn(e, ctx);
} catch (err) {
violations.push({ round: roundN, type: 'nextTurn-throws', msg: err.message });
break;
}
totalTurns++;
// only count if turn belongs to THIS round (no wrap)
if (e.round === startRound) seenThisRound.push(e.currentTurnParticipantId);
const actor = currentParticipant(e);
// 1. damage
if (actor) {
const foes = e.participants.filter(
part => part.id !== actor.id && part.currentHp > 0 && part.isActive !== false
);
if (foes.length > 0) {
const tgt = pick(foes);
const dmg = 1 + rnd(5);
e = await applyHpChange(e, tgt.id, 'damage', dmg, ctx);
}
}
// 2. heal (cleric)
if (actor && actor.name === 'Cleric' && totalTurns % 2 === 0) {
const wounded = e.participants
.filter(part => part.currentHp > 0 && part.currentHp < part.maxHp && part.isActive !== false)
.sort((a,b)=>(a.currentHp/a.maxHp)-(b.currentHp/b.maxHp));
if (wounded.length > 0) {
const tgt = wounded[0];
const amt = 2 + rnd(5);
e = await applyHpChange(e, tgt.id, 'heal', amt, ctx);
}
}
// 3. conditions
if (condQueue.length > 0) {
const cond = condQueue[0];
const living = e.participants.filter(part => part.currentHp > 0 && part.isActive !== false);
if (living.length > 0) {
const tgt = pick(living);
try { e = await toggleCondition(e, tgt.id, cond, ctx); condQueue.shift(); }
catch (err) { condQueue.shift(); }
}
} else if (totalTurns % 6 === 0) {
const living = e.participants.filter(part => part.currentHp > 0 && part.isActive !== false);
if (living.length > 0) {
const tgt = pick(living);
const cond = pick([...CONDITIONS, ...CUSTOM_CONDITIONS]);
try { e = await toggleCondition(e, tgt.id, cond, ctx); } catch (err) {}
}
}
// 4. toggleParticipantActive
if (totalTurns % 9 === 0) {
const living = e.participants.filter(part => part.currentHp > 0);
if (living.length > 0) {
const tgt = pick(living);
try { e = await toggleParticipantActive(e, tgt.id, ctx); } catch (err) {}
}
}
// 5. deathSave
if (actor && actor.status === 'dying' && actor.type !== 'npc') {
try { const r = await deathSave(e, actor.id, 'fail', ctx); e = r.enc; } catch (err) {}
}
// 6. removeParticipant (monsters/NPC only; dead PCs stay until DM removes manually)
if (totalTurns % 5 === 0) {
const dead = e.participants.find(part => (part.status === 'dead' || part.currentHp <= 0) && (part.type === 'npc' || part.name.startsWith('Goblin') || part.name === 'OrcBoss' || part.name === 'Wolf'));
if (dead) { try { e = await removeParticipant(e, dead.id, ctx); } catch (err) {} }
}
// 7. addParticipant
if (totalTurns % 10 === 0 && reinforcementsAdded < 4) {
const spec = pick([
{ name:`Reinforce${reinforcementsAdded+1}`, maxHp:120, initMod:1 },
{ name:`Summon${reinforcementsAdded+1}`, maxHp:80, initMod:4 },
]);
const built = buildMonsterParticipant(spec).participant;
try { e = await addParticipant(e, built, ctx); reinforcementsAdded++; } catch (err) {}
}
// 8. updateParticipant
if (totalTurns % 7 === 0) {
const living = e.participants.filter(part => part.currentHp > 0);
if (living.length > 0) {
const tgt = pick(living);
try { e = await updateParticipant(e, tgt.id, { notes:`edited@turn${totalTurns}` }, ctx); } catch (err) {}
}
}
// 9. pause
if (totalTurns % 12 === 0 && !lastPaused) { e = await togglePause(e, ctx); lastPaused = true; }
// 10. reorderParticipants (mirror replay's buggy signature usage — swallowed no-op)
if (totalTurns % 8 === 0 && lastReorder !== totalTurns) {
const living = e.participants.filter(part => part.currentHp > 0 && part.isActive !== false);
if (living.length >= 2) {
const tgt = living[0];
const newInit = (tgt.initiative || 0) + 1;
try {
const reordered = [...e.participants].map(part => part.id === tgt.id ? { ...part, initiative: newInit } : part);
e = await reorderParticipants(e, reordered); // array as draggedId throws → swallowed
lastReorder = totalTurns;
} catch (err) {}
}
}
guard++;
if (!e.isStarted) break;
}
if (!e.isStarted) break;
// === per-round invariants ===
const uniq = new Set(seenThisRound);
if (uniq.size !== seenThisRound.length) {
violations.push({ round: roundN, type: 'rotation-dupe',
seen: seenThisRound.map(id => e.participants.find(part=>part.id===id)?.name||id) });
}
// turnOrderIds no dup
const orderUniq = new Set(e.turnOrderIds);
if (orderUniq.size !== e.turnOrderIds.length) {
violations.push({ round: roundN, type: 'turnOrder-dup-id', order: e.turnOrderIds });
}
// currentTurn valid. It may be inactive immediately after DM toggles
// current actor inactive; toggle-active is status edit, not turn advance.
// Next Turn is responsible for skipping inactive current.
if (e.currentTurnParticipantId) {
const ct = e.participants.find(part => part.id === e.currentTurnParticipantId);
if (!ct) violations.push({ round: roundN, type: 'currentTurn-missing' });
}
// HP bounds
for (const part of e.participants) {
if (typeof part.currentHp !== 'number' || isNaN(part.currentHp) || part.currentHp < 0 || part.currentHp > part.maxHp) {
violations.push({ round: roundN, type: 'hp-invalid', id: part.id, hp: part.currentHp, max: part.maxHp });
}
// custom conditions persisted as raw strings (not dropped)
for (const c of (part.conditions || [])) {
if (typeof c !== 'string' || !c) {
violations.push({ round: roundN, type: 'condition-not-string', id: part.id, c });
}
}
}
// custom (freeform) conditions must survive toggle round-trip
// (presence verified via condition-not-string invariant above;
// queue drains them through toggleCondition which proves acceptance)
// revive dead between rounds
const dead = e.participants.filter(part => part.currentHp <= 0 || part.isActive === false);
for (const d of dead) {
try {
if (d.isActive === false) e = await toggleParticipantActive(e, d.id, ctx);
e = await applyHpChange(e, d.id, 'heal', d.maxHp, ctx);
} catch (err) {}
}
}
// Report
if (violations.length > 0) {
const byType = {};
violations.forEach(v => { byType[v.type] = (byType[v.type]||0) + 1; });
const summary = Object.entries(byType).sort((a,b)=>b[1]-a[1]).map(([k,n])=>`${n}x ${k}`).join(', ');
const first5 = violations.slice(0,5).map(v => `r${v.round} ${v.type}${v.seen?': '+JSON.stringify(v.seen):''}${v.order?': '+JSON.stringify(v.order):''}${v.msg?': '+v.msg:''}`).join('\n ');
// dump full state for first dupe for triage
throw new Error(`combat integrity violations: ${violations.length}\n ${summary}\n first 5:\n ${first5}`);
}
expect(violations).toHaveLength(0);
});
// Custom (freeform) condition strings: UI contract.
// toggleCondition must accept ANY string, not just built-ins.
test('custom condition strings survive add/remove round-trip', async () => {
const { ctx } = mockCtx();
let e = setupEncounter();
e = await startEncounter(e, ctx);
const tgt = e.participants[0].id;
// add custom (not in built-ins)
e = await toggleCondition(e, tgt, 'hexed', ctx);
let part = e.participants.find(x => x.id === tgt);
expect(part.conditions).toContain('hexed');
// emoji-bearing custom id
e = await toggleCondition(e, tgt, '🛡️blessed', ctx);
part = e.participants.find(x => x.id === tgt);
expect(part.conditions).toContain('🛡️blessed');
// remove
e = await toggleCondition(e, tgt, 'hexed', ctx);
part = e.participants.find(x => x.id === tgt);
expect(part.conditions).not.toContain('hexed');
expect(part.conditions).toContain('🛡️blessed');
});
});
+85
View File
@@ -0,0 +1,85 @@
'use strict';
const shared = require('@ttrpg/shared');
const { makeParticipant, startEncounter, nextTurn, applyHpChange, deathSave } = shared;
const { mockCtx } = require('./_helpers');
function pc(id, init, extra = {}) {
return makeParticipant({
id,
name: id,
type: 'character',
initiative: init,
maxHp: 100,
currentHp: 100,
...extra,
});
}
function enc(ps) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
}
const byId = (e, id) => e.participants.find(p => p.id === id);
describe('death state: participant remains in initiative and encounter', () => {
test('dropping to 0 keeps PC active, in participants, and in turnOrderIds', async () => {
const { ctx } = mockCtx();
let e = enc([pc('a', 20), pc('b', 15), pc('c', 10)]);
e = await startEncounter(e, ctx);
const orderBefore = e.turnOrderIds.slice();
e = await applyHpChange(e, 'b', 'damage', 100, ctx);
expect(byId(e, 'b').status).toBe('dying');
expect(byId(e, 'b').isActive).not.toBe(false);
expect(e.participants.map(p => p.id)).toContain('b');
expect(e.turnOrderIds).toEqual(orderBefore);
});
test('dead PC remains in participants and turnOrderIds after three failures', async () => {
const { ctx } = mockCtx();
let e = enc([pc('a', 20), pc('b', 15), pc('c', 10)]);
e = await startEncounter(e, ctx);
const orderBefore = e.turnOrderIds.slice();
e = await applyHpChange(e, 'b', 'damage', 100, ctx);
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
expect(byId(e, 'b').status).toBe('dead');
expect(byId(e, 'b').isActive).not.toBe(false);
expect(e.participants.map(p => p.id)).toContain('b');
expect(e.turnOrderIds).toEqual(orderBefore);
});
test('nextTurn may still land on dead PC because DM/tool events may matter', async () => {
const { ctx } = mockCtx();
let e = enc([pc('a', 20), pc('b', 15), pc('c', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'b', 'damage', 100, ctx);
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
e = await nextTurn(e, ctx);
expect(e.currentTurnParticipantId).toBe('b');
expect(byId(e, 'b').status).toBe('dead');
});
test('deathSave rejected/no-op once dead', async () => {
const { ctx } = mockCtx();
let e = enc([pc('a', 20), pc('b', 15)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'b', 'damage', 100, ctx);
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
const before = byId(e, 'b');
try { e = (await deathSave(e, 'b', 'fail', ctx)).enc; } catch (err) {}
expect(byId(e, 'b')).toMatchObject(before);
});
});
+334
View File
@@ -0,0 +1,334 @@
'use strict';
const {
makeParticipant,
startEncounter,
applyHpChange,
deathSave,
stabilizeParticipant,
reviveParticipant,
} = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers');
function pc(id, extra = {}) {
return makeParticipant({
id,
name: id,
type: 'character',
initiative: 10,
maxHp: 100,
currentHp: 100,
...extra,
});
}
function monster(id, extra = {}) {
return makeParticipant({
id,
name: id,
type: 'monster',
initiative: 10,
maxHp: 100,
currentHp: 100,
...extra,
});
}
function enc(participants, extra = {}) {
return {
id: 'e1',
name: 'death-save-test',
participants,
isStarted: false,
isPaused: false,
round: 0,
currentTurnParticipantId: null,
turnOrderIds: [],
...extra,
};
}
async function startedAtZero() {
const { ctx } = mockCtx();
let e = enc([pc('a')]);
e = await startEncounter(e, ctx);
const originalTurnOrderIds = [...e.turnOrderIds];
e = await applyHpChange(e, 'a', 'damage', 100, ctx);
return { e, ctx, originalTurnOrderIds };
}
const part = e => e.participants.find(p => p.id === 'a');
describe('death saves: D&D 5e status state machine', () => {
test('damage drops character to dying without removal or deactivation', async () => {
const { e, originalTurnOrderIds } = await startedAtZero();
const p = part(e);
expect(p.currentHp).toBe(0);
expect(p.status).toBe('dying');
expect(p.deathSaveSuccesses).toBe(0);
expect(p.deathSaveFailures).toBe(0);
expect(p.isActive).not.toBe(false);
expect(e.participants.map(x => x.id)).toContain('a');
expect(e.turnOrderIds).toEqual(originalTurnOrderIds);
});
test('damage drops non-NPC monster to dead/inactive, not dying', async () => {
const { ctx } = mockCtx();
let e = enc([monster('a')]);
e = await startEncounter(e, ctx);
const orderBefore = [...e.turnOrderIds];
e = await applyHpChange(e, 'a', 'damage', 100, ctx);
expect(part(e).status).toBe('dead');
expect(part(e).deathSaveSuccesses).toBe(0);
expect(part(e).deathSaveFailures).toBe(0);
expect(part(e).conditions).not.toContain('unconscious');
expect(part(e).isActive).toBe(false);
expect(e.turnOrderIds).toEqual(orderBefore);
});
test('dead non-NPC monster with stale active flag repairs to inactive', async () => {
const { ctx } = mockCtx();
let e = enc([monster('a', { currentHp: 0, status: 'dead', isActive: true })]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 1, ctx);
expect(part(e).status).toBe('dead');
expect(part(e).isActive).toBe(false);
});
test('damage drops NPC to dying', async () => {
const { ctx } = mockCtx();
let e = enc([monster('a', { type: 'npc' })]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 100, ctx);
expect(part(e).type).toBe('npc');
expect(part(e).status).toBe('dying');
expect(part(e).conditions).toContain('unconscious');
});
test('three failures immediately makes dead, resets counters, keeps participant and turn order', async () => {
let { e, ctx, originalTurnOrderIds } = await startedAtZero();
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
expect(part(e).status).toBe('dying');
expect(part(e).deathSaveFailures).toBe(1);
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
expect(part(e).status).toBe('dying');
expect(part(e).deathSaveFailures).toBe(2);
const res = await deathSave(e, 'a', 'fail', ctx);
e = res.enc;
expect(res.status).toBe('dead');
expect(part(e).status).toBe('dead');
expect(part(e).currentHp).toBe(0);
expect(part(e).deathSaveSuccesses).toBe(0);
expect(part(e).deathSaveFailures).toBe(0);
expect(part(e).isActive).not.toBe(false);
expect(e.participants.map(x => x.id)).toContain('a');
expect(e.turnOrderIds).toEqual(originalTurnOrderIds);
});
test('three successes immediately makes stable and resets counters', async () => {
let { e, ctx } = await startedAtZero();
e = (await deathSave(e, 'a', 'success', ctx)).enc;
e = (await deathSave(e, 'a', 'success', ctx)).enc;
const res = await deathSave(e, 'a', 'success', ctx);
e = res.enc;
expect(res.status).toBe('stable');
expect(part(e).status).toBe('stable');
expect(part(e).currentHp).toBe(0);
expect(part(e).deathSaveSuccesses).toBe(0);
expect(part(e).deathSaveFailures).toBe(0);
});
test('successes and failures are independent before terminal transition', async () => {
let { e, ctx } = await startedAtZero();
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
e = (await deathSave(e, 'a', 'success', ctx)).enc;
e = (await deathSave(e, 'a', 'success', ctx)).enc;
expect(part(e).status).toBe('dying');
expect(part(e).deathSaveFailures).toBe(2);
expect(part(e).deathSaveSuccesses).toBe(2);
e = (await deathSave(e, 'a', 'success', ctx)).enc;
expect(part(e).status).toBe('stable');
expect(part(e).deathSaveFailures).toBe(0);
expect(part(e).deathSaveSuccesses).toBe(0);
});
test('nat1 adds two failures and can kill', async () => {
let { e, ctx } = await startedAtZero();
e = (await deathSave(e, 'a', 'nat1', ctx)).enc;
expect(part(e).status).toBe('dying');
expect(part(e).deathSaveFailures).toBe(2);
e = (await deathSave(e, 'a', 'nat1', ctx)).enc;
expect(part(e).status).toBe('dead');
expect(part(e).deathSaveFailures).toBe(0);
});
test('nat1 at one failure immediately kills', async () => {
let { e, ctx } = await startedAtZero();
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
e = (await deathSave(e, 'a', 'nat1', ctx)).enc;
expect(part(e).status).toBe('dead');
expect(part(e).deathSaveFailures).toBe(0);
});
test('nat20 restores to 1 HP conscious and resets counters', async () => {
let { e, ctx } = await startedAtZero();
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
e = (await deathSave(e, 'a', 'success', ctx)).enc;
e = (await deathSave(e, 'a', 'nat20', ctx)).enc;
expect(part(e).status).toBe('conscious');
expect(part(e).currentHp).toBe(1);
expect(part(e).deathSaveSuccesses).toBe(0);
expect(part(e).deathSaveFailures).toBe(0);
});
test('damage while dying adds one failure', async () => {
let { e, ctx } = await startedAtZero();
e = await applyHpChange(e, 'a', 'damage', 1, ctx);
expect(part(e).status).toBe('dying');
expect(part(e).deathSaveFailures).toBe(1);
});
test('critical damage while dying adds two failures', async () => {
let { e, ctx } = await startedAtZero();
e = await applyHpChange(e, 'a', 'damage', 1, { isCriticalHit: true }, ctx);
expect(part(e).status).toBe('dying');
expect(part(e).deathSaveFailures).toBe(2);
});
test('damage while stable returns to dying and applies failure', async () => {
let { e, ctx } = await startedAtZero();
e = (await deathSave(e, 'a', 'success', ctx)).enc;
e = (await deathSave(e, 'a', 'success', ctx)).enc;
e = (await deathSave(e, 'a', 'success', ctx)).enc;
expect(part(e).status).toBe('stable');
e = await applyHpChange(e, 'a', 'damage', 1, ctx);
expect(part(e).status).toBe('dying');
expect(part(e).deathSaveSuccesses).toBe(0);
expect(part(e).deathSaveFailures).toBe(1);
});
test('healing while dying or stable makes conscious and clears counters', async () => {
let { e, ctx } = await startedAtZero();
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
e = await applyHpChange(e, 'a', 'heal', 10, ctx);
expect(part(e).status).toBe('conscious');
expect(part(e).currentHp).toBe(10);
expect(part(e).deathSaveFailures).toBe(0);
e = await applyHpChange(e, 'a', 'damage', 10, ctx);
e = (await deathSave(e, 'a', 'success', ctx)).enc;
e = (await deathSave(e, 'a', 'success', ctx)).enc;
e = (await deathSave(e, 'a', 'success', ctx)).enc;
expect(part(e).status).toBe('stable');
e = await applyHpChange(e, 'a', 'heal', 5, ctx);
expect(part(e).status).toBe('conscious');
expect(part(e).currentHp).toBe(5);
expect(part(e).deathSaveSuccesses).toBe(0);
expect(part(e).deathSaveFailures).toBe(0);
});
test('normal healing does not revive dead', async () => {
let { e, ctx } = await startedAtZero();
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
expect(part(e).status).toBe('dead');
try { e = await applyHpChange(e, 'a', 'heal', 10, ctx); } catch (err) {}
expect(part(e).status).toBe('dead');
expect(part(e).currentHp).toBe(0);
});
test('stabilize sets stable, hp zero, counters zero', async () => {
let { e, ctx } = await startedAtZero();
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
e = await stabilizeParticipant(e, 'a', ctx);
expect(part(e).status).toBe('stable');
expect(part(e).currentHp).toBe(0);
expect(part(e).deathSaveSuccesses).toBe(0);
expect(part(e).deathSaveFailures).toBe(0);
});
test('negative heal acts as damage and can drop to dying', async () => {
const { ctx } = mockCtx();
let e = enc([pc('a', { currentHp: 1 })]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'heal', -1, ctx);
expect(part(e).currentHp).toBe(0);
expect(part(e).status).toBe('dying');
});
test('negative damage acts as healing', async () => {
let { e, ctx } = await startedAtZero();
e = await applyHpChange(e, 'a', 'damage', -5, ctx);
expect(part(e).currentHp).toBe(5);
expect(part(e).status).toBe('conscious');
});
test('revive moves dead participant to 0 HP stable/unconscious', async () => {
let { e, ctx } = await startedAtZero();
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
expect(part(e).status).toBe('dead');
e = await reviveParticipant(e, 'a', ctx);
expect(part(e).currentHp).toBe(0);
expect(part(e).status).toBe('stable');
expect(part(e).deathSaveSuccesses).toBe(0);
expect(part(e).deathSaveFailures).toBe(0);
expect(part(e).conditions).toContain('unconscious');
expect(part(e).isActive).toBe(true);
});
test('death save outside dying state is rejected or no-op', async () => {
const { ctx } = mockCtx();
let e = enc([pc('a')]);
e = await startEncounter(e, ctx);
try { e = (await deathSave(e, 'a', 'fail', ctx)).enc; } catch (err) {}
expect(part(e).status).toBe('conscious');
expect(part(e).deathSaveFailures).toBe(0);
});
});
+195
View File
@@ -0,0 +1,195 @@
// Undo roundtrip for DEATH paths: damage to 0 HP (dying/dead), deathSave,
// stabilize, revive, dead-monster auto-deactivate. Each must restore full
// prior state including status, death-save counters, conditions (unconscious),
// and isActive.
//
// Pattern: apply op (writes encounter + log) -> read last log -> expandUndo
// against newEnc -> merge -> deepEqual original snapshot.
const shared = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers');
const { expandUndo } = shared;
const {
makeParticipant, startEncounter,
applyHpChange, deathSave, stabilizeParticipant, reviveParticipant,
} = shared;
function char(id, hp = 100) {
return makeParticipant({
id, name: id, type: 'character',
initiative: 10, maxHp: hp, currentHp: hp,
});
}
function mon(id, hp = 100) {
return makeParticipant({
id, name: id, type: 'monster',
initiative: 10, maxHp: hp, currentHp: hp,
});
}
function enc(ps) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
}
const snap = (e) => JSON.parse(JSON.stringify(e));
function lastUndo(storage, enc) {
const logs = storage.logs();
const last = logs[logs.length - 1];
expect(last.undo).toBeTruthy();
const expanded = expandUndo(last, enc);
expect(expanded).toBeTruthy();
return expanded.updates;
}
describe('death-path undo roundtrip', () => {
test('damage to 0 HP (dying) undo restores status + unconscious condition', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a')]);
e = await startEncounter(e, ctx);
const before = snap(e);
const newEnc = await applyHpChange(e, 'a', 'damage', 100, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.status).toBe('dying');
expect(p.conditions).toContain('unconscious');
const undo = lastUndo(storage, newEnc);
const after = { ...newEnc, ...undo };
expect(snap(after)).toEqual(before);
});
test('damage while dying undo restores status + failures + conditions', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a')]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
const before = snap(e);
const newEnc = await applyHpChange(e, 'a', 'damage', 5, ctx); // +1 fail
expect(newEnc.participants.find(x => x.id === 'a').deathSaveFailures).toBe(1);
const undo = lastUndo(storage, newEnc);
const after = { ...newEnc, ...undo };
expect(snap(after)).toEqual(before);
});
test('damage while stable undo restores stable + reset failures', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a')]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
e = await stabilizeParticipant(e, 'a', ctx); // -> stable
const before = snap(e);
const newEnc = await applyHpChange(e, 'a', 'damage', 1, ctx); // -> dying 1 fail
const undo = lastUndo(storage, newEnc);
const after = { ...newEnc, ...undo };
expect(snap(after)).toEqual(before);
});
test('massive damage (dead) undo restores conscious', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a', 50)]);
e = await startEncounter(e, ctx);
const before = snap(e);
const newEnc = await applyHpChange(e, 'a', 'damage', 200, ctx);
expect(newEnc.participants.find(x => x.id === 'a').status).toBe('dead');
const undo = lastUndo(storage, newEnc);
const after = { ...newEnc, ...undo };
expect(snap(after)).toEqual(before);
});
test('dead monster auto-deactivate undo restores active', async () => {
const { storage, ctx } = mockCtx();
let e = enc([mon('a')]);
e = await startEncounter(e, ctx);
// first make it dead+inactive
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dead + inactive
// second damage at 0 while dead: no-op (returns same enc) but if it had
// repaired a stale active-dead monster it writes deactivate_dead_monster.
// Force the repair path: set stale isActive=true then damage.
e = { ...e, participants: e.participants.map(p => p.id === 'a' ? { ...p, isActive: true } : p) };
const before = snap(e); // stale active-dead = state right before the op
const newEnc = await applyHpChange(e, 'a', 'damage', 1, ctx);
expect(newEnc.participants.find(x => x.id === 'a').isActive).toBe(false);
const undo = lastUndo(storage, newEnc);
const after = { ...newEnc, ...undo };
expect(snap(after)).toEqual(before);
});
test('deathSave fail undo restores dying + counters + conditions', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a')]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
const before = snap(e);
const r = await deathSave(e, 'a', 'fail', ctx);
expect(r.enc.participants.find(x => x.id === 'a').deathSaveFailures).toBe(1);
const undo = lastUndo(storage, r.enc);
const after = { ...r.enc, ...undo };
expect(snap(after)).toEqual(before);
});
test('deathSave nat1 (2 fails) undo restores prior counters', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a')]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
const before = snap(e);
const r = await deathSave(e, 'a', 'nat1', ctx);
expect(r.enc.participants.find(x => x.id === 'a').deathSaveFailures).toBe(2);
const undo = lastUndo(storage, r.enc);
const after = { ...r.enc, ...undo };
expect(snap(after)).toEqual(before);
});
test('deathSave nat20 (conscious) undo restores dying + unconscious', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a')]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
const before = snap(e);
const r = await deathSave(e, 'a', 'nat20', ctx);
expect(r.enc.participants.find(x => x.id === 'a').status).toBe('conscious');
expect(r.enc.participants.find(x => x.id === 'a').currentHp).toBe(1);
const undo = lastUndo(storage, r.enc);
const after = { ...r.enc, ...undo };
expect(snap(after)).toEqual(before);
});
test('deathSave to stable undo restores dying + unconscious', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a')]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
e = await deathSave(e, 'a', 'success', ctx).then(x => x.enc);
e = await deathSave(e, 'a', 'success', ctx).then(x => x.enc);
const before = snap(e); // 2 successes, dying
const r = await deathSave(e, 'a', 'success', ctx); // -> stable
expect(r.enc.participants.find(x => x.id === 'a').status).toBe('stable');
const undo = lastUndo(storage, r.enc);
const after = { ...r.enc, ...undo };
expect(snap(after)).toEqual(before);
});
test('stabilize undo restores dying + unconscious + counters', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a')]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
e = await deathSave(e, 'a', 'fail', ctx).then(x => x.enc); // 1 fail
const before = snap(e);
const newEnc = await stabilizeParticipant(e, 'a', ctx);
expect(newEnc.participants.find(x => x.id === 'a').status).toBe('stable');
const undo = lastUndo(storage, newEnc);
const after = { ...newEnc, ...undo };
expect(snap(after)).toEqual(before);
});
test('revive undo restores dead + isActive + counters', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a')]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 200, ctx); // -> dead (active)
const before = snap(e);
const newEnc = await reviveParticipant(e, 'a', ctx);
expect(newEnc.participants.find(x => x.id === 'a').status).toBe('stable');
const undo = lastUndo(storage, newEnc);
const after = { ...newEnc, ...undo };
expect(snap(after)).toEqual(before);
});
});
+52
View File
@@ -0,0 +1,52 @@
// Toggle-active semantics guard.
// Design: toggle active is a status edit, NOT a turn action.
// Deactivating current does not advance turn or increment round. DM clicks
// Next Turn explicitly; Next Turn skips inactive participants.
'use strict';
const { makeParticipant, nextTurn, toggleParticipantActive } = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers');
function p(id, init, extra = {}) {
return makeParticipant({ id, name: id, type: 'monster',
initiative: init, maxHp: 100, currentHp: 100, ...extra });
}
function enc(ps, extra = {}) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra };
}
describe('toggle-active is not a turn advance', () => {
test('mid-round: deactivating current leaves current unchanged', async () => {
const { ctx } = mockCtx();
const e = enc([p('a',10),p('b',5),p('c',3)], { isStarted:true, round:1,
turnOrderIds:['a','b','c'], currentTurnParticipantId:'a' });
const deact = await toggleParticipantActive(e, 'a', ctx);
expect(deact.currentTurnParticipantId).toBe('a');
expect(deact.round).toBe(1);
expect(deact.participants.find(p => p.id === 'a').isActive).toBe(false);
});
test('nextTurn after deactivating current skips inactive participant', async () => {
const { ctx } = mockCtx();
const e = enc([p('a',10),p('b',5),p('c',3)], { isStarted:true, round:1,
turnOrderIds:['a','b','c'], currentTurnParticipantId:'a' });
const deact = await toggleParticipantActive(e, 'a', ctx);
const next = await nextTurn(deact, ctx);
expect(next.currentTurnParticipantId).toBe('b');
expect(next.round).toBe(1);
});
test('wrap: deactivating current does not increment round; nextTurn does', async () => {
const { ctx } = mockCtx();
const e = enc([p('a',10),p('b',5),p('c',3)], { isStarted:true, round:2,
turnOrderIds:['a','b','c'], currentTurnParticipantId:'c' });
const deact = await toggleParticipantActive(e, 'c', ctx);
expect(deact.currentTurnParticipantId).toBe('c');
expect(deact.round).toBe(2);
const next = await nextTurn(deact, ctx);
expect(next.currentTurnParticipantId).toBe('a');
expect(next.round).toBe(3);
});
});
+185
View File
@@ -0,0 +1,185 @@
// Generic (non-5e) ruleset: no death saves, negative HP allowed, down status.
const shared = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers');
const {
buildCharacterParticipant, buildMonsterParticipant,
startEncounter, applyHpChange, deathSave, markDead, reviveParticipant,
} = shared;
function char(id, hp = 100) {
const { participant } = buildCharacterParticipant({ id: `orig-${id}`, name: id, defaultMaxHp: hp, defaultInitMod: 2 });
participant.id = id;
return participant;
}
function mon(id, hp = 100) {
const { participant } = buildMonsterParticipant({ name: id, maxHp: hp, initMod: 2 });
participant.id = id;
return participant;
}
function enc(ps, ruleset = 'generic') {
return {
name: 't', participants: ps, isStarted: false, isPaused: false,
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
ruleset,
};
}
const snap = (e) => JSON.parse(JSON.stringify(e));
describe('generic ruleset', () => {
test('damage past 0 = negative HP, status down (character)', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
const newEnc = await applyHpChange(e, 'a', 'damage', 15, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.currentHp).toBe(-5);
expect(p.status).toBe('down');
});
test('damage exactly to 0 = status down', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
const newEnc = await applyHpChange(e, 'a', 'damage', 10, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.currentHp).toBe(0);
expect(p.status).toBe('down');
});
test('heal from negative = conscious', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 15, ctx); // -5
const newEnc = await applyHpChange(e, 'a', 'heal', 10, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.currentHp).toBe(5);
expect(p.status).toBe('conscious');
});
test('heal caps at maxHp', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 5, ctx); // 5
const newEnc = await applyHpChange(e, 'a', 'heal', 20, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.currentHp).toBe(10);
expect(p.status).toBe('conscious');
});
test('monster death = auto-inactive (generic)', async () => {
const { ctx } = mockCtx();
let e = enc([mon('m', 10)]);
e = await startEncounter(e, ctx);
const newEnc = await applyHpChange(e, 'm', 'damage', 20, ctx);
const p = newEnc.participants.find(x => x.id.startsWith('m'));
expect(p.currentHp).toBe(-10);
expect(p.status).toBe('dead');
expect(p.isActive).toBe(false);
});
test('monster at exactly 0 = dead + inactive (generic)', async () => {
const { ctx } = mockCtx();
let e = enc([mon('m', 10)]);
e = await startEncounter(e, ctx);
const newEnc = await applyHpChange(e, 'm', 'damage', 10, ctx);
const p = newEnc.participants.find(x => x.id.startsWith('m'));
expect(p.currentHp).toBe(0);
expect(p.status).toBe('dead');
expect(p.isActive).toBe(false);
});
test('deathSave throws in generic mode', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
await expect(deathSave(e, 'a', 'fail', ctx)).rejects.toThrow();
});
test('revive in generic mode: dead→conscious (no stable)', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
e = await markDead(e, 'a', ctx); // dead
const newEnc = await reviveParticipant(e, 'a', ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.status).toBe('conscious');
expect(p.currentHp).toBe(0); // HP unchanged from down
});
test('revive generic reactivates if inactive', async () => {
const { ctx } = mockCtx();
let e = enc([mon('m', 10)]);
e = await startEncounter(e, ctx);
e = await markDead(e, e.participants[0].id, ctx); // dead+inactive
const newEnc = await reviveParticipant(e, e.participants[0].id, ctx);
const p = newEnc.participants[0];
expect(p.status).toBe('conscious');
expect(p.isActive).toBe(true);
});
test('markDead sets status dead (character, stays active)', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
const newEnc = await markDead(e, 'a', ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.status).toBe('dead');
expect(p.isActive).toBe(true);
});
test('markDead on monster = dead + inactive', async () => {
const { ctx } = mockCtx();
let e = enc([mon('m', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'm', 'damage', 5, ctx); // down (5hp)
const newEnc = await markDead(e, e.participants[0].id, ctx);
const p = newEnc.participants[0];
expect(p.status).toBe('dead');
expect(p.isActive).toBe(false);
});
test('markDead on conscious = dead', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
const newEnc = await markDead(e, 'a', ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.status).toBe('dead');
});
test('damage while down = more negative (no death saves)', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 15, ctx); // -5 down
const newEnc = await applyHpChange(e, 'a', 'damage', 5, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.currentHp).toBe(-10);
expect(p.status).toBe('down');
});
test('no unconscious condition auto-added (generic)', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
const newEnc = await applyHpChange(e, 'a', 'damage', 10, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.conditions).not.toContain('unconscious');
});
test('down→conscious clears dead? no, dead is separate', async () => {
// sanity: down heal to conscious, dead untouched
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
const newEnc = await applyHpChange(e, 'a', 'heal', 10, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.status).toBe('conscious');
});
});
+60
View File
@@ -0,0 +1,60 @@
// HP formula roll: parse "2d6+9" style, roll, return total.
const shared = require('@ttrpg/shared');
const { rollHpFormula } = shared;
describe('rollHpFormula', () => {
test('basic NdM', () => {
const r = rollHpFormula('2d6');
expect(r).toBeGreaterThanOrEqual(2);
expect(r).toBeLessThanOrEqual(12);
});
test('NdM+bonus', () => {
const r = rollHpFormula('2d6+9');
expect(r).toBeGreaterThanOrEqual(11);
expect(r).toBeLessThanOrEqual(21);
});
test('NdM-bonus', () => {
const r = rollHpFormula('3d8-2');
expect(r).toBeGreaterThanOrEqual(1);
expect(r).toBeLessThanOrEqual(22);
});
test('single die', () => {
const r = rollHpFormula('1d20');
expect(r).toBeGreaterThanOrEqual(1);
expect(r).toBeLessThanOrEqual(20);
});
test('case insensitive', () => {
const r = rollHpFormula('2D6+5');
expect(r).toBeGreaterThanOrEqual(7);
expect(r).toBeLessThanOrEqual(17);
});
test('whitespace tolerant', () => {
const r = rollHpFormula(' 2d6 + 9 ');
expect(r).toBeGreaterThanOrEqual(11);
expect(r).toBeLessThanOrEqual(21);
});
test('large die', () => {
const r = rollHpFormula('4d12+10');
expect(r).toBeGreaterThanOrEqual(14);
expect(r).toBeLessThanOrEqual(58);
});
test('invalid returns null', () => {
expect(rollHpFormula('abc')).toBeNull();
expect(rollHpFormula('')).toBeNull();
expect(rollHpFormula(null)).toBeNull();
expect(rollHpFormula(undefined)).toBeNull();
expect(rollHpFormula('2d')).toBeNull();
expect(rollHpFormula('d6')).toBeNull();
});
test('zero dice invalid', () => {
expect(rollHpFormula('0d6')).toBeNull();
});
});
+126
View File
@@ -0,0 +1,126 @@
// INVARIANT test: ONE list. turnOrderIds === participants.map(id) always.
// No re-sort after startEncounter. nextTurn follows list order, skipping inactive.
// Drag (reorder) overrides initiative — cross-init drag allowed + reflected.
// Display === rotation by construction (same array).
//
// RED now: current code has two lists (sort on display, frozen turnOrderIds),
// reorder throws on cross-init. Refactor (1-list model) greens these.
//
// New API: mutating funcs are async, take ctx last, write encounter + log
// internally, return newEnc. Chained calls become `e = await fn(e, ctx)`.
// No-ops (reorder blocked) return the same enc ref.
'use strict';
const shared = require('@ttrpg/shared');
const {
makeParticipant,
startEncounter, nextTurn, addParticipant, removeParticipant,
toggleParticipantActive, togglePause, applyHpChange,
reorderParticipants, endEncounter,
} = shared;
const { mockCtx } = require('./_helpers');
function p(id, init, extra = {}) {
return makeParticipant({ id, name: id, type: 'monster',
initiative: init, maxHp: 100, currentHp: 100, ...extra });
}
function enc(ps, extra = {}) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra };
}
describe('1-list model: turnOrderIds === participants.map(id), no re-sort', () => {
test('startEncounter: list = sorted-active participants order', async () => {
const { ctx } = mockCtx();
let e = enc([p('a',3),p('b',10),p('c',5)]);
e = await startEncounter(e, ctx);
expect(e.turnOrderIds).toEqual(['b','c','a']); // 10,5,3
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
});
test('startEncounter: inactive stays in list slot (skipped by nextTurn)', async () => {
const { ctx } = mockCtx();
let e = enc([p('a',10),p('b',5,{isActive:false}),p('c',3)]);
e = await startEncounter(e, ctx);
// 1-list: inactive b stays in slot, nextTurn skips it
expect(e.turnOrderIds).toEqual(['a','b','c']);
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
expect(e.currentTurnParticipantId).toBe('a'); // b inactive, skipped on start
});
test('addParticipant mid-encounter: inserted by init, list synced', async () => {
const { ctx } = mockCtx();
let e = enc([p('a',10),p('c',3)]);
e = await startEncounter(e, ctx); // [a,c]
e = await addParticipant(e, p('b',7), ctx); // insert between a,c
expect(e.turnOrderIds).toEqual(['a','b','c']);
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
});
test('addParticipant: list === participants.map(id) after add', async () => {
const { ctx } = mockCtx();
let e = enc([p('a',10)]);
e = await startEncounter(e, ctx);
e = await addParticipant(e, p('b',5), ctx);
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
});
test('removeParticipant: list synced, order preserved', async () => {
const { ctx } = mockCtx();
let e = enc([p('a',10),p('b',7),p('c',3)]);
e = await startEncounter(e, ctx);
e = await removeParticipant(e, 'b', ctx);
expect(e.turnOrderIds).toEqual(['a','c']);
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
});
test('reorder cross-init: blocked (no-op)', async () => {
const { ctx } = mockCtx();
let e = enc([p('a',10),p('b',7),p('c',3)]);
e = await startEncounter(e, ctx); // [a,b,c]
const newEnc = await reorderParticipants(e, 'c', 'a', ctx); // cross-init
expect(newEnc).toBe(e); // same ref = no write
expect(e.turnOrderIds).toEqual(['a','b','c']); // unchanged
});
test('reorder same-init: within upcoming side of pointer follows new order', async () => {
const { ctx } = mockCtx();
let e = enc([p('a',10),p('b',10),p('c',10),p('d',3)]); // a,b,c tie
e = await startEncounter(e, ctx); // [a,b,c,d], cur=a (idx0)
// Reorder c before b (both upcoming idx1,2). Within same side = allowed.
e = await reorderParticipants(e, 'c', 'b', ctx); // [a,c,b,d], cur=a
expect(e.turnOrderIds).toEqual(['a','c','b','d']);
// walk: a current, next c, next b, next d, wrap a
e = await nextTurn(e, ctx);
expect(e.currentTurnParticipantId).toBe('c');
});
test('toggle inactive: list unchanged (stays in rotation slot)', async () => {
const { ctx } = mockCtx();
let e = enc([p('a',10),p('b',7),p('c',3)]);
e = await startEncounter(e, ctx); // [a,b,c]
e = await toggleParticipantActive(e, 'b', ctx); // b off
expect(e.turnOrderIds).toEqual(['a','b','c']); // b stays in slot
});
test('toggle inactive: nextTurn skips b, visits a→c', async () => {
const { ctx } = mockCtx();
let e = enc([p('a',10),p('b',7),p('c',3)]);
e = await startEncounter(e, ctx); // cur=a
e = await toggleParticipantActive(e, 'b', ctx); // b inactive
e = await nextTurn(e, ctx); // skip b → c
expect(e.currentTurnParticipantId).toBe('c');
});
test('hp death + revive: list unchanged', async () => {
const { ctx } = mockCtx();
let e = enc([p('a',10),p('b',7),p('c',3)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'b', 'damage', 100, ctx); // b dies
expect(e.turnOrderIds).toEqual(['a','b','c']);
e = await applyHpChange(e, 'b', 'heal', 50, ctx); // b revive
expect(e.turnOrderIds).toEqual(['a','b','c']);
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
});
});
+248
View File
@@ -0,0 +1,248 @@
// Logging contract: every mutating combat op writes a log entry to ctx.logPath
// via ctx.storage.addDoc. No-op (no state change) performs NO writes and
// returns the same encounter reference. Display lifecycle ops write the display
// doc, no combat log.
//
// Contract = every combat mutation produces a log entry.
// New shape: mutating funcs are async, take ctx, write encounter (updateDoc) +
// log (addDoc) internally, return newEnc. Logs are read back via storage.logs().
//
// undo payloads now live on the written log entry's `undo_payload.updates`.
'use strict';
const shared = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers');
const {
makeParticipant,
startEncounter, nextTurn, togglePause,
addParticipant, addParticipants, updateParticipant, removeParticipant,
toggleParticipantActive, applyHpChange, deathSave, toggleCondition,
reorderParticipants, endEncounter,
} = shared;
function p(id, init, extra = {}) {
return makeParticipant({ id, name: id, type: 'monster',
initiative: init, maxHp: 100, currentHp: 100, ...extra });
}
function enc(ps, extra = {}) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra };
}
// Assert last written log entry is a well-formed mutation log.
function expectLastLogged(storage) {
const logs = storage.logs();
expect(logs.length).toBeGreaterThan(0);
const last = logs[logs.length - 1];
expect(typeof last.message).toBe('string');
expect(last.message.length).toBeGreaterThan(0);
return last;
}
describe('Logging contract: mutating ops', () => {
let storage, ctx;
beforeEach(() => {
({ storage, ctx } = mockCtx());
});
test('startEncounter logs', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
await startEncounter(e, ctx);
expect(storage.logs()).toHaveLength(1);
expectLastLogged(storage);
});
test('nextTurn logs', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
const started = await startEncounter(e, ctx); // 1 log
await nextTurn(started, ctx); // 2 logs
expect(storage.logs()).toHaveLength(2);
expectLastLogged(storage);
});
test('togglePause does NOT log (lifecycle excluded from undo)', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
const started = await startEncounter(e, ctx);
await togglePause(started, ctx);
expect(storage.logs()).toHaveLength(1); // start only
});
test('addParticipant logs', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
await addParticipant(e, p('d', 5), ctx);
expect(storage.logs()).toHaveLength(1);
expectLastLogged(storage);
});
test('removeParticipant logs', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
await removeParticipant(e, 'b', ctx);
expect(storage.logs()).toHaveLength(1);
expectLastLogged(storage);
});
test('toggleParticipantActive logs', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
await toggleParticipantActive(e, 'b', ctx);
expect(storage.logs()).toHaveLength(1);
expectLastLogged(storage);
});
test('applyHpChange (damage) logs', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
await applyHpChange(e, 'b', 'damage', 10, ctx);
expect(storage.logs()).toHaveLength(1);
expectLastLogged(storage);
});
test('applyHpChange (heal) logs', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
await applyHpChange(e, 'b', 'heal', 5, ctx);
expect(storage.logs()).toHaveLength(1);
expectLastLogged(storage);
});
test('deathSave logs', async () => {
const e = enc([p('a', 10), p('b', 7, { type: 'character' }), p('c', 3)]);
const started = await startEncounter(e, ctx); // 1 log
const dying = await applyHpChange(started, 'b', 'damage', 100, ctx); // 2 logs
const r = await deathSave(dying, 'b', 'fail', ctx); // 3 logs
expect(r.status).toBe('dying');
expect(storage.logs()).toHaveLength(3);
expectLastLogged(storage);
});
test('toggleCondition logs', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
await toggleCondition(e, 'b', 'poisoned', ctx);
expect(storage.logs()).toHaveLength(1);
expectLastLogged(storage);
});
test('reorderParticipants logs (BUG-7)', async () => {
// same-init tie (both 10) for valid reorder (unstarted: no pointer check)
const e2 = enc([p('a', 10), p('x', 10), p('c', 3)]);
await reorderParticipants(e2, 'x', 'a', ctx);
expect(storage.logs()).toHaveLength(1);
expectLastLogged(storage);
});
test('endEncounter logs', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
const started = await startEncounter(e, ctx);
await endEncounter(started, ctx);
expect(storage.logs()).toHaveLength(2);
expectLastLogged(storage);
});
});
describe('Logging contract: no-ops', () => {
let storage, ctx;
beforeEach(() => {
({ storage, ctx } = mockCtx());
});
test('reorder same-id = no-op', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
const newEnc = await reorderParticipants(e, 'a', 'a', ctx);
expect(newEnc).toBe(e); // same ref = no write
expect(storage.logs()).toHaveLength(0);
});
test('reorder cross-init = no-op', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
const newEnc = await reorderParticipants(e, 'a', 'b', ctx);
expect(newEnc).toBe(e); // same ref = no write
expect(storage.logs()).toHaveLength(0);
});
});
describe('Logging undo payloads', () => {
let storage, ctx;
beforeEach(() => {
({ storage, ctx } = mockCtx());
});
test('startEncounter undo restores pre-combat state', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
await startEncounter(e, ctx);
const log = expectLastLogged(storage);
expect(log.undo).toBeDefined();
expect(log.undo.isStarted).toBe(false);
});
test('endEncounter undo restores combat state', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
const started = await startEncounter(e, ctx);
await endEncounter(started, ctx);
const log = expectLastLogged(storage);
expect(log.undo).toBeDefined();
expect(log.undo.isStarted).toBe(true);
});
test('applyHpChange undo restores prior hp', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
const newEnc = await applyHpChange(e, 'b', 'damage', 10, ctx);
const log = expectLastLogged(storage);
expect(log.undo).toBeDefined();
const restored = { ...newEnc, ...shared.expandUndo(log, newEnc).updates };
expect(restored.participants.find(x => x.id === 'b').currentHp).toBe(100);
});
test('reorder undo restores prior order (BUG-7)', async () => {
const e2 = enc([p('a', 10), p('x', 10), p('c', 3)]);
const orig = e2.participants.map(p => p.id);
const newEnc = await reorderParticipants(e2, 'x', 'a', ctx);
const log = expectLastLogged(storage);
expect(log.undo).toBeDefined();
const restored = { ...newEnc, ...shared.expandUndo(log, newEnc).updates };
expect(restored.participants.map(p => p.id)).toEqual(orig);
});
});
describe('Logging: addParticipants + updateParticipant', () => {
let storage, ctx;
beforeEach(() => {
({ storage, ctx } = mockCtx());
});
test('addParticipants logs', async () => {
const e = enc([p('a', 10), p('b', 7)]);
await addParticipants(e, [p('c', 3)], ctx);
expect(storage.logs()).toHaveLength(1);
expectLastLogged(storage);
});
test('updateParticipant (same slot) logs', async () => {
const e = enc([p('a', 10), p('b', 7)]);
await updateParticipant(e, 'b', { name: 'B' }, ctx);
expect(storage.logs()).toHaveLength(1);
expectLastLogged(storage);
});
test('updateParticipant (init change) logs', async () => {
const e = enc([p('a', 10), p('b', 7)]);
await updateParticipant(e, 'b', { initiative: 5 }, ctx);
expect(storage.logs()).toHaveLength(1);
expectLastLogged(storage);
});
test('addParticipants undo restores prior list', async () => {
const e = enc([p('a', 10), p('b', 7)]);
const orig = e.participants.map(p => p.id);
const newEnc = await addParticipants(e, [p('c', 3)], ctx);
const log = expectLastLogged(storage);
const restored = { ...newEnc, ...shared.expandUndo(log, newEnc).updates };
expect(restored.participants.map(p => p.id)).toEqual(orig);
});
test('updateParticipant undo restores prior participant', async () => {
const e = enc([p('a', 10), p('b', 7)]);
const orig = e.participants.map(p => p.id);
const newEnc = await updateParticipant(e, 'b', { name: 'B' }, ctx);
const log = expectLastLogged(storage);
const restored = { ...newEnc, ...shared.expandUndo(log, newEnc).updates };
expect(restored.participants.map(p => p.id)).toEqual(orig);
});
});
+106
View File
@@ -0,0 +1,106 @@
// Characterization test: addParticipant + pause/resume corrupts turn rotation.
// Audit found 56-77 violations/100 rounds starting round 20 in pure turn.js
// simulation. Visible in live replay (round 10: 17 turns, 6 duped actors,
// R-series stuck repeating forever).
//
// This test uses FRESH ids (crypto.randomUUID equivalent) — NOT the audit's
// self-inflicted dup (loop spun while paused, re-added same `r${totalTurns}`).
// Validates real bug reachable via normal UI flow (DM adds monster while paused,
// resumes).
//
// Rewritten for async turn.js API: funcs are awaited, take ctx, write own logs.
const shared = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers');
const { startEncounter, nextTurn, togglePause, addParticipant, makeParticipant } = shared;
function p(id, initiative, extra = {}) {
return makeParticipant({
id, name: id, type: 'monster',
initiative, maxHp: 100, currentHp: 100,
...extra,
});
}
function enc(ps) {
return {
name: 'T', participants: ps,
isStarted: false, isPaused: false,
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
};
}
describe('addParticipant + pause/resume rotation corruption', () => {
test('add fresh participant while paused, resume, rotation completes full cycle', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 20), p('b', 15), p('c', 10)];
let e = enc(ps);
e = await startEncounter(e, ctx);
const baseOrder = e.turnOrderIds.slice(); // [a,b,c]
e = await nextTurn(e, ctx); // current=b
e = await togglePause(e, ctx); // pause
// add fresh participant x (initiative 25, would sort first)
const x = p('x', 25);
e = await addParticipant(e, x, ctx);
e = await togglePause(e, ctx); // resume (rebuilds order)
// after resume, complete one full round: visit each active participant once
const visited = [e.currentTurnParticipantId];
for (let i = 0; i < e.turnOrderIds.length - 1; i++) {
e = await nextTurn(e, ctx);
visited.push(e.currentTurnParticipantId);
}
const uniq = new Set(visited);
// EXPECT: 4 unique (a,b,c,x). BUG: rotation may not visit all.
expect(uniq.size).toBe(e.turnOrderIds.length);
});
test('multiple adds while paused, resume, rotation visits all', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 20), p('b', 15), p('c', 10)];
let e = enc(ps);
e = await startEncounter(e, ctx);
e = await nextTurn(e, ctx); // current=b
e = await togglePause(e, ctx); // pause
// add 3 fresh participants
for (const id of ['x', 'y', 'z']) {
const np = p(id, 5 + Math.floor(Math.random() * 30));
e = await addParticipant(e, np, ctx);
}
e = await togglePause(e, ctx); // resume
const visited = [e.currentTurnParticipantId];
for (let i = 0; i < e.turnOrderIds.length + 2; i++) {
e = await nextTurn(e, ctx);
visited.push(e.currentTurnParticipantId);
}
const uniq = new Set(visited);
// EXPECT: all 6 participants reachable. BUG: some stuck/repeated.
expect(uniq.size).toBe(e.turnOrderIds.length);
});
test('add while running, then pause+resume, rotation stays valid', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 20), p('b', 15), p('c', 10)];
let e = enc(ps);
e = await startEncounter(e, ctx);
e = await nextTurn(e, ctx); // current=b
const x = p('x', 25);
e = await addParticipant(e, x, ctx); // add while running
e = await togglePause(e, ctx); // pause
e = await togglePause(e, ctx); // resume
const visited = [e.currentTurnParticipantId];
for (let i = 0; i < e.turnOrderIds.length + 2; i++) {
e = await nextTurn(e, ctx);
visited.push(e.currentTurnParticipantId);
}
const uniq = new Set(visited);
expect(uniq.size).toBe(e.turnOrderIds.length);
});
});
+73
View File
@@ -0,0 +1,73 @@
// removeParticipant + computeTurnOrderAfterRemoval edge cases.
//
// New API: mutating funcs are async, take ctx last, write encounter + log
// internally, return newEnc. Chained calls become `e = await fn(e, ctx)`.
const shared = require('@ttrpg/shared');
const { makeParticipant, startEncounter, nextTurn, removeParticipant, toggleParticipantActive, applyHpChange } = shared;
const { mockCtx } = require('./_helpers');
function p(id, init, extra = {}) {
return makeParticipant({ id, name: id, type: 'monster',
initiative: init, maxHp: 100, currentHp: 100, ...extra });
}
function enc(ps) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
}
describe('removeParticipant turn-order edges', () => {
test('removing current picks next active as current', async () => {
const { ctx } = mockCtx();
let e = enc([p('a',20),p('b',15),p('c',10)]);
e = await startEncounter(e, ctx);
e = await removeParticipant(e, 'a', ctx); // a was current
expect(e.currentTurnParticipantId).toBe('b');
});
test('removing last in order wraps current to first', async () => {
const { ctx } = mockCtx();
let e = enc([p('a',20),p('b',15),p('c',10)]);
e = await startEncounter(e, ctx);
e = await nextTurn(e, ctx); // b
e = await nextTurn(e, ctx); // c (current)
e = await removeParticipant(e, 'c', ctx);
expect(e.currentTurnParticipantId).toBe('a');
});
test('removing current when all others inactive → no active, isStarted stays (BUG-9 candidate)', async () => {
const { ctx } = mockCtx();
let e = enc([p('a',20),p('b',15),p('c',10)]);
e = await startEncounter(e, ctx); // [a,b,c], cur=a
// deactivate b + c (stay in slot, inactive)
e = await toggleParticipantActive(e, 'b', ctx);
e = await toggleParticipantActive(e, 'c', ctx);
// remove current a
e = await removeParticipant(e, 'a', ctx);
// 1-list: turnOrderIds=[b,c], no active → current null, isStarted stays true
expect(e.turnOrderIds).toEqual(['b', 'c']);
expect(e.currentTurnParticipantId).toBeNull();
// isStarted still true but no turn → nextTurn throws (stale state)
});
test('removing non-current keeps currentTurn', async () => {
const { ctx } = mockCtx();
let e = enc([p('a',20),p('b',15),p('c',10)]);
e = await startEncounter(e, ctx);
e = await removeParticipant(e, 'b', ctx);
expect(e.currentTurnParticipantId).toBe('a');
expect(e.turnOrderIds).toEqual(['a', 'c']);
});
test('removing current that is dead (HP=0) - BUG-3 overlap', async () => {
// Dead participant removed mid-combat. Desired (M4): they STAY in order.
// removeParticipant is explicit DM action, distinct from auto-skip.
const { ctx } = mockCtx();
let e = enc([p('a',20),p('b',15),p('c',10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'b', 'damage', 100, ctx); // b dead
e = await removeParticipant(e, 'b', ctx);
expect(e.turnOrderIds).not.toContain('b');
expect(e.participants.find(x => x.id === 'b')).toBeUndefined();
});
});
+99
View File
@@ -0,0 +1,99 @@
// Characterization for reorderParticipants correct usage.
// replay-combat.js calls it with wrong signature (swallowed by try/catch),
// so real behavior untested. Lock what it actually does.
//
// New API: reorderParticipants is async, takes ctx last, writes encounter +
// log internally, returns newEnc. A blocked move (cross-init / same id /
// cross-pointer) returns the SAME enc ref (no write). Missing id rejects.
const shared = require('@ttrpg/shared');
const { makeParticipant, startEncounter, nextTurn, reorderParticipants } = shared;
const { mockCtx } = require('./_helpers');
function p(id, init, extra = {}) {
return makeParticipant({
id, name: id, type: 'monster',
initiative: init, maxHp: 100, currentHp: 100,
...extra,
});
}
function enc(ps) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
}
describe('reorderParticipants', () => {
test('drag upward inserts before target (1-list model, pre-combat)', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10), p('b', 20), p('c', 20)]; // b,c tie
let e = enc(ps); // pre-combat, no pointer
const newEnc = await reorderParticipants(e, 'c', 'b', ctx);
// drag c upward onto b: insert before b → [a,c,b]
expect(newEnc.participants.map(p => p.id)).toEqual(['a', 'c', 'b']);
});
test('drag downward inserts after target (1-list model, pre-combat)', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 20), p('b', 20), p('c', 10)]; // a,b tie
let e = enc(ps); // pre-combat, no pointer
const newEnc = await reorderParticipants(e, 'a', 'b', ctx);
// drag a downward onto b: insert after b → [b,a,c]
expect(newEnc.participants.map(p => p.id)).toEqual(['b', 'a', 'c']);
});
test('cross-init drag blocked (no-op)', async () => {
const { storage, ctx } = mockCtx();
const ps = [p('a', 10), p('b', 20)];
let e = enc(ps);
e = await startEncounter(e, ctx); // [b,a]
const before = storage.calls.filter(c => c.fn === 'updateDoc').length;
const newEnc = await reorderParticipants(e, 'a', 'b', ctx);
expect(newEnc).toBe(e); // same ref = no write
expect(storage.calls.filter(c => c.fn === 'updateDoc')).toHaveLength(before); // no new write
});
test('throws if id not found', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10), p('b', 20)];
let e = enc(ps);
e = await startEncounter(e, ctx);
await expect(reorderParticipants(e, 'a', 'zzz', ctx)).rejects.toThrow();
});
test('paused combat allows reorder across current-turn pointer', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 20), p('b', 20), p('c', 20), p('d', 20)];
let e = await startEncounter(enc(ps), ctx); // current=a
e = { ...e, isPaused: true };
const newEnc = await reorderParticipants(e, 'd', 'a', ctx);
expect(newEnc).not.toBe(e);
expect(newEnc.participants.map(p => p.id)).toEqual(['d', 'a', 'b', 'c']);
expect(newEnc.turnOrderIds).toEqual(['d', 'a', 'b', 'c']);
});
test('syncs turnOrderIds = participants order (1-list, fixes BUG-6)', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10), p('b', 20), p('c', 20)];
let e = enc(ps);
e = await startEncounter(e, ctx); // started, but reorder pre-pointer advance
// startEncounter sets current=b (idx0). reorder c before b crosses pointer.
// Use pre-combat to test syncTurnOrder.
let pre = enc(ps);
pre = await reorderParticipants(pre, 'c', 'b', ctx);
expect(pre.turnOrderIds).toEqual(['a','c','b']);
expect(pre.turnOrderIds).toEqual(pre.participants.map(p => p.id));
});
// BUG-6 candidate: reorder should affect turnOrderIds so mid-combat
// drag-drop changes who goes next within same-initiative tie.
// Currently RED (turnOrderIds not in patch).
test('reorder updates turnOrderIds to reflect new participant order (pre-combat)', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10), p('b', 20), p('c', 20)];
let e = enc(ps); // pre-combat, no pointer
e = await reorderParticipants(e, 'c', 'b', ctx);
expect(e.turnOrderIds).toEqual(['a', 'c', 'b']);
});
});
+181
View File
@@ -0,0 +1,181 @@
// Regression test: full round must rotate through ALL active participants exactly once.
// Audit of 100-round replay found 124 skips + 78 dupes (round 1 already missing Fighter
// before any coverage action). nextTurn has core bug, not just coverage-path issue.
//
// Rewritten for async turn.js API: funcs are awaited, take ctx, write own logs.
const shared = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers');
const { startEncounter, nextTurn, makeParticipant } = shared;
function p(id, initiative, extra = {}) {
return makeParticipant({
id, name: id, type: 'monster',
initiative, maxHp: 20, currentHp: 20,
...extra,
});
}
function enc(ps) {
return {
name: 'T', participants: ps,
isStarted: false, isPaused: false,
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
};
}
describe('round rotation integrity', () => {
test('3 participants: one full round visits each exactly once', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 20), p('b', 15), p('c', 10)];
let e = enc(ps);
e = await startEncounter(e, ctx);
const startOrder = e.turnOrderIds.slice();
const visited = [e.currentTurnParticipantId];
// advance (len-1) turns: visits remaining participants, round NOT yet wrapped.
for (let i = 0; i < startOrder.length - 1; i++) {
e = await nextTurn(e, ctx);
visited.push(e.currentTurnParticipantId);
}
expect(e.round).toBe(1); // still round 1
const uniq = new Set(visited);
expect(uniq.size).toBe(startOrder.length); // each exactly once
expect(visited.length).toBe(startOrder.length);
});
test('8 participants (replay shape): one full round visits each exactly once', async () => {
const { ctx } = mockCtx();
const ps = [
p('Goblin1', 12), p('Wolf', 13), p('Merchant', 8), p('OrcBoss', 11),
p('Goblin2', 12), p('Fighter', 14), p('Rogue', 15), p('Cleric', 10),
];
let e = enc(ps);
e = await startEncounter(e, ctx);
const startOrder = e.turnOrderIds.slice();
const visited = [e.currentTurnParticipantId];
for (let i = 0; i < startOrder.length - 1; i++) {
e = await nextTurn(e, ctx);
visited.push(e.currentTurnParticipantId);
}
expect(e.round).toBe(1);
const uniq = new Set(visited);
expect(uniq.size).toBe(startOrder.length);
expect(visited.length).toBe(startOrder.length);
});
test('multiple rounds: each round visits each participant exactly once', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)];
let e = enc(ps);
e = await startEncounter(e, ctx);
const startOrder = e.turnOrderIds.slice();
const expectedRound = e.round;
// capture exactly one full round (current + len-1 advances), no wrap yet.
const visited = [e.currentTurnParticipantId];
for (let i = 0; i < startOrder.length - 1; i++) {
e = await nextTurn(e, ctx);
visited.push(e.currentTurnParticipantId);
}
const uniq = new Set(visited);
expect(uniq.size).toBe(startOrder.length);
expect(e.round).toBe(expectedRound);
});
});
describe('round rotation with mid-round state changes', () => {
const { toggleParticipantActive, addParticipant, removeParticipant, reorderParticipants, applyHpChange } = shared;
test('toggle a participant inactive mid-round, others still each visited once', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)];
let e = enc(ps);
e = await startEncounter(e, ctx);
const startOrder = e.turnOrderIds.slice();
const visited = [e.currentTurnParticipantId];
e = await nextTurn(e, ctx); visited.push(e.currentTurnParticipantId);
// now mark 'a' inactive (already took its turn)
e = await toggleParticipantActive(e, 'a', ctx);
e = await nextTurn(e, ctx); visited.push(e.currentTurnParticipantId);
e = await nextTurn(e, ctx); visited.push(e.currentTurnParticipantId);
// round should wrap, but 'a' inactive so only b,c,d visited
const visitedActive = visited.filter(id => id !== 'a');
const uniq = new Set(visitedActive);
expect(uniq.size).toBe(startOrder.length - 1); // b,c,d each once
});
test('reactivate inactive participant mid-round, it gets a turn this round', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)];
let e = enc(ps);
// start with 'c' inactive
e.participants = e.participants.map(p => p.id === 'c' ? { ...p, isActive: false } : p);
e = await startEncounter(e, ctx);
// 1-list: c stays in slot (inactive), skipped by nextTurn
expect(e.turnOrderIds).toEqual(['a', 'b', 'c', 'd']);
expect(e.currentTurnParticipantId).toBe('a'); // c inactive, a first
// advance one turn, then reactivate c
e = await nextTurn(e, ctx); // b
e = await toggleParticipantActive(e, 'c', ctx);
// continue rotation - c should now be reachable
const visited = [e.currentTurnParticipantId];
for (let i = 0; i < e.turnOrderIds.length; i++) {
e = await nextTurn(e, ctx);
visited.push(e.currentTurnParticipantId);
}
expect(visited).toContain('c');
});
test('addParticipant mid-round: new participant gets turn this round or next', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 20), p('b', 15), p('c', 10)];
let e = enc(ps);
e = await startEncounter(e, ctx);
const startOrder = e.turnOrderIds.slice();
e = await nextTurn(e, ctx); // advance one
// add new participant
const newP = p('x', 25);
e = await addParticipant(e, newP, ctx);
// finish round - original 3 should still each get exactly one turn
const visited = [startOrder[0], e.currentTurnParticipantId];
while (e.round === 1) {
e = await nextTurn(e, ctx);
visited.push(e.currentTurnParticipantId);
if (visited.length > 20) break; // safety
}
const originals = visited.filter(id => ['a','b','c'].includes(id));
const uniq = new Set(originals);
expect(uniq.size).toBe(3);
});
test('reorderParticipants mid-round keeps rotation valid', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 20), p('b', 15), p('c', 15), p('d', 5)]; // b,c same init (15)
let e = enc(ps);
e = await startEncounter(e, ctx);
const startOrder = e.turnOrderIds.slice();
e = await nextTurn(e, ctx);
// reorder: swap b,c (same initiative)
e = await reorderParticipants(e, 'b', 'c', ctx);
const visited = [startOrder[0], e.currentTurnParticipantId];
for (let i = 0; i < startOrder.length; i++) {
e = await nextTurn(e, ctx);
visited.push(e.currentTurnParticipantId);
}
const uniq = new Set(visited);
expect(uniq.size).toBeGreaterThanOrEqual(startOrder.length);
});
});
+128
View File
@@ -0,0 +1,128 @@
// Invariant: no real skip. Every active participant at round start (still
// active at round end) gets a turn. Tracks per ACTUAL round (e.round), so
// rounds spanning pause/resume across loop iterations count correctly.
//
// Guards BUG-5 fix (slot-array turn order, no re-sort on wrap/resume).
// If this goes RED, turn order rotation is skipping participants again.
//
// New API: mutating funcs are async, take ctx last, write encounter + log
// internally, return newEnc. setup(ctx) returns the started encounter;
// loop bodies await each mutating call.
'use strict';
const shared = require('@ttrpg/shared');
const {
buildCharacterParticipant, buildMonsterParticipant,
startEncounter, nextTurn, togglePause, addParticipant, removeParticipant,
toggleParticipantActive,
} = shared;
const { mockCtx } = require('./_helpers');
const nm = (enc) => (id) => {
const f = enc.participants.find(p => p.id === id);
return f ? f.name : id;
};
function setup(ctx) {
const ps = [
buildCharacterParticipant({ id: 'c1', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 }).participant,
buildCharacterParticipant({ id: 'c2', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 }).participant,
buildCharacterParticipant({ id: 'c3', name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 }).participant,
buildMonsterParticipant({ name: 'Goblin1', maxHp: 100, initMod: 2 }).participant,
buildMonsterParticipant({ name: 'Goblin2', maxHp: 100, initMod: 2 }).participant,
buildMonsterParticipant({ name: 'OrcBoss', maxHp: 500, initMod: 1 }).participant,
buildMonsterParticipant({ name: 'Wolf', maxHp: 120, initMod: 3 }).participant,
buildMonsterParticipant({ name: 'Merchant', maxHp: 150, initMod: 0, asNpc: true }).participant,
];
let e = {
name: 't', participants: ps, isStarted: false, isPaused: false,
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
};
return startEncounter(e, ctx); // async → Promise<newEnc>
}
describe('BUG-5: turn-order rotation never skips (deterministic)', () => {
jest.setTimeout(15000);
test('pure nextTurn: 0 skips across 100 rounds', async () => {
const { ctx } = mockCtx();
let e = await setup(ctx);
let totalSkips = 0;
for (let roundN = 1; roundN <= 100; roundN++) {
const startRound = e.round;
const activeAtStart = new Set(e.participants.filter(p => p.isActive).map(p => p.id));
const acted = new Set();
acted.add(e.currentTurnParticipantId);
let guard = 0;
const cap = e.participants.length + 1;
while (e.round === startRound && guard < cap) {
e = await nextTurn(e, ctx);
if (e.round === startRound) acted.add(e.currentTurnParticipantId);
guard++;
}
const skipped = [...activeAtStart].filter(id => {
const p = e.participants.find(x => x.id === id);
return p && p.isActive && !acted.has(id);
});
totalSkips += skipped.length;
}
expect(totalSkips).toBe(0);
});
test('with pause/resume + add/remove/toggle: 0 skips across ~540 rounds', async () => {
const { ctx } = mockCtx();
let e = await setup(ctx);
const N = nm(e);
let curRound = null;
let activeAtRoundStart = new Set();
let actedThisRound = new Set();
const onRoundStart = (enc) => {
curRound = enc.round;
activeAtRoundStart = new Set(enc.participants.filter(p => p.isActive).map(p => p.id));
actedThisRound = new Set();
if (enc.currentTurnParticipantId) actedThisRound.add(enc.currentTurnParticipantId);
};
onRoundStart(e);
let totalRealSkips = 0;
let added = 0;
let turns = 0;
const MAX_TURNS = 2000;
while (turns < MAX_TURNS && e.isStarted) {
turns++;
if (e.isPaused) e = await togglePause(e, ctx);
if (turns % 7 === 0 && !e.isPaused) { e = await togglePause(e, ctx); continue; }
const prevRound = e.round;
e = await nextTurn(e, ctx);
if (e.round !== prevRound) {
const skipped = [...activeAtRoundStart].filter(id => {
const p = e.participants.find(x => x.id === id);
return p && p.isActive && !actedThisRound.has(id);
});
totalRealSkips += skipped.length;
onRoundStart(e);
} else {
actedThisRound.add(e.currentTurnParticipantId);
}
if (turns % 9 === 0 && added < 8) {
const b = buildMonsterParticipant({ name: `R${added + 1}`, maxHp: 120, initMod: 3 }).participant;
b.id = `reinforce${added + 1}`;
e = await addParticipant(e, b, ctx); added++;
}
if (turns % 13 === 0) {
const cand = e.participants.filter(p => p.type === 'monster' && p.isActive && p.id !== e.currentTurnParticipantId);
if (cand.length) e = await removeParticipant(e, cand[0].id, ctx);
}
if (turns % 17 === 0) {
const cand = e.participants.filter(p => p.isActive && p.id !== e.currentTurnParticipantId);
if (cand.length) {
const t = cand[0];
e = await toggleParticipantActive(e, t.id, ctx);
e = await toggleParticipantActive(e, t.id, ctx);
}
}
}
expect(totalRealSkips).toBe(0);
});
});
+81
View File
@@ -0,0 +1,81 @@
// SLOT-NOT-SORT invariant: addParticipant + updateParticipant must PRESERVE
// manually-dragged tie order (same-initiative participants).
//
// Design doc (docs/INITIATIVE_ORDERING.md):
// "Re-slotting on add/edit must preserve drag-established tie order."
// "Tie-break = original add order. Later additions slot AFTER existing
// same-init participants. Stable insertion."
//
// startEncounter re-sorts once to freeze the list; after that add/update use
// stable slot insertion (slotIndexForInit), never wholesale re-sort — so drag
// tie order survives.
//
// New API: addParticipant / updateParticipant / reorderParticipants are async,
// take ctx, return newEnc.
'use strict';
const {
makeParticipant,
startEncounter, addParticipant, updateParticipant, reorderParticipants,
} = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers');
function p(id, init, extra = {}) {
return makeParticipant({ id, name: id, type: 'monster',
initiative: init, maxHp: 100, currentHp: 100, ...extra });
}
function enc(ps, extra = {}) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra };
}
describe('SLOT-NOT-SORT: drag tie order survives add/edit', () => {
test('addParticipant preserves existing drag tie order', async () => {
const { ctx } = mockCtx();
// Two same-init participants. DM drags b BEFORE a (tie override).
let e = enc([p('a', 10), p('b', 10)]);
e = await reorderParticipants(e, 'b', 'a', ctx); // drag b ahead of a → [b,a]
expect(e.participants.map(x => x.id)).toEqual(['b', 'a']);
// Add unrelated participant (different init). Tie order [b,a] MUST survive.
e = await addParticipant(e, p('c', 5), ctx);
const ids = e.participants.map(x => x.id);
// c slots last (init 5 < 10). b,a tie order preserved.
expect(ids).toEqual(['b', 'a', 'c']);
});
test('addParticipant with same init as dragged pair slots AFTER tie group', async () => {
const { ctx } = mockCtx();
// DM drags b before a (both init 10). Then add d also init 10.
// d must slot AFTER existing same-init pair (stable insertion), not re-sort.
let e = enc([p('a', 10), p('b', 10)]);
e = await reorderParticipants(e, 'b', 'a', ctx); // [b,a]
e = await addParticipant(e, p('d', 10), ctx); // init 10, after tie group
const ids = e.participants.map(x => x.id);
expect(ids).toEqual(['b', 'a', 'd']);
});
test('updateParticipant preserves drag tie order on unrelated field edit', async () => {
const { ctx } = mockCtx();
let e = enc([p('a', 10), p('b', 10)]);
e = await reorderParticipants(e, 'b', 'a', ctx); // [b,a]
// Edit a's name (not initiative). Tie order MUST survive.
e = await updateParticipant(e, 'a', { name: 'A2' }, ctx);
expect(e.participants.map(x => x.id)).toEqual(['b', 'a']);
});
test('updateParticipant init change slots, preserves OTHER tie group order', async () => {
const { ctx } = mockCtx();
// Two tie groups: [a,b] init 10, [c,d] init 5. DM drags b before a.
let e = enc([p('a', 10), p('b', 10), p('c', 5), p('d', 5)]);
e = await reorderParticipants(e, 'b', 'a', ctx); // [b,a,c,d]
// Edit c's initiative to 10 — slots into top group. [c] order within its
// own old group irrelevant (it leaves that group). But [b,a] tie MUST stay.
e = await updateParticipant(e, 'c', { initiative: 10 }, ctx);
const topTwo = e.participants.filter(x => x.initiative === 10).map(x => x.id);
// b before a preserved. c joins the 10-group (exact slot less critical
// than b,a order surviving).
expect(topTwo.indexOf('b')).toBeLessThan(topTwo.indexOf('a'));
});
});
+102
View File
@@ -0,0 +1,102 @@
// Temp HP: damage hits temp first, then regular.
// setTempHp replaces (no stacking). Healing regular HP ignores temp.
const shared = require('@ttrpg/shared');
const { makeParticipant, applyHpChange, setTempHp, addParticipant } = shared;
const { mockCtx } = require('./_helpers');
function setupEnc() {
const { ctx } = mockCtx();
return {
enc: { id: 'enc1', name: 'TempEnc', participants: [], isStarted: false, isPaused: false, round: 0 },
ctx,
};
}
describe('temp HP', () => {
test('setTempHp sets tempHp on participant', async () => {
const { enc, ctx } = setupEnc();
const p = makeParticipant({
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
});
let e = await addParticipant(enc, p, ctx);
e = await setTempHp(e, p.id, 5, ctx);
expect(e.participants[0].tempHp).toBe(5);
});
test('setTempHp replaces existing temp (no stacking)', async () => {
const { enc, ctx } = setupEnc();
const p = makeParticipant({
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
});
let e = await addParticipant(enc, p, ctx);
e = await setTempHp(e, p.id, 5, ctx);
e = await setTempHp(e, p.id, 3, ctx);
expect(e.participants[0].tempHp).toBe(3);
});
test('setTempHp to 0 clears temp', async () => {
const { enc, ctx } = setupEnc();
const p = makeParticipant({
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
});
let e = await addParticipant(enc, p, ctx);
e = await setTempHp(e, p.id, 5, ctx);
e = await setTempHp(e, p.id, 0, ctx);
expect(e.participants[0].tempHp).toBe(0);
});
test('damage: temp absorbs before regular HP', async () => {
const { enc, ctx } = setupEnc();
const p = makeParticipant({
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
});
let e = await addParticipant(enc, p, ctx);
e = await setTempHp(e, p.id, 5, ctx);
e = await applyHpChange(e, p.id, 'damage', 8, ctx);
expect(e.participants[0].tempHp).toBe(0);
expect(e.participants[0].currentHp).toBe(17);
});
test('damage less than temp: only temp reduced', async () => {
const { enc, ctx } = setupEnc();
const p = makeParticipant({
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
});
let e = await addParticipant(enc, p, ctx);
e = await setTempHp(e, p.id, 10, ctx);
e = await applyHpChange(e, p.id, 'damage', 4, ctx);
expect(e.participants[0].tempHp).toBe(6);
expect(e.participants[0].currentHp).toBe(20);
});
test('heal: does not affect temp HP', async () => {
const { enc, ctx } = setupEnc();
const p = makeParticipant({
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 10,
});
let e = await addParticipant(enc, p, ctx);
e = await setTempHp(e, p.id, 5, ctx);
e = await applyHpChange(e, p.id, 'heal', 8, ctx);
expect(e.participants[0].tempHp).toBe(5);
expect(e.participants[0].currentHp).toBe(18);
});
test('damage exactly equals temp: temp gone, regular intact', async () => {
const { enc, ctx } = setupEnc();
const p = makeParticipant({
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
});
let e = await addParticipant(enc, p, ctx);
e = await setTempHp(e, p.id, 5, ctx);
e = await applyHpChange(e, p.id, 'damage', 5, ctx);
expect(e.participants[0].tempHp).toBe(0);
expect(e.participants[0].currentHp).toBe(20);
});
test('makeParticipant: tempHp defaults to 0', async () => {
const p = makeParticipant({
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
});
expect(p.tempHp).toBe(0);
});
});
+213
View File
@@ -0,0 +1,213 @@
// Undo + redo roundtrip: every op that writes a log entry must restore prior
// state on undo AND re-apply forward state on redo. Uses REAL mock storage
// undo() (applies patch + flips undone flag), not just expandUndo output.
//
// Pattern per case:
// before = snap(enc) // state before op
// newEnc = await op(enc) // apply op (writes enc doc + log entry)
// afterUndo = undoLast() // real undo via storage.undo
// expect(afterUndo) === before
// afterRedo = redoLast() // real redo via storage.undo({redo:true})
// expect(afterRedo) === snap(newEnc)
const shared = require('@ttrpg/shared');
const { mockCtx, undoLast, redoLast } = require('./_helpers');
const { expandUndo } = shared;
const {
makeParticipant, startEncounter, nextTurn, togglePause,
addParticipant, removeParticipant, toggleParticipantActive,
applyHpChange, toggleCondition, reorderParticipants, endEncounter,
} = shared;
function p(id, init, extra = {}) {
return makeParticipant({
id, name: id, type: 'monster',
initiative: init, maxHp: 100, currentHp: 100,
...extra,
});
}
function enc(ps) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
}
const snap = (e) => {
const o = JSON.parse(JSON.stringify(e));
// normalize nullable timestamp fields: null = absent (undo clears via null)
if (o.startedAt === null) delete o.startedAt;
if (o.endedAt === null) delete o.endedAt;
return o;
};
describe('undo + redo roundtrip', () => {
test('startEncounter', async () => {
const { storage, ctx } = mockCtx();
const before = enc([p('a',10),p('b',20)]);
await storage.setDoc(ctx.encPath, before);
const newEnc = await startEncounter(before, ctx);
const last = storage.logs().at(-1);
const afterUndo = await undoLast(ctx, newEnc);
// participants[] order may differ (start sorts); check turn-state fields.
expect(afterUndo.isStarted).toBe(before.isStarted);
expect(afterUndo.isPaused).toBe(before.isPaused);
expect(afterUndo.round).toBe(before.round);
expect(afterUndo.currentTurnParticipantId).toBe(before.currentTurnParticipantId);
expect(afterUndo.turnOrderIds).toEqual(before.turnOrderIds);
const afterRedo = await redoLast(ctx, afterUndo, last);
expect(afterRedo.isStarted).toBe(newEnc.isStarted);
expect(afterRedo.round).toBe(newEnc.round);
expect(afterRedo.currentTurnParticipantId).toBe(newEnc.currentTurnParticipantId);
expect(afterRedo.turnOrderIds).toEqual(newEnc.turnOrderIds);
});
test('nextTurn', async () => {
const { storage, ctx } = mockCtx();
let e = enc([p('a',10),p('b',20),p('c',5)]);
e = await startEncounter(e, ctx);
await storage.setDoc(ctx.encPath, e);
const before = snap(e);
const newEnc = await nextTurn(e, ctx);
const last = storage.logs().at(-1);
const afterUndo = await undoLast(ctx, newEnc);
expect(snap(afterUndo)).toEqual(before);
const afterRedo = await redoLast(ctx, afterUndo, last);
expect(snap(afterRedo)).toEqual(snap(newEnc));
});
test('togglePause excluded from undo (no log entry)', async () => {
const { storage, ctx } = mockCtx();
let e = enc([p('a',10),p('b',20)]);
e = await startEncounter(e, ctx);
await storage.setDoc(ctx.encPath, e);
const before = snap(e);
const newEnc = await togglePause(e, ctx);
// state flips (isPaused), but no log written
expect(newEnc.isPaused).toBe(true);
expect(storage.logs()).toHaveLength(1); // start only
});
test('applyHpChange damage restores HP on undo, re-applies on redo', async () => {
const { storage, ctx } = mockCtx();
let e = enc([p('a',10,{maxHp:100,currentHp:100}),p('b',20)]);
e = await startEncounter(e, ctx);
await storage.setDoc(ctx.encPath, e);
const before = snap(e);
const newEnc = await applyHpChange(e, 'a', 'damage', 20, ctx);
const last = storage.logs().at(-1);
const afterUndo = await undoLast(ctx, newEnc);
expect(snap(afterUndo)).toEqual(before);
const afterRedo = await redoLast(ctx, afterUndo, last);
expect(snap(afterRedo)).toEqual(snap(newEnc));
});
test('applyHpChange heal restores HP on undo, re-applies on redo', async () => {
const { storage, ctx } = mockCtx();
let e = enc([p('a',10,{maxHp:100,currentHp:50})]);
e = await startEncounter(e, ctx);
await storage.setDoc(ctx.encPath, e);
const before = snap(e);
const newEnc = await applyHpChange(e, 'a', 'heal', 20, ctx);
const last = storage.logs().at(-1);
const afterUndo = await undoLast(ctx, newEnc);
expect(snap(afterUndo)).toEqual(before);
const afterRedo = await redoLast(ctx, afterUndo, last);
expect(snap(afterRedo)).toEqual(snap(newEnc));
});
test('toggleCondition', async () => {
const { storage, ctx } = mockCtx();
let e = enc([p('a',10),p('b',20)]);
e = await startEncounter(e, ctx);
await storage.setDoc(ctx.encPath, e);
const before = snap(e);
const newEnc = await toggleCondition(e, 'a', 'stunned', ctx);
const last = storage.logs().at(-1);
const afterUndo = await undoLast(ctx, newEnc);
expect(snap(afterUndo)).toEqual(before);
const afterRedo = await redoLast(ctx, afterUndo, last);
expect(snap(afterRedo)).toEqual(snap(newEnc));
});
test('toggleParticipantActive restores participants + turn order, redo re-applies', async () => {
const { storage, ctx } = mockCtx();
let e = enc([p('a',10),p('b',20),p('c',5)]);
e = await startEncounter(e, ctx);
await storage.setDoc(ctx.encPath, e);
const before = snap(e);
const newEnc = await toggleParticipantActive(e, 'b', ctx);
const last = storage.logs().at(-1);
const afterUndo = await undoLast(ctx, newEnc);
expect(snap(afterUndo)).toEqual(before);
const afterRedo = await redoLast(ctx, afterUndo, last);
expect(snap(afterRedo)).toEqual(snap(newEnc));
});
test('addParticipant', async () => {
const { storage, ctx } = mockCtx();
let e = enc([p('a',10),p('b',20)]);
e = await startEncounter(e, ctx);
await storage.setDoc(ctx.encPath, e);
const before = snap(e);
const np = makeParticipant({ id:'z', name:'z', type:'monster', initiative:15, maxHp:50, currentHp:50 });
const newEnc = await addParticipant(e, np, ctx);
const last = storage.logs().at(-1);
const afterUndo = await undoLast(ctx, newEnc);
expect(snap(afterUndo)).toEqual(before);
const afterRedo = await redoLast(ctx, afterUndo, last);
expect(snap(afterRedo)).toEqual(snap(newEnc));
});
test('removeParticipant restores prior participants + turn order, redo re-applies', async () => {
const { storage, ctx } = mockCtx();
let e = enc([p('a',10),p('b',20),p('c',5)]);
e = await startEncounter(e, ctx);
await storage.setDoc(ctx.encPath, e);
const before = snap(e);
const newEnc = await removeParticipant(e, 'b', ctx);
const last = storage.logs().at(-1);
const afterUndo = await undoLast(ctx, newEnc);
expect(snap(afterUndo)).toEqual(before);
const afterRedo = await redoLast(ctx, afterUndo, last);
expect(snap(afterRedo)).toEqual(snap(newEnc));
});
test('endEncounter', async () => {
const { storage, ctx } = mockCtx();
let e = enc([p('a',10),p('b',20)]);
e = await startEncounter(e, ctx);
await storage.setDoc(ctx.encPath, e);
const before = snap(e);
const newEnc = await endEncounter(e, ctx);
const last = storage.logs().at(-1);
const afterUndo = await undoLast(ctx, newEnc);
expect(snap(afterUndo)).toEqual(before);
const afterRedo = await redoLast(ctx, afterUndo, last);
expect(snap(afterRedo)).toEqual(snap(newEnc));
});
test('reorderParticipants (BUG-7 fixed)', async () => {
const { storage, ctx } = mockCtx();
const ps = [p('a',10),p('b',20),p('c',20)]; // b,c tie
let e = enc(ps);
await storage.setDoc(ctx.encPath, e);
const before = snap(e);
const newEnc = await reorderParticipants(e, 'c', 'b', ctx);
const last = storage.logs().at(-1);
const afterUndo = await undoLast(ctx, newEnc);
expect(snap(afterUndo)).toEqual(before);
const afterRedo = await redoLast(ctx, afterUndo, last);
expect(snap(afterRedo)).toEqual(snap(newEnc));
});
test('updateParticipant', async () => {
const { storage, ctx } = mockCtx();
let e = enc([p('a',10),p('b',20)]);
e = await startEncounter(e, ctx);
await storage.setDoc(ctx.encPath, e);
const before = snap(e);
const newEnc = await shared.updateParticipant(e, 'a', { initiative: 99 }, ctx);
const last = storage.logs().at(-1);
const afterUndo = await undoLast(ctx, newEnc);
expect(snap(afterUndo)).toEqual(before);
const afterRedo = await redoLast(ctx, afterUndo, last);
expect(snap(afterRedo)).toEqual(snap(newEnc));
});
});
+157
View File
@@ -0,0 +1,157 @@
// Character writeback on endEncounter: sync maxHp/ac to campaign players.
const shared = require('@ttrpg/shared');
const { endEncounter } = shared;
function makeMockStorage() {
const docs = new Map();
const writes = [];
return {
getDoc: jest.fn(async (p) => docs.get(p) || null),
setDoc: jest.fn(async (p, d) => { docs.set(p, d); }),
updateDoc: jest.fn(async (p, patch) => {
writes.push({ path: p, patch });
const cur = docs.get(p) || {};
docs.set(p, { ...cur, ...patch });
}),
addDoc: jest.fn(async (p, data) => {
writes.push({ path: p, patch: data });
docs.set(p, data);
}),
deleteDoc: jest.fn(async () => {}),
getCollection: jest.fn(async () => []),
_docs: docs,
_writes: writes,
};
}
function makeEncounter(overrides = {}) {
return {
id: 'enc1',
name: 'Test',
campaignId: 'camp1',
isStarted: true,
isPaused: false,
currentTurnParticipantId: 'p1',
round: 3,
turnOrderIds: ['p1', 'p2'],
participants: [
{ id: 'p1', name: 'Fighter', type: 'character', originalCharacterId: 'char1', initiative: 15, maxHp: 28, currentHp: 20, ac: 18 },
{ id: 'p2', name: 'Goblin', type: 'monster', originalCharacterId: null, initiative: 12, maxHp: 7, currentHp: 7, ac: 13 },
{ id: 'p3', name: 'Cleric', type: 'character', originalCharacterId: 'char2', initiative: 10, maxHp: 22, currentHp: 22, ac: 16 },
],
...overrides,
};
}
describe('character writeback on endEncounter', () => {
test('no writeback when syncCharacters false/missing', async () => {
const storage = makeMockStorage();
storage._docs.set('campaigns/camp1', {
id: 'camp1', name: 'Camp', players: [
{ id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17 },
],
});
const enc = makeEncounter();
const ctx = {
storage,
encounterPath: 'campaigns/camp1/encounters/enc1',
logPath: 'logs/log1',
logCollection: 'logs',
displayPath: 'activeDisplay/status',
};
await endEncounter(enc, ctx);
// no campaign write
expect(storage._writes.find(w => w.path === 'campaigns/camp1')).toBeUndefined();
});
test('writeback updates char maxHp + ac + currentHp when syncCharacters true', async () => {
const storage = makeMockStorage();
const players = [
{ id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17, defaultCurrentHp: 30 },
{ id: 'char2', name: 'Cleric', defaultMaxHp: 20, defaultAc: 14, defaultCurrentHp: 20 },
];
storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players, syncCharacters: true });
const enc = makeEncounter();
const ctx = {
storage,
encounterPath: 'campaigns/camp1/encounters/enc1',
logPath: 'logs/log1',
logCollection: 'logs',
displayPath: 'activeDisplay/status',
};
await endEncounter(enc, ctx);
const campWrite = storage._writes.find(w => w.path === 'campaigns/camp1');
expect(campWrite).toBeDefined();
const updated = campWrite.patch.players;
const fighter = updated.find(p => p.id === 'char1');
expect(fighter.defaultMaxHp).toBe(28);
expect(fighter.defaultAc).toBe(18);
expect(fighter.defaultCurrentHp).toBe(20);
const cleric = updated.find(p => p.id === 'char2');
expect(cleric.defaultMaxHp).toBe(22);
expect(cleric.defaultAc).toBe(16);
expect(cleric.defaultCurrentHp).toBe(22);
});
test('writeback skips monsters (no originalCharacterId)', async () => {
const storage = makeMockStorage();
const players = [{ id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17 }];
storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players, syncCharacters: true });
const enc = makeEncounter();
const ctx = {
storage,
encounterPath: 'campaigns/camp1/encounters/enc1',
logPath: 'logs/log1',
logCollection: 'logs',
displayPath: 'activeDisplay/status',
};
await endEncounter(enc, ctx);
const campWrite = storage._writes.find(w => w.path === 'campaigns/camp1');
// players array still 1 entry, goblin not added
expect(campWrite.patch.players.length).toBe(1);
});
test('undo payload includes old char values for restore', async () => {
const storage = makeMockStorage();
const players = [
{ id: 'char1', name: 'Fighter', defaultMaxHp: 30, defaultAc: 17, defaultCurrentHp: 30 },
{ id: 'char2', name: 'Cleric', defaultMaxHp: 20, defaultAc: 14, defaultCurrentHp: 20 },
];
storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players, syncCharacters: true });
storage._docs.set('logs/log1', null);
const enc = makeEncounter();
const ctx = {
storage,
encounterPath: 'campaigns/camp1/encounters/enc1',
logPath: 'logs/log1',
logCollection: 'logs',
displayPath: 'activeDisplay/status',
};
await endEncounter(enc, ctx);
const logWrite = storage._writes.find(w => w.path === 'logs/log1');
expect(logWrite).toBeDefined();
const log = logWrite.patch;
expect(log.undo.characterWriteback).toBeDefined();
expect(log.undo.characterWriteback).toHaveLength(2);
const fighterOld = log.undo.characterWriteback.find(c => c.id === 'char1');
expect(fighterOld.defaultMaxHp).toBe(30);
expect(fighterOld.defaultAc).toBe(17);
expect(fighterOld.defaultCurrentHp).toBe(30);
});
test('writeback no-op if character missing from campaign', async () => {
const storage = makeMockStorage();
storage._docs.set('campaigns/camp1', { id: 'camp1', name: 'Camp', players: [], syncCharacters: true });
const enc = makeEncounter();
const ctx = {
storage,
encounterPath: 'campaigns/camp1/encounters/enc1',
logPath: 'logs/log1',
logCollection: 'logs',
displayPath: 'activeDisplay/status',
};
await endEncounter(enc, ctx);
// no writeback (no matching chars = no change)
expect(storage._writes.find(w => w.path === 'campaigns/camp1')).toBeUndefined();
});
});
+1193
View File
File diff suppressed because it is too large Load Diff
+4088 -893
View File
File diff suppressed because it is too large Load Diff
+91
View File
@@ -0,0 +1,91 @@
// Mock in-memory Firestore for jest tests.
// Reset via resetMockDb() in setupTests.js beforeEach.
const state = {
docs: new Map(), // path -> data
subscribers: new Map(), // path -> Set<cb>
counter: 0,
calls: [], // recorded SDK calls
};
export const MOCK_DB = {
get(path) { return state.docs.has(path) ? clone(state.docs.get(path)) : null; },
set(path, data) {
state.docs.set(path, clone(data));
this._notify(path);
},
merge(path, patch) {
const cur = state.docs.has(path) ? state.docs.get(path) : {};
const next = { ...cur, ...clone(patch) };
state.docs.set(path, next);
this._notify(path);
},
delete(path) {
state.docs.delete(path);
this._notify(path);
},
collection(collPath) {
const out = [];
for (const [p, data] of state.docs) {
const parent = p.split('/').slice(0, -1).join('/');
if (parent === collPath) out.push({ id: p.split('/').pop(), data: clone(data) });
}
return out;
},
subscribe(path, cb) {
if (!state.subscribers.has(path)) state.subscribers.set(path, new Set());
state.subscribers.get(path).add(cb);
return () => state.subscribers.get(path)?.delete(cb);
},
_notify(path) {
// notify exact doc path subscribers (wrapped in act for test isolation).
// Set flag true around cb: testing-library asyncWrapper (waitFor) flips it
// false during poll, which makes raw react act() warn 'not configured'.
if (state.subscribers.has(path)) state.subscribers.get(path).forEach(cb => {
try {
const { act } = require('react');
const prev = globalThis.IS_REACT_ACT_ENVIRONMENT;
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
act(() => cb());
globalThis.IS_REACT_ACT_ENVIRONMENT = prev;
} catch {
cb();
}
});
// notify parent collection subscribers
const parent = path.split('/').slice(0, -1).join('/');
if (parent && state.subscribers.has(parent)) state.subscribers.get(parent).forEach(cb => {
try {
const { act } = require('react');
const prev = globalThis.IS_REACT_ACT_ENVIRONMENT;
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
act(() => cb());
globalThis.IS_REACT_ACT_ENVIRONMENT = prev;
} catch {
cb();
}
});
},
nextId() { state.counter += 1; return String(state.counter).padStart(3, '0'); },
_state: state,
};
export function recordCall(entry) {
state.calls.push({ ...entry, ts: Date.now() });
}
export function resetMockDb() {
state.docs.clear();
state.subscribers.clear();
state.calls.length = 0;
state.counter = 0;
}
export function getCalls() {
return [...state.calls];
}
function clone(v) {
if (v === null || v === undefined) return v;
return JSON.parse(JSON.stringify(v));
}

Some files were not shown because too many files have changed in this diff Show More