11 Commits
Author SHA1 Message Date
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
24 changed files with 1111 additions and 371 deletions
+11 -4
View File
@@ -247,15 +247,22 @@ 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. 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 (backend + frontend separately):** **Local dev (recommended):**
```bash ```bash
npm install # installs root, server/, and shared/ workspaces npm install # installs root, server/, and shared/ workspaces
npm run server:dev # starts backend on :4001 (SQLite at server/data/tracker.sqlite) ./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: # in another terminal:
REACT_APP_STORAGE=ws \ REACT_APP_STORAGE=server \
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \ REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
REACT_APP_BACKEND_WS=ws://127.0.0.1:4001/ws \ REACT_APP_BACKEND_REALTIME_URL=ws://127.0.0.1:4001/ws \
npm start npm start
``` ```
+99 -210
View File
@@ -1,75 +1,21 @@
# TODO # TODO
Backlog of bugs + long-term items, from user. Milestones live in Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
REWORK_PLAN.md.
## Feature backlog ## Open bugs
TODO: FIX: test for warnings. any warnings in tests, or compile or runtime == failure. like perl and php used to ahve. warning == error. we should lways solve them ### BUG-7: reorderParticipants not logged
- FIXED — now returns `log: { message, undo }`. Handler calls logAction.
Undo snapshots participants[] + turnOrderIds + pointer.
- Found second gap: deathSave also returned null log. Fixed (message + undo).
- Logging contract test added: turn.logging.test.js (all mutating ops logged,
no-ops null, undo payloads valid). Documents addParticipants +
updateParticipant gaps (null log, intentional).
TODO: make tests have integrated timeout - even 60s is probabvly too long, should not take long. ## Open features
TODO: combat.scenario doesnt do 100 rounds it does 100 turns. recover from kill-shared stash
TODO: death saves wrong
TODO: custom condition field
server = YOUR SQLite server. server/db.js = better-sqlite3. src/storage/server.js = client that talks to it (REST +
websocket). Name bad. Should be "sqlite-adapter" or "server". You right.
memory = in-process JS Map. Useless? Mostly yes for prod. Value: fast contract tests, no server spinup. If
contract test runs vs firebase-mock + live server backend.
Memory = optional convenience. === extra complication.
### feat - add all characters to participants list
### FEAT-M6: Transactional undo (moved from REWORK_PLAN)
- Every mutating action writes event: `(type, payload, undo_payload, undone, ts)`.
- Undo = apply `undo_payload` in same SQLite tx, flip `undone`. Transactional,
no stale clobber.
- Replaces fragile `/logs` snapshot-write undo.
- Migration: keep old undo working for existing entries until cleared; new
format for new entries.
- Related: BUG-7 (reorder no undo).
## Architecture: 1-list turn order model (DONE)
- Single source: turnOrderIds === participants.map(id). No re-sort after
startEncounter. nextTurn skips inactive (predicate), inactive stay in slot.
- Drag (reorder) overrides initiative --- cross-init allowed, DM choice.
- startEncounter sorts ALL participants by init once, then frozen.
- addParticipant splices by init pos. remove/toggle/reorder sync list.
- Display renders participants[] directly (no sortParticipantsByInitiative).
- BUG-6 (reorder divergence) fixed structurally. BUG-5 (rotation) held
(500 rounds CLEAN).
### FEAT-3: initiative first-class entry (add + edit)
- Current: only initMod at char-build. No initiative field at add-participant
or edit. 3-step to set after other steps.
- Need: initiative field at add-char, add-monster, AND edit participant.
- Separate design + RED. Own work item.
- Related: tie-break = drag order (current, works). Expose clearly.
### FEAT-1: Dead participants stay in turn order --- DONE
- Fixed: `applyHpChange` no longer flips `isActive` or touches `turnOrderIds`
on death/revive. Dead stay in rotation, `nextTurn` visits them, PCs get
death-save turn. `isActive` = DM toggle only.
- Tests: `shared/tests/turn.dead-skip.test.js` (4 green). Char tests updated
to new behavior.
### FEAT-2: upgrade app internal logs to be parseable ### FEAT-2: upgrade app internal logs to be parseable
- Goal: combat logs in Firestore store enough structured state to run - Goal: combat logs in storage store enough structured state to run
skip/rotation analysis on ANY historic round --- not just replay stdout. skip/rotation analysis on ANY historic round --- not just replay stdout.
- Current logs: `{timestamp, message, encounterName, undo}`. Parser must - Current logs: `{timestamp, message, encounterName, undo}`. Parser must
guess roster from message strings. Brittle. guess roster from message strings. Brittle.
@@ -81,172 +27,115 @@ Memory = optional convenience. === extra complication.
``` ```
- Then `scripts/analyze-turns.js` ingests app logs directly (adapter fetch). - Then `scripts/analyze-turns.js` ingests app logs directly (adapter fetch).
Works on real game sessions, any round, deterministic. Works on real game sessions, any round, deterministic.
- Parser scaffold NOW ingests replay stdout only (stopgap until FEAT-2).
## Confirmed bugs (tests written, NOT fixed)
### FEAT-M6: Transactional undo (moved from REWORK_PLAN)
- Every mutating action writes event: `(type, payload, undo_payload, undone, ts)`.
- Undo = apply `undo_payload` in same SQLite tx, flip `undone`. Transactional,
no stale clobber.
- Replaces fragile `/logs` snapshot-write undo.
- Migration: keep old undo working for existing entries until cleared; new
format for new entries.
- Related: BUG-7 (reorder no undo).
### Death saves: D&D 5e model (FIXED)
- FIXED — separate success/fail tracking. `deathSave(enc, id, type, n)`
type='success'|'fail'. Fields: `deathSaves`, `deathFails`, `isStable`,
`isDying`. 3 success=stable, 3 fail=dead. Returns `{patch, log, status}`.
Old single-counter broken (successes missing, treated saves as fails).
Old data lost (user OK — feature didn't work). UI: green ✓ row + red ✕ row.
### Custom conditions field (DONE)
- FIXED — per-campaign freeform conditions. Input field in picker → persists to
`campaigns/{id}.customConditions[]`. Merged with built-ins at render.
ParticipantManager subscribes campaign doc. DisplayView renders custom as
raw label. toggleCondition unchanged (any string id). Dedup case-insensitive.
Enter or button to add. maxLength 40.
- Conditions hardcoded list. No way for DM to add custom.
### Test integrated timeouts
- No per-test timeout. Tests hang on regression. Add jest timeout (<60s).
### add participants duplication throws a alert but it disappears fast
### add particopents list dropdown - remove ones already added
## Done (history)
### Architecture: 1-list turn order model
- Single source: turnOrderIds === participants.map(id). No re-sort after
startEncounter. nextTurn skips inactive (predicate), inactive stay in slot.
- Drag (reorder) = same-init tie-break only. Cross-init blocked.
- startEncounter sorts ALL participants by init once, then frozen.
- addParticipant/updateParticipant slot by init (slotIndexForInit), preserve
drag order. Display renders participants[] directly (no sort).
- Static guard test errs if `.sort(` reintroduced.
### BUG-1: addParticipant + pause/resume corrupts turn rotation ### BUG-1: addParticipant + pause/resume corrupts turn rotation
- **RESOLVED** as side effect of BUG-2 fix (dup-id rejection broke chain). - RESOLVED as side effect of BUG-2 fix.
- Audit: 0 violations / 100 rounds after BUG-2 fix.
- Replay: 10 rounds clean, no skip/dupe.
- Audit: 128 violations / 100 rounds, 4 symptom faces.
- Symptom chain (one bug family):
1. pause blocks nextTurn advance → totalTurns stays frozen (e.g. 120)
2. addParticipant re-adds same `r${totalTurns}` id (BUG-2: no dedup)
3. togglePause resume rebuilds turnOrderIds → dup id appears x2
4. nextTurn gets stuck on dup id → rotation breaks
5. eventually nextTurn throws 'Encounter not running'
- Symptom counts (audit-state.js, 100 rounds):
62x turnOrder-no-dup, 52x rotation-dupes, 14x nextTurn-throws
- Repro in replay round 10+: current stuck on one participant forever,
nextTurn returns same id, round never advances.
- Clean minimal repro (shared/tests/turn.pause-add.test.js) PASSES = combo
needs more state than single add+pause. Audit authoritative repro.
- Clean subsystems (zero violations): HP bounds, isActive, deathSave
range, conditions, removeParticipant orphans.
- Real repro = `node scripts/audit-state.js` (or audit-rotation.js).
### BUG-2: addParticipant allows duplicate id ### BUG-2: addParticipant allows duplicate id
- **FIXED** (commit: addParticipant throws on dup id). - FIXED (addParticipant throws on dup id).
- Test: `shared/tests/turn.characterization.test.js` 'addParticipant rejects
duplicate id' --- GREEN.
### bug-3 was a halucination has been removed ### BUG-4: hide-player-HP breaks display view
- FIXED --- mock honors setDoc{merge}, all 5 activeDisplay sites use merge.
### BUG-4: hide-player-HP breaks display view (preexisting) --- PROD FIXED, TEST RED (mock bug) ### BUG-5: mid-round addParticipant/revive corrupts rotation
- **Broader than hide-HP**: ALL 5 `storage.setDoc(getPath.activeDisplay(), ...)` calls - FIXED --- slot-array + DRY advance core `nextActiveAfter`.
use `{merge:true}` which is IGNORED (setDoc = replace per contract).
Each write clobbers other fields on activeDisplay/status doc.
- line 1619: hide-HP toggle → clobbers campaignId+encounterId (display paused)
- line 1648: start combat → clobbers hidePlayerHp
- line 1779: end combat → clobbers hidePlayerHp
- line 1997: deactivate → clobbers hidePlayerHp
- line 2002: activate → clobbers hidePlayerHp
- Toggle "hide player HP" in admin → display view flips to "Game Session Paused".
- Toggling back does NOT recover. Must re-activate encounter in encounters
panel to restore display.
- Expected: hide-HP toggle updates one field on activeDisplay/status doc,
display stays live on current encounter.
- Likely cause: toggle writes to wrong path, or clobbers activeCampaignId/
activeEncounterId with null (setDoc replace vs updateDoc patch).
- Fix: use updateDoc (patch) not setDoc (replace); or include all existing
fields when writing.
- Status update (2026-07): all 5 sites now use `{merge:true}`. Real firebase
adapter honors merge → production works. BUT jsdom test still RED because
`src/__mocks__/firebase/firestore.js` setDoc records call, IGNORES opts
(no actual merge). Mock must simulate firebase merge semantics for test
to pass. Fix = mock setDoc: if opts.merge, MOCK_DB.merge(path,data) else
replace. OR change App.js setDoc(merge) → updateDoc (cleaner, server adapter
uses PATCH). Decide which.
- Test: render App + DisplayView, toggle hide-HP, assert display still shows
encounter (not paused).
### BUG-5: mid-round addParticipant/revive corrupts rotation --- FIXED ### BUG-6: reorderParticipants doesn't update turnOrderIds
- Fixed (commit `494327f`). Slot-array turn order + DRY advance core - FIXED structurally by 1-list model.
`nextActiveAfter`. Both nextTurn + computeTurnOrderAfterRemoval delegate.
- 500-round replay: 0 skips, 0 double-acts.
### BUG-6: reorderParticipants doesn't update turnOrderIds --- FIXED
- Fixed structurally by 1-list model (commit 5d3a060). turnOrderIds =
participants.map(id) always. reorder cross-init allowed (DM override).
Display === rotation by construction.
- Test: `shared/tests/turn.reorder.test.js` 'reorder updates turnOrderIds' (RED).
- `reorderParticipants(enc, draggedId, targetId)` swaps two same-initiative
participants in `participants[]` array but leaves `turnOrderIds` unchanged.
- nextTurn rotates via `turnOrderIds` only → reorder has NO effect on combat
rotation. Mid-encounter drag-drop = pointless.
- replay-combat.js calls reorderParticipants with WRONG signature
`(enc, reorderedArray)` --- swallowed by try/catch, silent no-op. So
replay never exercised real path either.
- Fix: reorder must also update turnOrderIds to match new participant order
(within same-initiative tie).
### BUG-7: reorderParticipants has no undo
- Test: `shared/tests/turn.undo.test.js` 'reorderParticipants has no undo' (GREEN doc).
- `reorderParticipants` returns `log: null`. Other ops return `log.undo`.
- Cannot undo drag-drop. Candidate for undo system (M6).
### BUG-8: server adapter has no reconnect ### BUG-8: server adapter has no reconnect
- Test: `server/tests/server-reconnect.test.js` (RED). - FIXED --- onclose reconnects + re-subscribes existing paths.
- WS dies (idle/error/close) → `wsReady=null`, subscribers dead forever.
- Display frozen until full reload.
- Fix: `onclose` → reconnect + re-subscribe existing paths.
### BUG-10: deact+reactivate same round double-acts participant ### BUG-10: deact+reactivate same round double-acts participant
- Discovered in 500-round replay (3 occurrences). DISTINCT from BUG-5. - FIXED --- 1-list model keeps slot position on toggle. Reactivate does not
- Pattern: participant acts → DM deactivates them → DM reactivates them grant second turn. Test: turn.bug10.test.js.
same round → `computeTurnOrderAfterAddition` re-inserts by initiative
(front) → acts AGAIN before round ends.
- No "acted-this-round" guard. Slot-array model has no per-round-acted set.
- Edge case (DM deact+reactivate same participant same round).
- Fix candidate: track actedThisRound set, skip re-acted; OR insertion
places reactivate AFTER current position (not by initiative).
- Parser now discounts deact-current advances, so this surfaced real.
### BUG-11: FE Combat.scenario test crashes (pre-existing) ### BUG-11: FE Combat.scenario test crashes
- `src/tests/Combat.scenario.test.js:254` deathSave query helper throws - FIXED --- moved to shared/turn.combat.test.js, pure functions, 100 rounds.
(button not found).
- Baseline (my changes removed) also exit=1. Pre-existing, not regression. ### BUG-12: campaign selection follows activeDisplay
- Crashes whole FE test run (process dies). - FIXED.
### BUG-13: reorderParticipants crossing current pointer = ambiguous acted-semantics ### BUG-13: reorderParticipants crossing current pointer = ambiguous acted-semantics
- Discovered 7/1 replay. `reorderParticipants` (shared/turn.js:522) = pure - FIXED --- block cross-pointer reorder during active encounter (both dirs).
drag, no pointer logic. Swapping two actors across current pointer mid-round Full fix needs actedThisRound tracking. Pragmatic block prevents skip/double.
= ambiguous who-acted-this-round. Earlier replay arbitrary swaps showed Pre-combat: free reorder.
skip/double (R9 Summon3 2x, R11 Goblin1 2x) before fix restricted swaps to
upcoming-only.
- Replay now avoids crossing (adjacent upcoming pair only, commit af165f4).
Real app untested: if DM drags actor past current pointer mid-round, skip/
double behavior undefined.
- Decide: block cross-pointer reorder, or define acted-semantics. RED needed.
### BUG-14: addParticipant init-insertion breaks after drag-reorder ### BUG-14: addParticipant init-insertion breaks after drag-reorder
- Discovered 7/1 replay. `computeTurnOrderAfterAddition` scans for first id - FIXED --- slotIndexForInit scans current list (post-drag aware).
with init < addedInit, assumes list init-sorted. After drag, list NOT sorted
→ scan hits wrong slot.
- Trace turn 30→31: list `[Goblin1:20,Goblin2:22,...]` (drag moved Goblin1
before Goblin2). Add Reinforce3 init 21 → scan hits Goblin1:20 (idx 0, <21)
first → insert at 0. Should slot after Goblin2:22. WRONG.
- Root conflict: 1-list model = drag source of truth (no re-sort); addParticipant
= init-based insertion (needs sorted list). After ANY drag, add-insertion
meaningless.
- Proposed fix: append to end always (option A). DM drags to position. Matches
drag = source of truth. Makes `computeTurnOrderAfterAddition` trivial.
- Related: FEAT-3 (initiative first-class field).
## Pipeline (bugs only --- milestones live in REWORK_PLAN.md) ### BUG-15: DisplayView re-sorts (drag order not preserved)
- FIXED --- display renders participants[] directly.
### BUG-16: subscribeCollection hook drops queryConstraints ### BUG-16: subscribeCollection hook drops queryConstraints
- SS `useFirestoreCollection` (App.js ~L233) calls - FIXED --- neutral builders, both adapters honor orderBy/limit.
`storage.subscribeCollection(collectionPath, cb)` --- NO constraints passed.
- main (L251-253): `const q = query(collection(db,collectionPath), ...queryConstraints); onSnapshot(q, ...)`.
- Effect: `LOG_QUERY = [orderBy('timestamp','desc'), limit(500)]` IGNORED. Logs
unsorted + unbounded. Other collections unaffected (no constraints passed
elsewhere) but interface broken.
- firebase adapter `subscribeCollection(path, cb, queryConstraints=[])` HAS the
param, hook just doesn't forward it.
- Fix: hook pass `queryConstraints` as 3rd arg. RED first.
### BUG-17: dead SDK imports in App.js ### BUG-17: dead SDK imports in App.js
- L5 imports `doc,setDoc,collection,onSnapshot,writeBatch,query,getDocs` from - FIXED --- trimmed (auth + getFirestore + getStorage remain).
`./storage` but App never calls them raw (all via `storage.*`). Only used as
re-export passthrough. `getFirestore`,`initializeApp` still used in init flow.
- Noise, not behavioral. Cleanup: trim dead imports OR keep for adapter test
instrumentation. Decide after audit.
### BUG-18: stale comments reference deleted memory adapter + phantom Phase B ### BUG-18: stale comments reference deleted memory adapter
- `src/storage/firebase.js` header had Phase A/B comment (DELETED this session). - FIXED.
- `src/storage/index.js` L3: "per-group refactor pending" --- stale.
- `src/tests/StorageEsm.test.js`, `storage.factory.test.js` referenced memory
(FIXED this session). Re-audit for stragglers.
- [ ] BUG-4: fix setDoc→updateDoc for all 5 activeDisplay sites ### FEAT-1: Dead participants stay in turn order
- [x] BUG-5: fixed (1-list model, 500 rounds clean) - DONE --- applyHpChange no longer flips isActive on death. Dead stay in
- [x] BUG-6: fixed structurally (1-list model) rotation, nextTurn visits them, PCs get death-save turn.
- [x] BUG-12: fixed --- campaign selection follows activeDisplay
- [x] BUG-15: fixed --- DisplayView no longer re-sorts (drag order preserved) ### feat: add all characters to participants list
- [x] BUG-8: server adapter reconnect (implemented + GREEN) - DONE --- addParticipants bulk add wired (App.js:943).
- [ ] BUG-10: deact+reactivate double-act
- [ ] BUG-11: FE Combat.scenario crash ### Warning = failure in tests
- [ ] BUG-13: reorder cross-pointer semantics (RED + decide block/allow) - DONE --- console.error/warn throw in test env.
- [ ] BUG-14: addParticipant init-insert breaks post-drag (append? + RED)
### combat.scenario 100 rounds not turns
- DONE --- loops by actual round-wrap count.
### FEAT-3: initiative first-class entry (add + edit) DONE
- Current: only initMod at char-build. No initiative field at add-participant
or edit. 3-step to set after other steps.
- Need: initiative field at add-char, add-monster, AND edit participant.
- Related: tie-break = drag order (current, works). Expose clearly.
+2 -2
View File
@@ -18,9 +18,9 @@ COPY tailwind.config.js postcss.config.js ./
# better-sqlite3 native build (alpine musl) # better-sqlite3 native build (alpine musl)
RUN cd server && npm rebuild better-sqlite3 RUN cd server && npm rebuild better-sqlite3
# build frontend (ws storage, same-origin via caddy) # build frontend (server storage, same-origin /api + /ws via caddy)
ARG REACT_APP_TRACKER_APP_ID=ttrpg-initiative-tracker-default ARG REACT_APP_TRACKER_APP_ID=ttrpg-initiative-tracker-default
ENV REACT_APP_STORAGE=ws ENV REACT_APP_STORAGE=server
ENV REACT_APP_TRACKER_APP_ID=$REACT_APP_TRACKER_APP_ID ENV REACT_APP_TRACKER_APP_ID=$REACT_APP_TRACKER_APP_ID
RUN NODE_OPTIONS=--openssl-legacy-provider npm run build RUN NODE_OPTIONS=--openssl-legacy-provider npm run build
+18 -5
View File
@@ -50,14 +50,19 @@ git config core.hooksPath .githooks # enable pre-push test gate
## Run ## Run
### Backend (dev) ### Local dev stack (one command)
```bash ```bash
npm run server:dev # :4001, db: server/data/tracker.sqlite ./scripts/dev-start.sh # backend :4001 + frontend :3999, server mode
# or direct: ./scripts/dev-stop.sh # stop both
DB_PATH=./data/tracker.sqlite PORT=4001 node server/index.js
``` ```
- 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.
Smoke check: Smoke check:
```bash ```bash
curl http://127.0.0.1:4001/health # -> {"ok":true} curl http://127.0.0.1:4001/health # -> {"ok":true}
@@ -65,8 +70,16 @@ curl http://127.0.0.1:4001/health # -> {"ok":true}
Never put db in `/tmp` (wipe risk). Use `./data/` (gitignored) or docker volume. Never put db in `/tmp` (wipe risk). Use `./data/` (gitignored) or docker volume.
### Frontend (dev server, server mode) ### 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 ```bash
REACT_APP_STORAGE=server \ REACT_APP_STORAGE=server \
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \ REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
+7
View File
@@ -7,3 +7,10 @@ REACT_APP_FIREBASE_MESSAGING_SENDER_ID="YOUR_FIREBASE_MESSAGING_SENDER_ID_HERE"
REACT_APP_FIREBASE_APP_ID="YOUR_FIREBASE_APP_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"
+11 -1
View File
@@ -1,6 +1,16 @@
# scripts/ # scripts/
Manual demo tool. NOT test. 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 ## replay-combat.js
+5 -2
View File
@@ -60,6 +60,9 @@ const CONDITIONS = [
'invisible', 'paralyzed', 'petrified', 'poisoned', 'prone', 'restrained', 'invisible', 'paralyzed', 'petrified', 'poisoned', 'prone', 'restrained',
'sapped', 'shield', 'slowed', 'stunned', 'unconscious', 'vexed', 'sapped', 'shield', 'slowed', 'stunned', 'unconscious', 'vexed',
]; ];
// Custom (freeform) condition ids — DM-added strings. toggleCondition must
// accept ANY string (UI custom-condition contract). Exercise both paths.
const CUSTOM_CONDITIONS = ['hexed', 'rager', 'marked_for_death', '🛡️blessed'];
async function patch(encounterPath, enc, result, label) { async function patch(encounterPath, enc, result, label) {
if (!result || !result.patch) { if (label) console.log(` (${label}: no-op)`); return enc; } if (!result || !result.patch) { if (label) console.log(` (${label}: no-op)`); return enc; }
@@ -151,7 +154,7 @@ async function main() {
await sleep(DELAY); await sleep(DELAY);
let totalTurns = 0; let totalTurns = 0;
const condQueue = [...CONDITIONS].sort(() => Math.random() - 0.5); const condQueue = [...CONDITIONS, ...CUSTOM_CONDITIONS].sort(() => Math.random() - 0.5);
let reinforcementsAdded = 0; let reinforcementsAdded = 0;
let lastPaused = false; let lastPaused = false;
let lastReorder = 0; let lastReorder = 0;
@@ -240,7 +243,7 @@ async function main() {
const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false); const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
if (living.length > 0) { if (living.length > 0) {
const tgt = pick(living); const tgt = pick(living);
const cond = pick(CONDITIONS); const cond = pick([...CONDITIONS, ...CUSTOM_CONDITIONS]);
try { try {
const c = toggleCondition(enc, tgt.id, cond); const c = toggleCondition(enc, tgt.id, cond);
enc = await patch(encounterPath, enc, c, `condition ${cond} on ${tgt.name}`); enc = await patch(encounterPath, enc, c, `condition ${cond} on ${tgt.name}`);
+1
View File
@@ -3,4 +3,5 @@ module.exports = {
testEnvironment: 'node', testEnvironment: 'node',
testMatch: ['<rootDir>/tests/**/*.test.js'], testMatch: ['<rootDir>/tests/**/*.test.js'],
collectCoverageFrom: ['turn.js'], collectCoverageFrom: ['turn.js'],
testTimeout: 10000,
}; };
+98
View File
@@ -0,0 +1,98 @@
// STATIC GUARD: every mutation logged. Scans shared/turn.js source.
// Invariant: any return with patch != null MUST also have log != null.
// return { patch: {...}, log: {...} } — OK (logged mutation)
// return { patch: null, log: null } — OK (no-op)
// return { patch: {...}, log: null } — FAIL (unlogged mutation = BUG-7 class)
//
// BUG-7 history: reorderParticipants + deathSave + addParticipants +
// updateParticipant all returned log:null with real patches. Per-op test
// missed them because gaps section asserted null as "expected." Static scan
// catches any future regression regardless of test enumeration.
'use strict';
const fs = require('fs');
const path = require('path');
const SRC = fs.readFileSync(path.join(__dirname, '..', 'turn.js'), 'utf8');
// Extract function name + body via brace counting.
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;
}
// Split body into return statements at top level of the function.
// Returns array of return-expr strings.
function extractReturns(body) {
const returns = [];
// find `return ` at depth 1 (inside fn body)
let i = 0;
// body starts with `{`. Skip into depth 1.
while (i < body.length && body[i] !== '{') i++;
i++; // past {
let depth = 1;
let start = -1;
for (; i < body.length; i++) {
const ch = body[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
if (depth === 1 && body.slice(i, i + 7) === 'return ') {
start = i + 7;
}
if (depth === 1 && ch === ';' && start !== -1) {
returns.push(body.slice(start, i));
start = -1;
}
}
return returns;
}
// For a return expr, detect: has `log: null` AND has `patch:` that is NOT null.
// Returns true if suspicious (unlogged mutation).
function isUnloggedMutation(retExpr) {
if (!/log:\s*null/.test(retExpr)) return false;
// patch: null → no-op, OK
if (/patch:\s*null/.test(retExpr)) return false;
// patch: { ... } or patch: someVar → mutation with null log = BAD
if (/patch:/.test(retExpr)) return true;
return false;
}
describe('STATIC: every mutation logged (logging contract)', () => {
const fns = extractFunctions(SRC);
test('no function returns patch!=null with log:null', () => {
const offenders = [];
for (const { name, body } of fns) {
const rets = extractReturns(body);
for (const r of rets) {
if (isUnloggedMutation(r)) {
offenders.push(`${name}: return ${r.trim().slice(0, 80)}`);
}
}
}
expect(offenders).toEqual([]);
});
});
+82
View File
@@ -0,0 +1,82 @@
// 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.
'use strict';
const shared = require('@ttrpg/shared');
const {
makeParticipant,
startEncounter, nextTurn, toggleParticipantActive,
} = shared;
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:[] };
}
const apply = (e, r) => r && r.patch ? { ...e, ...r.patch } : e;
describe('BUG-10: deact+reactivate same round', () => {
test('no participant acts twice in a round after deact+reactivate', () => {
// [a(10), b(7), c(3)]
let e = enc([p('a',10),p('b',7),p('c',3)]);
e = apply(e, startEncounter(e)); // a current, r1
const r1 = [];
r1.push(e.currentTurnParticipantId); // a acts
e = apply(e, nextTurn(e)); r1.push(e.currentTurnParticipantId); // b acts
// b already acted. Deactivate b (NOT current now — b just became current
// via nextTurn, so b IS current). Toggle off advances pointer to c.
e = apply(e, toggleParticipantActive(e, 'b')); // b off
// current advanced to c (b was current)
r1.push(e.currentTurnParticipantId); // c "becomes" active turn
// reactivate b same round
e = apply(e, toggleParticipantActive(e, 'b')); // b on
// nextTurn from c → round 2 (a). b must NOT re-act in round 1.
e = apply(e, nextTurn(e));
expect(e.round).toBe(2);
expect(e.currentTurnParticipantId).toBe('a');
// b acted once in r1, must act once in r2
e = apply(e, nextTurn(e));
expect(e.currentTurnParticipantId).toBe('b');
e = apply(e, nextTurn(e));
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', () => {
// [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 = apply(e, startEncounter(e)); // a
e = apply(e, nextTurn(e)); // b
e = apply(e, nextTurn(e)); // c (r1 full: a,b,c)
// c is current. deactivate a (not current, already acted).
e = apply(e, toggleParticipantActive(e, 'a')); // a off, pointer stays c
expect(e.currentTurnParticipantId).toBe('c');
e = apply(e, toggleParticipantActive(e, 'a')); // a on
expect(e.currentTurnParticipantId).toBe('c');
// nextTurn from c → a (round 2). a acts in r2, once.
e = apply(e, nextTurn(e));
expect(e.round).toBe(2);
expect(e.currentTurnParticipantId).toBe('a');
});
});
+72
View File
@@ -0,0 +1,72 @@
// 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).
'use strict';
const shared = require('@ttrpg/shared');
const {
makeParticipant,
startEncounter, nextTurn, reorderParticipants,
} = shared;
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:[] };
}
const apply = (e, r) => r && r.patch ? { ...e, ...r.patch } : e;
describe('BUG-13: cross-pointer reorder blocked during active encounter', () => {
test('dragging not-yet-acted participant ahead of pointer = no-op', () => {
// [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 = apply(e, startEncounter(e)); // a (idx0, pointer)
e = apply(e, nextTurn(e)); // 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 r = reorderParticipants(e, 'c', 'b');
expect(r.patch).toBeNull();
expect(e.turnOrderIds).toEqual(['a','b','c']); // unchanged
});
test('dragging already-acted participant behind pointer = no-op', () => {
// [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 = apply(e, startEncounter(e)); // a (pointer idx0)
e = apply(e, nextTurn(e)); // b (pointer idx1)
// Drag a (already acted, ahead of pointer) after c (behind pointer).
// Crosses pointer. Would let a act again. Blocked.
const r = reorderParticipants(e, 'a', 'c');
expect(r.patch).toBeNull();
});
test('reorder within same side of pointer = allowed', () => {
// [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 = apply(e, startEncounter(e)); // a (idx0)
// swap b,c (both idx1,2 — behind pointer). No cross.
const r = reorderParticipants(e, 'c', 'b');
expect(r.patch).not.toBeNull();
expect(r.patch.participants.map(p => p.id)).toEqual(['a','c','b','d']);
});
test('pre-combat: free reorder (no pointer)', () => {
// isStarted=false. No pointer. Reorder allowed freely.
let e = enc([p('a',10),p('b',10),p('c',10)]);
const r = reorderParticipants(e, 'c', 'a');
expect(r.patch).not.toBeNull();
expect(r.patch.participants.map(p => p.id)).toEqual(['c','a','b']);
});
});
+54
View File
@@ -0,0 +1,54 @@
// BUG-7: reorderParticipants not logged.
// Every combat op returns { patch, log }. Handler calls logAction(log.message,
// ctx, { updates: log.undo }) → writes entry to logs collection.
// reorderParticipants returns log: null → handler skips logAction → drag
// invisible in combat log + no undo payload (siblings all have one).
//
// RED: prove log is null (bug). Fix: return { message, undo }.
'use strict';
const shared = require('@ttrpg/shared');
const { makeParticipant, reorderParticipants } = shared;
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', () => {
const e = enc([p('a', 10), p('b', 10), p('c', 10)]);
const r = reorderParticipants(e, 'c', 'b');
expect(r.patch).not.toBeNull();
expect(r.log).not.toBeNull();
expect(typeof r.log.message).toBe('string');
expect(r.log.message.length).toBeGreaterThan(0);
expect(r.log.undo).toBeDefined();
});
test('undo restores original participants order', () => {
const e = enc([p('a', 10), p('b', 10), p('c', 10)]);
const orig = e.participants.map(p => p.id);
const r = reorderParticipants(e, 'c', 'b');
const restored = { ...e, ...r.log.undo };
expect(restored.participants.map(p => p.id)).toEqual(orig);
});
test('no-op (same id) returns null log', () => {
const e = enc([p('a', 10), p('b', 10)]);
const r = reorderParticipants(e, 'a', 'a');
expect(r.log).toBeNull();
});
test('no-op (cross-init blocked) returns null log', () => {
const e = enc([p('a', 10), p('b', 5)]);
const r = reorderParticipants(e, 'a', 'b');
expect(r.patch).toBeNull();
expect(r.log).toBeNull();
});
});
+12 -33
View File
@@ -6,7 +6,6 @@ const shared = require('@ttrpg/shared');
const { const {
sortParticipantsByInitiative, sortParticipantsByInitiative,
computeTurnOrderAfterRemoval, computeTurnOrderAfterRemoval,
computeTurnOrderAfterAddition,
startEncounter, startEncounter,
nextTurn, nextTurn,
togglePause, togglePause,
@@ -248,22 +247,22 @@ describe('applyHpChange', () => {
}); });
describe('deathSave', () => { describe('deathSave', () => {
test('increments saves', () => { test('increments fails', () => {
const ps = [p('a', 10, { currentHp: 0, deathSaves: 0 })]; const ps = [p('a', 10, { currentHp: 0, deathFails: 0 })];
const { patch } = deathSave(enc(ps), 'a', 1); const { patch } = deathSave(enc(ps), 'a', 'fail', 1);
expect(patch.participants[0].deathSaves).toBe(1); expect(patch.participants[0].deathFails).toBe(1);
}); });
test('clicking same save decrements (toggle)', () => { test('clicking same fail decrements (toggle)', () => {
const ps = [p('a', 10, { currentHp: 0, deathSaves: 2 })]; const ps = [p('a', 10, { currentHp: 0, deathFails: 2 })];
const { patch } = deathSave(enc(ps), 'a', 2); const { patch } = deathSave(enc(ps), 'a', 'fail', 2);
expect(patch.participants[0].deathSaves).toBe(1); expect(patch.participants[0].deathFails).toBe(1);
}); });
test('third save sets isDying', () => { test('third fail sets isDying', () => {
const ps = [p('a', 10, { currentHp: 0, deathSaves: 2 })]; const ps = [p('a', 10, { currentHp: 0, deathFails: 2 })];
const result = deathSave(enc(ps), 'a', 3); const result = deathSave(enc(ps), 'a', 'fail', 3);
expect(result.patch.participants[0].deathSaves).toBe(3); expect(result.patch.participants[0].deathFails).toBe(3);
expect(result.patch.participants[0].isDying).toBe(true); expect(result.patch.participants[0].isDying).toBe(true);
expect(result.isDying).toBe(true); expect(result.isDying).toBe(true);
}); });
@@ -326,26 +325,6 @@ describe('computeTurnOrderAfterRemoval', () => {
}); });
}); });
describe('computeTurnOrderAfterAddition', () => {
test('not started = empty', () => {
const out = computeTurnOrderAfterAddition(enc([]), 'a');
expect(out).toEqual({});
});
test('returns insertAt (1-list: caller splices + syncs)', () => {
const e = enc([p('a',3)], { isStarted: true, turnOrderIds: ['a'], participants: [p('a',3)] });
const out = computeTurnOrderAfterAddition(e, 'a');
// already present → no-op
expect(out).toEqual({});
});
test('no-op if already present', () => {
const e = enc([], { isStarted: true, turnOrderIds: ['a', 'b'] });
const out = computeTurnOrderAfterAddition(e, 'a');
expect(out).toEqual({});
});
});
describe('addParticipant', () => { describe('addParticipant', () => {
test('appends participant', () => { test('appends participant', () => {
const np = p('z', 7); const np = p('z', 7);
+40 -3
View File
@@ -32,6 +32,9 @@ const CONDITIONS = [
'invisible','paralyzed','petrified','poisoned','prone','restrained', 'invisible','paralyzed','petrified','poisoned','prone','restrained',
'sapped','shield','slowed','stunned','unconscious','vexed', '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 = {}) { function p(id, init, extra = {}) {
return makeParticipant({ return makeParticipant({
@@ -88,7 +91,8 @@ describe('combat integrity (100 rounds, full op coverage)', () => {
let lastPaused = false; let lastPaused = false;
let lastReorder = 0; let lastReorder = 0;
let reinforcementsAdded = 0; let reinforcementsAdded = 0;
const condQueue = [...CONDITIONS]; // built-ins first, then custom (proves arbitrary string accepted)
const condQueue = [...CONDITIONS, ...CUSTOM_CONDITIONS];
for (let roundN = 1; roundN <= ROUNDS; roundN++) { for (let roundN = 1; roundN <= ROUNDS; roundN++) {
const startRound = e.round; const startRound = e.round;
@@ -148,7 +152,7 @@ describe('combat integrity (100 rounds, full op coverage)', () => {
const living = e.participants.filter(p => p.currentHp > 0 && p.isActive !== false); const living = e.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
if (living.length > 0) { if (living.length > 0) {
const tgt = pick(living); const tgt = pick(living);
const cond = pick(CONDITIONS); const cond = pick([...CONDITIONS, ...CUSTOM_CONDITIONS]);
try { e = apply(e, toggleCondition(e, tgt.id, cond)); } catch (err) {} try { e = apply(e, toggleCondition(e, tgt.id, cond)); } catch (err) {}
} }
} }
@@ -162,7 +166,7 @@ describe('combat integrity (100 rounds, full op coverage)', () => {
} }
// 5. deathSave // 5. deathSave
if (actor && actor.currentHp <= 0 && !actor.isNpc) { if (actor && actor.currentHp <= 0 && !actor.isNpc) {
try { e = apply(e, deathSave(e, actor.id, 1)); } catch (err) {} try { e = apply(e, deathSave(e, actor.id, 'fail', 1)); } catch (err) {}
} }
// 6. removeParticipant // 6. removeParticipant
if (totalTurns % 5 === 0) { if (totalTurns % 5 === 0) {
@@ -231,7 +235,16 @@ describe('combat integrity (100 rounds, full op coverage)', () => {
if (typeof part.currentHp !== 'number' || isNaN(part.currentHp) || part.currentHp < 0 || part.currentHp > part.maxHp) { 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 }); 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 // revive dead between rounds
const dead = e.participants.filter(p => p.currentHp <= 0 || p.isActive === false); const dead = e.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
@@ -254,4 +267,28 @@ describe('combat integrity (100 rounds, full op coverage)', () => {
} }
expect(violations).toHaveLength(0); 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', () => {
let e = setupEncounter();
e = apply(e, startEncounter(e));
const tgt = e.participants[0].id;
// add custom (not in built-ins)
e = apply(e, toggleCondition(e, tgt, 'hexed'));
let p = e.participants.find(x => x.id === tgt);
expect(p.conditions).toContain('hexed');
// emoji-bearing custom id
e = apply(e, toggleCondition(e, tgt, '🛡️blessed'));
p = e.participants.find(x => x.id === tgt);
expect(p.conditions).toContain('🛡️blessed');
// remove
e = apply(e, toggleCondition(e, tgt, 'hexed'));
p = e.participants.find(x => x.id === tgt);
expect(p.conditions).not.toContain('hexed');
expect(p.conditions).toContain('🛡️blessed');
});
}); });
+2 -2
View File
@@ -56,10 +56,10 @@ describe('unified: death deactivates, dead skipped in rotation', () => {
// kill b (current = a) // kill b (current = a)
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch }; e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
// b is dead: DM can still fire deathSave (manual action) // b is dead: DM can still fire deathSave (manual action)
const r = deathSave(e, 'b', 1); const r = deathSave(e, 'b', 'fail', 1);
expect(r.patch).toBeTruthy(); expect(r.patch).toBeTruthy();
const b = r.patch.participants.find(x => x.id === 'b'); const b = r.patch.participants.find(x => x.id === 'b');
expect(b.deathSaves).toBe(1); expect(b.deathFails).toBe(1);
}); });
// D1 unification: shared now matches App main — death flips isActive=false. // D1 unification: shared now matches App main — death flips isActive=false.
+90
View File
@@ -0,0 +1,90 @@
// Death saves: broken model. Current = single counter, 3=dying. D&D 5e needs
// separate success/fail tracking:
// 3 successes = stable (0hp unconscious, safe until healed)
// 3 failures = dead
// Current tracks "saves" (really fails via red X UI), no successes at all.
// "Stable" state doesn't exist.
//
// RED: prove current can't model 3-success stability.
'use strict';
const shared = require('@ttrpg/shared');
const { makeParticipant, startEncounter, applyHpChange, deathSave } = shared;
function p(id) {
return makeParticipant({ id, name: id, type: 'character',
initiative: 10, maxHp: 100, currentHp: 100 });
}
function enc(ps, extra = {}) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra };
}
const apply = (e, r) => r && r.patch ? { ...e, ...r.patch } : e;
describe('death saves: missing success/stable model', () => {
test('3 successes makes character stable (not dead, not dying)', () => {
// Character at 0hp. Roll 3 successful death saves.
let e = enc([p('a')]);
e = apply(e, startEncounter(e));
e = apply(e, applyHpChange(e, 'a', 'damage', 100)); // 0hp
expect(e.participants[0].currentHp).toBe(0);
// Roll 3 successful saves. Should mark stable.
e = apply(e, deathSave(e, 'a', 'success', 1));
e = apply(e, deathSave(e, 'a', 'success', 2));
const r3 = deathSave(e, 'a', 'success', 3);
e = apply(e, r3);
// RED: current deathSave signature = (enc, id, saveNumber). No type arg.
// Will throw or misbehave. Want: status field.
expect(r3.status).toBe('stable');
expect(e.participants[0].isDying).toBe(false);
expect(e.participants[0].isStable).toBe(true);
expect(e.participants[0].deathFails).toBe(0);
});
test('3 failures makes character dead (isDying for removal)', () => {
let e = enc([p('a')]);
e = apply(e, startEncounter(e));
e = apply(e, applyHpChange(e, 'a', 'damage', 100));
const r1 = deathSave(e, 'a', 'fail', 1);
e = apply(e, r1);
const r2 = deathSave(e, 'a', 'fail', 2);
e = apply(e, r2);
const r3 = deathSave(e, 'a', 'fail', 3);
e = apply(e, r3);
expect(r3.status).toBe('dead');
expect(e.participants[0].isDying).toBe(true);
expect(e.participants[0].deathFails).toBe(3);
});
test('successes and fails tracked independently', () => {
// Mixed: 1 success, 2 fails, then 2 more successes = stable.
let e = enc([p('a')]);
e = apply(e, startEncounter(e));
e = apply(e, applyHpChange(e, 'a', 'damage', 100));
e = apply(e, deathSave(e, 'a', 'success', 1));
expect(e.participants[0].deathSaves).toBe(1);
expect(e.participants[0].deathFails).toBe(0);
e = apply(e, deathSave(e, 'a', 'fail', 1));
e = apply(e, deathSave(e, 'a', 'fail', 2));
expect(e.participants[0].deathSaves).toBe(1);
expect(e.participants[0].deathFails).toBe(2);
// 2 more successes → total 3 successes → stable
e = apply(e, deathSave(e, 'a', 'success', 2));
const r = deathSave(e, 'a', 'success', 3);
expect(r.status).toBe('stable');
expect(e.participants[0].deathFails).toBe(2); // fails unchanged
});
test('deathSave signature: (enc, id, type, n)', () => {
// type = 'success' | 'fail'. n = 1|2|3.
expect(deathSave.length).toBe(4);
});
});
+9 -6
View File
@@ -90,12 +90,15 @@ describe('1-list model: turnOrderIds === participants.map(id), no re-sort', () =
expect(e.turnOrderIds).toEqual(['a','b','c']); // unchanged expect(e.turnOrderIds).toEqual(['a','b','c']); // unchanged
}); });
test('reorder same-init: rotation follows new list order', () => { test('reorder same-init: within upcoming side of pointer follows new order', () => {
let e = enc([p('a',10),p('b',10),p('c',3)]); // a,b tie let e = enc([p('a',10),p('b',10),p('c',10),p('d',3)]); // a,b,c tie
e = apply(e, startEncounter(e)); // [a,b,c], cur=a e = apply(e, startEncounter(e)); // [a,b,c,d], cur=a (idx0)
e = apply(e, reorderParticipants(e, 'b', 'a')); // [b,a,c], cur still a // Reorder c before b (both upcoming idx1,2). Within same side = allowed.
const rot = walkRotation(e); // start a, next c (wrap), next b, back a e = apply(e, reorderParticipants(e, 'c', 'b')); // [a,c,b,d], cur=a
expect(rot).toEqual(['a','c','b']); expect(e.turnOrderIds).toEqual(['a','c','b','d']);
// walk: a current, next c, next b, next d, wrap a
e = apply(e, nextTurn(e));
expect(e.currentTurnParticipantId).toBe('c');
}); });
test('toggle inactive: list unchanged (stays in rotation slot)', () => { test('toggle inactive: list unchanged (stays in rotation slot)', () => {
+203
View File
@@ -0,0 +1,203 @@
// Logging contract: every mutating combat op returns { patch, log } where
// log = { message: string, undo: object|null }. No-op (no state change)
// returns { patch: null, log: null }. Display lifecycle ops return { patch }
// only (no log field expected).
//
// logAction handler does:
// if (log) logAction(log.message, ctx, { updates: log.undo })
// → null log = skipped = gap in audit trail.
//
// Contract = every combat mutation MUST produce a log entry.
'use strict';
const shared = require('@ttrpg/shared');
const {
makeParticipant, buildMonsterParticipant,
startEncounter, nextTurn, togglePause,
addParticipant, addParticipants, updateParticipant, removeParticipant,
toggleParticipantActive, applyHpChange, deathSave, toggleCondition,
reorderParticipants, endEncounter,
} = shared;
function p(id, init) {
return makeParticipant({ id, name: id, type: 'monster',
initiative: init, maxHp: 100, currentHp: 100 });
}
function enc(ps, extra = {}) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra };
}
const apply = (e, r) => r && r.patch ? { ...e, ...r.patch } : e;
// Helper: assert a result is a logged mutation (patch + valid log).
function expectLogged(r) {
expect(r.patch).not.toBeNull();
expect(r.log).not.toBeNull();
expect(typeof r.log).toBe('object');
expect(typeof r.log.message).toBe('string');
expect(r.log.message.length).toBeGreaterThan(0);
}
function expectNoOp(r) {
expect(r.patch).toBeNull();
expect(r.log).toBeNull();
}
describe('Logging contract: mutating ops', () => {
let e;
beforeEach(() => {
e = enc([p('a', 10), p('b', 7), p('c', 3)]);
});
test('startEncounter logs', () => {
const r = startEncounter(e);
expectLogged(r);
});
test('nextTurn logs', () => {
const started = apply(e, startEncounter(e));
const r = nextTurn(started);
expectLogged(r);
});
test('togglePause logs', () => {
const r = togglePause(apply(e, startEncounter(e)));
expectLogged(r);
});
test('addParticipant logs', () => {
const r = addParticipant(e, p('d', 5));
expectLogged(r);
});
test('removeParticipant logs', () => {
const r = removeParticipant(e, 'b');
expectLogged(r);
});
test('toggleParticipantActive logs', () => {
const r = toggleParticipantActive(e, 'b');
expectLogged(r);
});
test('applyHpChange (damage) logs', () => {
const r = applyHpChange(e, 'b', 'damage', 10);
expectLogged(r);
});
test('applyHpChange (heal) logs', () => {
const r = applyHpChange(e, 'b', 'heal', 5);
expectLogged(r);
});
test('deathSave logs', () => {
const started = apply(e, startEncounter(e));
const dying = apply(started, applyHpChange(started, 'b', 'damage', 100));
const r = deathSave(dying, 'b', 'fail', 1);
expectLogged(r);
});
test('toggleCondition logs', () => {
const r = toggleCondition(e, 'b', 'poisoned');
expectLogged(r);
});
test('reorderParticipants logs (BUG-7)', () => {
// same-init tie (both 10) for valid reorder
const e2 = enc([p('a', 10), p('x', 10), p('c', 3)]);
const r = reorderParticipants(e2, 'x', 'a');
expectLogged(r);
});
test('endEncounter logs', () => {
const started = apply(e, startEncounter(e));
const r = endEncounter(started);
expectLogged(r);
});
});
describe('Logging contract: no-ops', () => {
let e;
beforeEach(() => {
e = enc([p('a', 10), p('b', 7), p('c', 3)]);
});
test('reorder same-id = no-op', () => {
expectNoOp(reorderParticipants(e, 'a', 'a'));
});
test('reorder cross-init = no-op', () => {
expectNoOp(reorderParticipants(e, 'a', 'b'));
});
});
describe('Logging undo payloads', () => {
let e;
beforeEach(() => {
e = enc([p('a', 10), p('b', 7), p('c', 3)]);
});
test('startEncounter undo restores pre-combat state', () => {
const r = startEncounter(e);
expect(r.log.undo).toBeDefined();
expect(r.log.undo.isStarted).toBe(false);
});
test('endEncounter undo restores combat state', () => {
const started = apply(e, startEncounter(e));
const r = endEncounter(started);
expect(r.log.undo).toBeDefined();
expect(r.log.undo.isStarted).toBe(true);
});
test('applyHpChange undo restores prior hp', () => {
const r = applyHpChange(e, 'b', 'damage', 10);
expect(r.log.undo).toBeDefined();
expect(r.log.undo.participants).toBeDefined();
});
test('reorder undo restores prior order (BUG-7)', () => {
const e2 = enc([p('a', 10), p('x', 10), p('c', 3)]);
const orig = e2.participants.map(p => p.id);
const r = reorderParticipants(e2, 'x', 'a');
expect(r.log.undo).toBeDefined();
const restored = { ...e2, ...r.log.undo };
expect(restored.participants.map(p => p.id)).toEqual(orig);
});
});
describe('Logging: addParticipants + updateParticipant', () => {
let e;
beforeEach(() => {
e = enc([p('a', 10), p('b', 7)]);
});
test('addParticipants logs', () => {
const r = addParticipants(e, [p('c', 3)]);
expectLogged(r);
});
test('updateParticipant (same slot) logs', () => {
const r = updateParticipant(e, 'b', { name: 'B' });
expectLogged(r);
});
test('updateParticipant (init change) logs', () => {
const r = updateParticipant(e, 'b', { initiative: 5 });
expectLogged(r);
});
test('addParticipants undo restores prior list', () => {
const orig = e.participants.map(p => p.id);
const r = addParticipants(e, [p('c', 3)]);
const restored = { ...e, ...r.log.undo };
expect(restored.participants.map(p => p.id)).toEqual(orig);
});
test('updateParticipant undo restores prior participant', () => {
const orig = e.participants.map(p => p.id);
const r = updateParticipant(e, 'b', { name: 'B' });
const restored = { ...e, ...r.log.undo };
expect(restored.participants.map(p => p.id)).toEqual(orig);
});
});
+14 -16
View File
@@ -18,15 +18,12 @@ function enc(ps) {
} }
describe('reorderParticipants', () => { describe('reorderParticipants', () => {
test('drag before target (1-list model)', () => { test('drag before target (1-list model, pre-combat)', () => {
const ps = [p('a', 10), p('b', 20), p('c', 20)]; // b,c tie const ps = [p('a', 10), p('b', 20), p('c', 20)]; // b,c tie
let e = enc(ps); let e = enc(ps); // pre-combat, no pointer
e = { ...e, ...startEncounter(e).patch };
// initial order: b,c,a (init 20,20,10)
expect(e.turnOrderIds).toEqual(['b', 'c', 'a']);
const r = reorderParticipants(e, 'c', 'b'); const r = reorderParticipants(e, 'c', 'b');
// drag c before b: remove c → [b,a], insert before b → [c,b,a] // drag c before b: remove c → [a,b], insert before b → [a,c,b]
expect(r.patch.participants.map(p => p.id)).toEqual(['c', 'b', 'a']); expect(r.patch.participants.map(p => p.id)).toEqual(['a', 'c', 'b']);
}); });
test('cross-init drag blocked (no-op)', () => { test('cross-init drag blocked (no-op)', () => {
@@ -47,21 +44,22 @@ describe('reorderParticipants', () => {
test('syncs turnOrderIds = participants order (1-list, fixes BUG-6)', () => { test('syncs turnOrderIds = participants order (1-list, fixes BUG-6)', () => {
const ps = [p('a', 10), p('b', 20), p('c', 20)]; const ps = [p('a', 10), p('b', 20), p('c', 20)];
let e = enc(ps); let e = enc(ps);
e = { ...e, ...startEncounter(e).patch }; e = { ...e, ...startEncounter(e).patch }; // started, but reorder pre-pointer advance
const r = reorderParticipants(e, 'c', 'b'); // startEncounter sets current=b (idx0). reorder c before b crosses pointer.
expect(r.patch.turnOrderIds).toEqual(['c', 'b', 'a']); // Use pre-combat to test syncTurnOrder.
expect(r.patch.turnOrderIds).toEqual(r.patch.participants.map(p => p.id)); let pre = enc(ps);
pre = { ...pre, ...reorderParticipants(pre, 'c', 'b').patch };
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 // BUG-6 candidate: reorder should affect turnOrderIds so mid-combat
// drag-drop changes who goes next within same-initiative tie. // drag-drop changes who goes next within same-initiative tie.
// Currently RED (turnOrderIds not in patch). // Currently RED (turnOrderIds not in patch).
test('reorder updates turnOrderIds to reflect new participant order', () => { test('reorder updates turnOrderIds to reflect new participant order (pre-combat)', () => {
const ps = [p('a', 10), p('b', 20), p('c', 20)]; const ps = [p('a', 10), p('b', 20), p('c', 20)];
let e = enc(ps); let e = enc(ps); // pre-combat, no pointer
e = { ...e, ...startEncounter(e).patch };
// order: b,c,a
e = { ...e, ...reorderParticipants(e, 'c', 'b').patch }; e = { ...e, ...reorderParticipants(e, 'c', 'b').patch };
expect(e.turnOrderIds).toEqual(['c', 'b', 'a']); expect(e.turnOrderIds).toEqual(['a', 'c', 'b']);
}); });
}); });
+113 -47
View File
@@ -114,26 +114,6 @@ const computeTurnOrderAfterRemoval = (encounter, removedId, updatedParticipants)
// current pointer — no re-sort anywhere except startEncounter. // current pointer — no re-sort anywhere except startEncounter.
// Tie rule: insert AFTER existing same-init (preserves creation order). // Tie rule: insert AFTER existing same-init (preserves creation order).
// NOTE: 1-list model — caller syncs participants[] in same pos as insert target. // NOTE: 1-list model — caller syncs participants[] in same pos as insert target.
const computeTurnOrderAfterAddition = (encounter, addedId) => {
if (!encounter.isStarted) return {};
const currentIds = encounter.turnOrderIds || [];
if (currentIds.includes(addedId)) return {};
const added = (encounter.participants || []).find(p => p.id === addedId);
if (!added) return {};
// find first id with strictly lower initiative; insert before it (== after all >= )
const initOf = id => {
const p = (encounter.participants || []).find(x => x.id === id);
return p ? (p.initiative || 0) : 0;
};
const addedInit = added.initiative || 0;
let insertAt = currentIds.length;
for (let i = 0; i < currentIds.length; i++) {
if (initOf(currentIds[i]) < addedInit) { insertAt = i; break; }
}
return { insertAt }; // caller splices participants[] at this pos, then syncs
};
// ----------------------------------------------------------------------------
// Participant factory (mirrors ParticipantManager.handleAddParticipant shape) // Participant factory (mirrors ParticipantManager.handleAddParticipant shape)
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@@ -149,7 +129,9 @@ function makeParticipant(opts) {
isNpc: opts.isNpc || false, isNpc: opts.isNpc || false,
conditions: opts.conditions || [], conditions: opts.conditions || [],
isActive: opts.isActive !== undefined ? opts.isActive : true, isActive: opts.isActive !== undefined ? opts.isActive : true,
deathSaves: opts.deathSaves || 0, deathSaves: opts.deathSaves || 0, // successes (0-3)
deathFails: opts.deathFails || 0, // failures (0-3)
isStable: opts.isStable || false,
isDying: opts.isDying || false, isDying: opts.isDying || false,
}; };
} }
@@ -326,7 +308,7 @@ function togglePause(encounter) {
// ADD_PARTICIPANT — appends participant. (Initiative rolled by caller via build*.) // ADD_PARTICIPANT — appends participant. (Initiative rolled by caller via build*.)
// If encounter already started, also slot participant into turnOrderIds by // If encounter already started, also slot participant into turnOrderIds by
// initiative (via computeTurnOrderAfterAddition). // initiative (slotIndexForInit). 1-list model.
function addParticipant(encounter, participant) { function addParticipant(encounter, participant) {
if ((encounter.participants || []).some(p => p.id === participant.id)) { if ((encounter.participants || []).some(p => p.id === participant.id)) {
throw new Error(`Participant with id "${participant.id}" already exists in encounter.`); throw new Error(`Participant with id "${participant.id}" already exists in encounter.`);
@@ -353,8 +335,16 @@ function addParticipant(encounter, participant) {
// ADD_PARTICIPANTS — bulk add (e.g. "add all campaign characters"). // ADD_PARTICIPANTS — bulk add (e.g. "add all campaign characters").
function addParticipants(encounter, newParticipants) { function addParticipants(encounter, newParticipants) {
const undo = { participants: encounter.participants || [] };
const updatedParticipants = [...(encounter.participants || []), ...newParticipants]; const updatedParticipants = [...(encounter.participants || []), ...newParticipants];
return { patch: { participants: updatedParticipants }, log: null }; const names = newParticipants.map(p => p.name).join(', ');
return {
patch: { participants: updatedParticipants },
log: {
message: `Added ${newParticipants.length} participant${newParticipants.length === 1 ? '' : 's'}: ${names}`,
undo,
},
};
} }
// UPDATE_PARTICIPANT — edit modal save (name/initiative/hp/isNpc). // UPDATE_PARTICIPANT — edit modal save (name/initiative/hp/isNpc).
@@ -366,14 +356,28 @@ function updateParticipant(encounter, participantId, updatedData) {
// SLOT only if initiative changed. Other edits (name/hp/notes/isNpc) = no // SLOT only if initiative changed. Other edits (name/hp/notes/isNpc) = no
// position change (design doc). Re-slots on unrelated edits shuffle the // position change (design doc). Re-slots on unrelated edits shuffle the
// list mid-round → rotation dupes. // list mid-round → rotation dupes.
const undo = { participants: encounter.participants || [] };
if (!('initiative' in updatedData) || updatedData.initiative === target.initiative) { if (!('initiative' in updatedData) || updatedData.initiative === target.initiative) {
const sameSlot = existing.map(p => p.id === participantId ? merged : p); const sameSlot = existing.map(p => p.id === participantId ? merged : p);
return { patch: { participants: sameSlot, ...syncTurnOrder(sameSlot) }, log: null }; const fields = Object.keys(updatedData).join(', ');
return {
patch: { participants: sameSlot, ...syncTurnOrder(sameSlot) },
log: {
message: `${target.name} updated (${fields})`,
undo,
},
};
} }
const without = existing.filter(p => p.id !== participantId); const without = existing.filter(p => p.id !== participantId);
const idx = slotIndexForInit(without, merged.initiative); const idx = slotIndexForInit(without, merged.initiative);
without.splice(idx, 0, merged); without.splice(idx, 0, merged);
return { patch: { participants: without, ...syncTurnOrder(without) }, log: null }; return {
patch: { participants: without, ...syncTurnOrder(without) },
log: {
message: `${target.name} updated (initiative: ${merged.initiative})`,
undo,
},
};
} }
// REMOVE_PARTICIPANT — verbatim from ParticipantManager.confirmDeleteParticipant // REMOVE_PARTICIPANT — verbatim from ParticipantManager.confirmDeleteParticipant
@@ -490,30 +494,64 @@ function applyHpChange(encounter, participantId, changeType, amount) {
}; };
} }
// DEATH_SAVE — verbatim from ParticipantManager.handleDeathSaveChange // DEATH_SAVE — D&D 5e model. Separate success/fail tracking.
// saveNumber: 1 | 2 | 3. Returns isDying flag if 3rd save hit (client triggers removal animation). // type: 'success' | 'fail'
function deathSave(encounter, participantId, saveNumber) { // n: 1 | 2 | 3 (which pip clicked)
// 3 successes = stable (0hp unconscious, safe). 3 fails = dead (isDying,
// caller animates removal). Toggling same pip = undo that pip.
// Returns { patch, log, status } where status: 'stable'|'dead'|'pending'.
function deathSave(encounter, participantId, type, n) {
const participant = (encounter.participants || []).find(p => p.id === participantId); const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.'); if (!participant) throw new Error('Participant not found.');
const currentSaves = participant.deathSaves || 0; if (type !== 'success' && type !== 'fail') {
const newSaves = currentSaves === saveNumber ? saveNumber - 1 : saveNumber; throw new Error(`deathSave type must be 'success' or 'fail', got "${type}".`);
}
const name = participant.name;
const undo = { participants: encounter.participants || [] };
if (newSaves === 3) { // Toggle: clicking current-value pip decrements (undo that pip).
// Mark dying — caller waits for animation, then calls removeParticipant. const current = type === 'success'
const updatedParticipants = (encounter.participants || []).map(p => ? (participant.deathSaves || 0)
p.id === participantId ? { ...p, deathSaves: newSaves, isDying: true } : p : (participant.deathFails || 0);
); const next = current === n ? n - 1 : n;
return {
patch: { participants: updatedParticipants }, const field = type === 'success' ? 'deathSaves' : 'deathFails';
log: null, const updates = { [field]: next };
isDying: true,
}; // Clear terminal flags on any change (re-eval below).
let status = 'pending';
const newSuccesses = type === 'success' ? next : (participant.deathSaves || 0);
const newFails = type === 'fail' ? next : (participant.deathFails || 0);
if (newSuccesses >= 3) {
updates.isStable = true;
updates.isDying = false;
status = 'stable';
} else if (newFails >= 3) {
updates.isStable = false;
updates.isDying = true;
status = 'dead';
} else {
updates.isStable = false;
updates.isDying = false;
} }
const updatedParticipants = (encounter.participants || []).map(p => const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, deathSaves: newSaves } : p p.id === participantId ? { ...p, ...updates } : p
); );
return { patch: { participants: updatedParticipants }, log: null, isDying: false };
const message = status === 'stable'
? `${name} stabilized (3 death saves)`
: status === 'dead'
? `${name} failed 3 death saves — dead`
: `${name} death ${type}: ${next}/3`;
return {
patch: { participants: updatedParticipants },
log: { message, undo },
status,
isDying: status === 'dead', // back-compat for App.js handler
};
} }
// TOGGLE_CONDITION — verbatim from ParticipantManager.toggleCondition // TOGGLE_CONDITION — verbatim from ParticipantManager.toggleCondition
@@ -536,10 +574,13 @@ function toggleCondition(encounter, participantId, conditionId) {
}; };
} }
// REORDER_PARTICIPANTS — drag-drop. 1-list model: drag overrides initiative
// (DM choice). Cross-init drag allowed. Splices participants[], syncs turnOrderIds.
// REORDER_PARTICIPANTS — drag. Same-initiative ONLY (tie-break override). // REORDER_PARTICIPANTS — drag. Same-initiative ONLY (tie-break override).
// Cross-init drag = no-op (use edit-initiative field instead). Splice move. // Cross-init drag = no-op (use edit-initiative field instead). Splice move.
// Cross-pointer drag = no-op once encounter started (BUG-13). nextTurn walks
// turnOrderIds forward from current pointer. Dragging a not-yet-acted
// participant to a position behind the pointer would skip them (wrap past).
// Dragging an already-acted participant ahead of pointer would re-act them.
// Blocking cross-pointer keeps rotation deterministic. Pre-combat: free.
function reorderParticipants(encounter, draggedId, targetId) { function reorderParticipants(encounter, draggedId, targetId) {
const participants = [...(encounter.participants || [])]; const participants = [...(encounter.participants || [])];
const dragged = participants.find(p => p.id === draggedId); const dragged = participants.find(p => p.id === draggedId);
@@ -550,11 +591,37 @@ function reorderParticipants(encounter, draggedId, targetId) {
return { patch: null, log: null }; // cross-init blocked return { patch: null, log: null }; // cross-init blocked
} }
const draggedIndex = participants.findIndex(p => p.id === draggedId); const draggedIndex = participants.findIndex(p => p.id === draggedId);
// BUG-13: block cross-pointer reorder while encounter running. nextTurn
// walks turnOrderIds forward from current pointer. Dragging across the
// pointer makes who-acts-next ambiguous — either skip (upcoming dragged
// ahead of pointer, walk wraps past) or double-act (acted dragged behind).
// Full fix needs actedThisRound tracking. Pragmatic now: block both dirs.
// Pre-combat: free reorder (no pointer yet).
if (encounter.isStarted && encounter.currentTurnParticipantId) {
const pointerIdx = participants.findIndex(p => p.id === encounter.currentTurnParticipantId);
const targetIdx0 = participants.findIndex(p => p.id === targetId);
if (pointerIdx >= 0) {
const crosses = (a, b) => (a <= pointerIdx && b > pointerIdx) || (a > pointerIdx && b <= pointerIdx);
if (crosses(draggedIndex, targetIdx0)) {
return { patch: null, log: null }; // cross-pointer blocked
}
}
}
const [removedItem] = participants.splice(draggedIndex, 1); const [removedItem] = participants.splice(draggedIndex, 1);
const newTargetIndex = participants.findIndex(p => p.id === targetId); const newTargetIndex = participants.findIndex(p => p.id === targetId);
participants.splice(newTargetIndex, 0, removedItem); participants.splice(newTargetIndex, 0, removedItem);
const turnUpdates = encounter.isStarted ? syncTurnOrder(participants) : {}; const turnUpdates = syncTurnOrder(participants); // 1-list: always mirror
return { patch: { participants, ...turnUpdates }, log: null }; return {
patch: { participants, ...turnUpdates },
log: {
message: `${removedItem.name} reordered before ${(participants.find(p => p.id === targetId) || {}).name || targetId}`,
undo: {
participants: encounter.participants || [],
turnOrderIds: encounter.turnOrderIds || [],
currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
},
},
};
} }
// END_ENCOUNTER — verbatim from InitiativeControls.confirmEndEncounter // END_ENCOUNTER — verbatim from InitiativeControls.confirmEndEncounter
@@ -611,7 +678,6 @@ module.exports = {
sortParticipantsByInitiative, sortParticipantsByInitiative,
syncTurnOrder, syncTurnOrder,
computeTurnOrderAfterRemoval, computeTurnOrderAfterRemoval,
computeTurnOrderAfterAddition,
makeParticipant, makeParticipant,
buildCharacterParticipant, buildCharacterParticipant,
buildMonsterParticipant, buildMonsterParticipant,
+144 -20
View File
@@ -797,7 +797,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
// PARTICIPANT MANAGER COMPONENT // PARTICIPANT MANAGER COMPONENT
// ============================================================================ // ============================================================================
function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { function ParticipantManager({ encounter, encounterPath, campaignCharacters, campaignId }) {
const [participantName, setParticipantName] = useState(''); const [participantName, setParticipantName] = useState('');
const [participantType, setParticipantType] = useState('monster'); const [participantType, setParticipantType] = useState('monster');
const [selectedCharacterId, setSelectedCharacterId] = useState(''); const [selectedCharacterId, setSelectedCharacterId] = useState('');
@@ -812,6 +812,17 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
const [itemToDelete, setItemToDelete] = useState(null); const [itemToDelete, setItemToDelete] = useState(null);
const [lastRollDetails, setLastRollDetails] = useState(null); const [lastRollDetails, setLastRollDetails] = useState(null);
const [openConditionsId, setOpenConditionsId] = useState(null); const [openConditionsId, setOpenConditionsId] = useState(null);
const [customConditionInput, setCustomConditionInput] = useState('');
// Per-campaign custom conditions (DM-added freeform strings).
const { data: campaignDoc } = useFirestoreDocument(campaignId ? getPath.campaign(campaignId) : null);
const customConditions = (campaignDoc && campaignDoc.customConditions) || [];
// Merged condition list: built-ins + custom. Custom = { id: label, label, emoji: '' }.
// id may contain spaces/emoji — toggleCondition stores raw string.
const allConditions = [
...CONDITIONS,
...customConditions.map(label => ({ id: label, label, emoji: '' })),
];
const participants = encounter.participants || []; const participants = encounter.participants || [];
@@ -873,6 +884,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
conditions: [], conditions: [],
isActive: true, isActive: true,
deathSaves: 0, deathSaves: 0,
deathFails: 0,
isStable: false,
isDying: false, isDying: false,
}; };
@@ -930,6 +943,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
isActive: true, isActive: true,
isNpc: false, isNpc: false,
deathSaves: 0, deathSaves: 0,
deathFails: 0,
isStable: false,
isDying: false, isDying: false,
}; };
}); });
@@ -940,8 +955,14 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
} }
try { try {
const { patch } = addParticipants(encounter, newParticipants); const { patch, log } = addParticipants(encounter, newParticipants);
await storage.updateDoc(encounterPath, patch); await storage.updateDoc(encounterPath, patch);
if (log) {
logAction(log.message, { encounterName: encounter.name }, {
encounterPath,
updates: log.undo,
});
}
} catch (err) { } catch (err) {
alert("Failed to add all characters. Please try again."); alert("Failed to add all characters. Please try again.");
} }
@@ -950,8 +971,14 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
const handleUpdateParticipant = async (updatedData) => { const handleUpdateParticipant = async (updatedData) => {
if (!db || !editingParticipant) return; if (!db || !editingParticipant) return;
try { try {
const { patch } = updateParticipant(encounter, editingParticipant.id, updatedData); const { patch, log } = updateParticipant(encounter, editingParticipant.id, updatedData);
await storage.updateDoc(encounterPath, patch); await storage.updateDoc(encounterPath, patch);
if (log) {
logAction(log.message, { encounterName: encounter.name }, {
encounterPath,
updates: log.undo,
});
}
setEditingParticipant(null); setEditingParticipant(null);
} catch (err) { } catch (err) {
alert("Failed to update participant. Please try again."); alert("Failed to update participant. Please try again.");
@@ -967,8 +994,14 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
const n = parseInt(value, 10); const n = parseInt(value, 10);
if (isNaN(n)) return; if (isNaN(n)) return;
try { try {
const { patch } = updateParticipant(encounter, participantId, { initiative: n }); const { patch, log } = updateParticipant(encounter, participantId, { initiative: n });
await storage.updateDoc(encounterPath, patch); await storage.updateDoc(encounterPath, patch);
if (log) {
logAction(log.message, { encounterName: encounter.name }, {
encounterPath,
updates: log.undo,
});
}
} catch (err) { } catch (err) {
// fall through silently // fall through silently
} }
@@ -1035,10 +1068,10 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
} }
}; };
const handleDeathSaveChange = async (participantId, saveNumber) => { const handleDeathSaveChange = async (participantId, type, saveNumber) => {
if (!db) return; if (!db) return;
try { try {
const { patch, isDying } = combatDeathSave(encounter, participantId, saveNumber); const { patch, isDying } = combatDeathSave(encounter, participantId, type, saveNumber);
await storage.updateDoc(encounterPath, patch); await storage.updateDoc(encounterPath, patch);
if (isDying) { if (isDying) {
setTimeout(async () => { setTimeout(async () => {
@@ -1056,8 +1089,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
try { try {
const { patch, log } = combatToggleCondition(encounter, participantId, conditionId); const { patch, log } = combatToggleCondition(encounter, participantId, conditionId);
await storage.updateDoc(encounterPath, patch); await storage.updateDoc(encounterPath, patch);
const cond = CONDITIONS.find(c => c.id === conditionId); const cond = allConditions.find(c => c.id === conditionId);
const condLabel = cond ? `${cond.label} ${cond.emoji}` : conditionId; const condLabel = cond ? `${cond.label} ${cond.emoji || ''}`.trim() : conditionId;
logAction(log.message.replace(conditionId, condLabel), { encounterName: encounter.name }, { logAction(log.message.replace(conditionId, condLabel), { encounterName: encounter.name }, {
encounterPath, encounterPath,
updates: log.undo, updates: log.undo,
@@ -1067,6 +1100,31 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
} }
}; };
// Add freeform condition: persist to campaign palette + apply to current participant.
const addCustomCondition = async (label, participantId = null) => {
const trimmed = (label || '').trim();
if (!trimmed || !campaignId) return;
setCustomConditionInput('');
const exists = allConditions.some(c => c.label.toLowerCase() === trimmed.toLowerCase());
if (!exists) {
try {
await storage.updateDoc(getPath.campaign(campaignId), { customConditions: [...customConditions, trimmed] });
} catch (err) {}
}
if (participantId) {
const cur = (encounter.participants.find(p => p.id === participantId)?.conditions) || [];
if (!cur.includes(trimmed)) {
try {
const { patch, log } = combatToggleCondition(encounter, participantId, trimmed);
await storage.updateDoc(encounterPath, patch);
logAction(log.message.replace(trimmed, trimmed), { encounterName: encounter.name }, {
encounterPath, updates: log.undo,
});
} catch (err) {}
}
}
};
const handleDragStart = (e, id) => { const handleDragStart = (e, id) => {
setDraggedItemId(id); setDraggedItemId(id);
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
@@ -1084,8 +1142,14 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
return; return;
} }
try { try {
const { patch } = reorderParticipants(encounter, draggedItemId, targetId); const { patch, log } = reorderParticipants(encounter, draggedItemId, targetId);
await storage.updateDoc(encounterPath, patch); await storage.updateDoc(encounterPath, patch);
if (log) {
logAction(log.message, { encounterName: encounter.name }, {
encounterPath,
updates: log.undo,
});
}
} catch (err) { } catch (err) {
// drag invalid (id not found) — ignore // drag invalid (id not found) — ignore
} }
@@ -1346,28 +1410,49 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
<span>HP: {p.currentHp}/{p.maxHp}</span> <span>HP: {p.currentHp}/{p.maxHp}</span>
</div> </div>
{/* Death Saves - only player characters make death saving throws */} {/* Death saves - D&D 5e: successes + fails separate */}
{isDead && encounter.isStarted && p.type === 'character' && ( {isDead && encounter.isStarted && p.type === 'character' && (
<div className="mt-2 flex items-center space-x-2"> <div className="mt-2 flex flex-col space-y-1">
<span className="text-xs text-red-300 font-medium">Death Saves:</span> {p.isStable && (
<div className="text-xs text-emerald-300 font-medium">Stabilized</div>
)}
{p.isDying && !p.isStable && (
<div className="text-xs text-red-400 font-medium animate-pulse">Dying</div>
)}
<div className="flex items-center space-x-2">
<span className="text-xs text-emerald-300 font-medium w-10">Saves:</span>
{[1, 2, 3].map(saveNum => ( {[1, 2, 3].map(saveNum => (
<button <button
key={saveNum} key={saveNum}
onClick={() => handleDeathSaveChange(p.id, saveNum)} onClick={() => handleDeathSaveChange(p.id, 'success', saveNum)}
className={`w-6 h-6 rounded border-2 transition-all ${(p.deathSaves || 0) >= saveNum ? 'bg-red-600 border-red-500' : 'bg-stone-800 border-stone-600 hover:border-red-400'} ${saveNum === 3 && (p.deathSaves || 0) === 3 ? 'animate-pulse' : ''}`} className={`w-6 h-6 rounded border-2 transition-all ${(p.deathSaves || 0) >= saveNum ? 'bg-emerald-600 border-emerald-500' : 'bg-stone-800 border-stone-600 hover:border-emerald-400'}`}
title={`Death save ${saveNum}`} title={`Success ${saveNum}`}
> >
{(p.deathSaves || 0) >= saveNum && <span className="text-white text-sm"></span>} {(p.deathSaves || 0) >= saveNum && <span className="text-white text-sm"></span>}
</button> </button>
))} ))}
</div> </div>
<div className="flex items-center space-x-2">
<span className="text-xs text-red-300 font-medium w-10">Fails:</span>
{[1, 2, 3].map(failNum => (
<button
key={failNum}
onClick={() => handleDeathSaveChange(p.id, 'fail', failNum)}
className={`w-6 h-6 rounded border-2 transition-all ${(p.deathFails || 0) >= failNum ? 'bg-red-600 border-red-500' : 'bg-stone-800 border-stone-600 hover:border-red-400'} ${failNum === 3 && (p.deathFails || 0) >= 3 ? 'animate-pulse' : ''}`}
title={`Fail ${failNum}`}
>
{(p.deathFails || 0) >= failNum && <span className="text-white text-sm"></span>}
</button>
))}
</div>
</div>
)} )}
{/* Active condition badges */} {/* Active condition badges */}
{(p.conditions || []).length > 0 && ( {(p.conditions || []).length > 0 && (
<div className="mt-1 flex flex-wrap gap-1"> <div className="mt-1 flex flex-wrap gap-1">
{(p.conditions || []).map(cId => { {(p.conditions || []).map(cId => {
const cond = CONDITIONS.find(c => c.id === cId); const cond = allConditions.find(c => c.id === cId);
return cond ? ( return cond ? (
<span <span
key={cId} key={cId}
@@ -1377,7 +1462,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
> >
{cond.emoji} {cond.label} {cond.emoji} {cond.label}
</span> </span>
) : null; ) : (
<span
key={cId}
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full bg-purple-900 border border-purple-600 text-xs text-purple-200 cursor-pointer hover:bg-purple-800"
title={`Remove ${cId}`}
onClick={() => toggleCondition(p.id, cId)}
>
{cId}
</span>
);
})} })}
</div> </div>
)} )}
@@ -1387,7 +1481,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
<div className="mt-2 p-2 bg-stone-900 rounded-md border border-stone-600"> <div className="mt-2 p-2 bg-stone-900 rounded-md border border-stone-600">
<p className="text-xs text-stone-400 mb-1 font-medium">Toggle Conditions</p> <p className="text-xs text-stone-400 mb-1 font-medium">Toggle Conditions</p>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{CONDITIONS.map(cond => { {allConditions.map(cond => {
const active = (p.conditions || []).includes(cond.id); const active = (p.conditions || []).includes(cond.id);
return ( return (
<button <button
@@ -1401,6 +1495,28 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
); );
})} })}
</div> </div>
{/* Add custom condition (per-campaign, freeform string) */}
<div className="mt-2 flex gap-1">
<input
type="text"
value={customConditionInput}
onChange={(e) => setCustomConditionInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); addCustomCondition(customConditionInput, p.id); }
}}
placeholder="Add custom condition..."
className="flex-grow px-2 py-1 text-xs rounded bg-stone-800 border border-stone-600 text-stone-200 placeholder-stone-500 focus:outline-none focus:border-amber-500"
maxLength={40}
/>
<button
type="button"
onClick={() => addCustomCondition(customConditionInput, p.id)}
disabled={!customConditionInput.trim()}
className="px-2 py-1 rounded text-xs bg-amber-700 border border-amber-500 text-white hover:bg-amber-600 disabled:opacity-40 disabled:cursor-not-allowed"
>
Add
</button>
</div>
</div> </div>
)} )}
</div> </div>
@@ -1911,6 +2027,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
encounter={selectedEncounter} encounter={selectedEncounter}
encounterPath={getPath.encounter(campaignId, selectedEncounter.id)} encounterPath={getPath.encounter(campaignId, selectedEncounter.id)}
campaignCharacters={campaignCharacters} campaignCharacters={campaignCharacters}
campaignId={campaignId}
/> />
</div> </div>
</div> </div>
@@ -2473,7 +2590,14 @@ function DisplayView() {
> >
{cond.emoji} {cond.label} {cond.emoji} {cond.label}
</span> </span>
) : null; ) : (
<span
key={cId}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-purple-900 border border-purple-500 text-xs md:text-sm text-purple-200 font-medium"
>
{cId}
</span>
);
})} })}
</div> </div>
)} )}
+4
View File
@@ -8,6 +8,10 @@ import { resetMockDb } from './__mocks__/firebase/_mock-db';
// Must be set before any component renders. // Must be set before any component renders.
globalThis.IS_REACT_ACT_ENVIRONMENT = true; globalThis.IS_REACT_ACT_ENVIRONMENT = true;
// Test timeout guard. CRA blocks `testTimeout` in package.json jest config
// (not in allowlist). Set via jest global here instead. Fails fast on hang.
jest.setTimeout(10000);
// WARNING = FAILURE. Fail test on any console.error/warn (React act warnings, // WARNING = FAILURE. Fail test on any console.error/warn (React act warnings,
// deprecations, prop errors). App code's own error logs allowed only inside // deprecations, prop errors). App code's own error logs allowed only inside
// try/catch failure paths — those tests should assert the error was handled, // try/catch failure paths — those tests should assert the error was handled,
+6 -6
View File
@@ -160,10 +160,10 @@ function removeParticipantByName(name) {
if (!p) throw new Error(`no remove target: ${name}`); if (!p) throw new Error(`no remove target: ${name}`);
applyEnc(removeParticipant(enc, p.id).patch); applyEnc(removeParticipant(enc, p.id).patch);
} }
function deathSaveAction(name, saveNum) { function deathSaveAction(name, type, saveNum) {
const p = any(name); const p = any(name);
if (!p) throw new Error(`no deathsave target: ${name}`); if (!p) throw new Error(`no deathsave target: ${name}`);
applyEnc(combatDeathSave(enc, p.id, saveNum).patch); applyEnc(combatDeathSave(enc, p.id, type, saveNum).patch);
} }
function dragTie(draggedName, targetName) { function dragTie(draggedName, targetName) {
const d = any(draggedName), t = any(targetName); const d = any(draggedName), t = any(targetName);
@@ -254,16 +254,16 @@ test('full 100-round combat scenario (shared, no React)', async () => {
// death scenarios: drop a PC to 0, death saves, revive // death scenarios: drop a PC to 0, death saves, revive
if (r === 25 && turnInRound === 0) { if (r === 25 && turnInRound === 0) {
await recordAsync(`r${r} drop Rogue`, () => applyDamage('Rogue', 99)); await recordAsync(`r${r} drop Rogue`, () => applyDamage('Rogue', 99));
record(`r${r} deathSave1 Rogue`, () => deathSaveAction('Rogue', 1)); record(`r${r} deathSave1 Rogue`, () => deathSaveAction('Rogue', 'fail', 1));
record(`r${r} revive Rogue`, () => applyHeal('Rogue', 22)); record(`r${r} revive Rogue`, () => applyHeal('Rogue', 22));
} }
// round 50: full 3-success death-save path (isDying) on Cleric, then remove // round 50: full 3-success death-save path (isDying) on Cleric, then remove
if (r === 50 && turnInRound === 0) { if (r === 50 && turnInRound === 0) {
await recordAsync(`r${r} drop Cleric`, () => applyDamage('Cleric', 99)); await recordAsync(`r${r} drop Cleric`, () => applyDamage('Cleric', 99));
await recordAsync(`r${r} deathSave Cleric x3 (isDying)`, async () => { await recordAsync(`r${r} deathSave Cleric x3 (isDying)`, async () => {
deathSaveAction('Cleric', 1); deathSaveAction('Cleric', 'fail', 1);
deathSaveAction('Cleric', 2); deathSaveAction('Cleric', 'fail', 2);
deathSaveAction('Cleric', 3); // → isDying true deathSaveAction('Cleric', 'fail', 3); // → dead
}); });
const cl = any('Cleric'); const cl = any('Cleric');
if (cl && cl.isDying) record(`r${r} remove dying Cleric`, () => removeParticipantByName('Cleric')); if (cl && cl.isDying) record(`r${r} remove dying Cleric`, () => removeParticipantByName('Cleric'));
+7 -7
View File
@@ -125,7 +125,7 @@ describe('DeathSave -> Firebase', () => {
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0); await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
// death save buttons appear // death save buttons appear
const save1 = screen.getByTitle('Death save 1'); const save1 = screen.getByTitle('Success 1');
fireEvent.click(save1); fireEvent.click(save1);
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaves === 1); await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaves === 1);
expect(lastEncCall().data.participants[0].deathSaves).toBe(1); expect(lastEncCall().data.participants[0].deathSaves).toBe(1);
@@ -159,13 +159,13 @@ describe('DeathSave -> Firebase', () => {
fireEvent.click(screen.getByTitle('Damage')); fireEvent.click(screen.getByTitle('Damage'));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0); await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
fireEvent.click(screen.getByTitle('Death save 1')); fireEvent.click(screen.getByTitle('Fail 1'));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaves === 1); await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathFails === 1);
fireEvent.click(screen.getByTitle('Death save 2')); fireEvent.click(screen.getByTitle('Fail 2'));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaves === 2); await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathFails === 2);
fireEvent.click(screen.getByTitle('Death save 3')); fireEvent.click(screen.getByTitle('Fail 3'));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.isDying === true); await waitFor(() => lastEncCall()?.data?.participants?.[0]?.isDying === true);
expect(lastEncCall().data.participants[0].isDying).toBe(true); expect(lastEncCall().data.participants[0].isDying).toBe(true);
expect(lastEncCall().data.participants[0].deathSaves).toBe(3); expect(lastEncCall().data.participants[0].deathFails).toBe(3);
}); });
}); });