Compare commits
11
Commits
4da5ed9bcc
...
7bcf01dcf9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bcf01dcf9 | ||
|
|
1ad4b660a4 | ||
|
|
9c90c1500c | ||
|
|
f2797595f4 | ||
|
|
32b8dfd8a1 | ||
|
|
ab9f8fa2e7 | ||
|
|
5a09f71d4c | ||
|
|
942545c085 | ||
|
|
0ff50561c7 | ||
|
|
8ad750c60a | ||
|
|
81a1629118 |
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)', () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user