Author SHA1 Message Date
david raistrick 055895875a Dev-only bulk delete all campaigns; deleteCollection SQL bulk + gates
Feature: debug button to wipe all campaigns/encounters/logs in dev builds.
Previously bulk delete fetched all logs per campaign, client-filtered,
batchWrite — 30s+/campaign. Now SQL bulk DELETE, no fetch.

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

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

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

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

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

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

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

handleCritDamage handler calls applyHpChange with isCriticalHit: true.
Button renders in HP block (right-aligned) for character/NPC participants
when dying or stable and combat started. Dead excluded (no-op).
2026-07-06 18:38:49 -04:00
david raistrick 6fe4a27dd5 Show stable regain hint in DM view for stable+unconscious participants
D&D 5e: stable creature regains 1 HP after 1d4 hours. Static display-only
reminder, no storage/time-tracking. Fires only for participants with
status=stable (dying->stable or dead->revive), shown where Revive button
would sit. Derived from status, survives refresh.
2026-07-06 18:34:50 -04:00
david raistrick bf1fccfd3b Exclude pause/resume from undo; fix verify round-wrap false positive
togglePause no longer writes log entry (shared/turn.js). Lifecycle op, not
player action. Undo stack now targets last real action, not flag flip.
Removed dead pause/resume cases from expandUndo.

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

Tests updated: togglePause logging test asserts no log; undo test asserts
excluded. Repro confirmed via minimal verify case (removeParticipant r1->r2).
2026-07-06 18:30:51 -04:00
david raistrick 2c997de0da Fix undo/redo forward-state restore, harden test infra, skip-guard
Root cause: mutation writers stored oldValues only. Redo rebuilt from
current state via derived logic — wrong order, wrong content, or no-op.
Undo patched fields in place but ignored roster/array order changes.

Writers now store forward arrays (shared/turn.js):
- addParticipant: newTurnOrderIds, newCurrentTurnParticipantId
- removeParticipant: newTurnOrderIds, newCurrentTurnParticipantId
- updateParticipant: oldTurnOrderIds + newTurnOrderIds (initiative re-slot)
- reorderParticipants: newParticipants + newTurnOrderIds
- damage/heal/deathSave/stabilize/revive/deactivate_dead_monster:
  oldValues + newValues both capture full death state incl conditions

expandUndo redo uses stored forward state instead of deriving:
- add/remove: order via newTurnOrderIds
- update: initiative change re-orders both directions
- reorder: re-applies newParticipants
- death ops: restore via newValues (was HP-only, dropped status/conditions)

Missing expandUndo cases added: stabilize, revive, deactivate_dead_monster
(were default:null -> undo no-op).

Test infra (shared/tests/_helpers.js):
- Mock storage undo() real now: applies patch + flips undone flag (was no-op)
- addDoc persists log entries, getCollection returns them
- undoLast/redoLast harness helpers exercise real mechanism
- Undo tests assert against actual persisted doc, not expandUndo output

turn.undo.test.js rewritten: every op tested as undo->deepEqual before,
redo->deepEqual after-op (12 cases). Was undo-only, redo untested.
turn.deathsave.undo.test.js added: 11 death-path roundtrips.

Dead code removed:
- round-trip.test.js: skipped suite testing deleted replay-from-logs.js
  (5 skipped tests rotting). Coverage gap acknowledged in TODO.

Skip-guard added (scripts/run-tests.sh): pre-flight grep refuses to run
if any .skip/xdescribe/xit found in test dirs. No CI; this is the gate.
2026-07-06 18:09:28 -04:00
david raistrick c80ac6882f Exercise death-save edge cases in combat replay and scenario tests
Replay (scripts/combat/replay.js):
- Merchant now type=npc via asNpc (was stale isNpc)
- Death-save eligibility keyed on type (character|npc), not removed isNpc
- Rotate outcomes success/fail/nat1/nat20 (was success-only)
- Import stabilizeParticipant, reviveParticipant
- Between-round revive: dead -> reviveParticipant, stable -> heal,
  inactive -> reactivate (heal was no-op on dead, leaving active-dead monsters)
- Fix deathSave return unwrap: it returns {enc, status}; callStep runner
  must extract .enc or state corrupts (round undefined, combat auto-ends)
- describe() fix: death-save arg is outcome, not type

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

Tests:
- npm run test:all
- app: 94 passed
- shared: 158 passed, 5 skipped
- server: 32 passed
2026-07-06 16:48:50 -04:00
david raistrick 2569cc4497 Builds replayable combat logs with first-class undo/redo, unified verification tooling, fast indexed log queries, and stricter CI.
feat(combat): add first-class undo and redo controls

Add undo and redo controls to the combat UI so the DM can recover from recent
actions without leaving the encounter view.

Undo and redo operate on the current encounter's combat history. Empty stacks
produce clear feedback instead of failing silently. Redo order follows normal
stack behavior after multiple undos.

This makes combat history actionable during play, not just visible in the log.

feat(logs): make combat logs replayable

Replace plain combat log messages with structured combat events that can be
used by the UI, exported as JSON, replayed, and verified.

Each new log entry records the action type, encounter identity, participant
identity, a small action delta, undo intent, and a turn snapshot. Download and
copy now export the event stream as JSON so a saved combat log is useful for
offline analysis and debugging.

Legacy logs remain viewable, but new logs use the structured event format.

feat(logs): make undo and redo transactional

Apply undo and redo as single storage operations so the encounter state and log
state cannot drift apart.

Server storage applies the encounter update and the log undone flag inside one
SQLite transaction. Firebase storage uses a batch write for the same behavior.
The storage contract now includes undo/redo semantics.

This replaces fragile multi-write undo behavior where a failure could update
the encounter without marking the log, or mark the log without updating the
encounter.

feat(combat): add unified replay and verification tool

Add one combat CLI for replaying live combat and verifying combat logs.

Replay drives the live backend through the same shared combat logic used by the
app, writes a JSON event log to an explicit output path, and automatically
verifies the result. Verification checks for DM-visible combat problems such as
skipped turns, double actions, bad round changes, and unexpected turn order
changes.

The tool uses the same JSON event stream produced by log downloads, supports
verbose turn output, and handles Ctrl-C by ending the encounter, writing the
partial log, and verifying what was captured.

fix(perf): keep long combat logging fast

Remove the combat-time log query bottleneck that made long replays slow as log
volume grew.

Combat controls no longer subscribe to the log collection just to keep undo and
redo state warm. Undo and redo now query the latest matching encounter log only
when clicked. Server collection queries support exact filters, ordering,
limits, and offsets, and SQLite indexes keep latest-log and per-encounter log
lookups fast.

Also fix duplicate WebSocket handler registration so realtime updates do not
double-fire under write load.

fix(turns): make toggle active a status change

Make toggle active a roster/status edit instead of a turn advance.

Deactivating the current participant no longer passes the turn or increments
the round. The current turn stays where it is until the DM explicitly clicks
Next Turn, and Next Turn skips inactive participants during normal rotation.

This matches the initiative design: slot order is stable, toggle active does
not move participants, and round changes only come from explicit turn advance.

chore(ci): make warnings and hangs fail fast

Tighten test and build checks so failures are visible instead of noisy or
silent.

Builds run with CI enabled so warnings fail production builds. The full test
command runs app, shared, and server suites with hard timeouts so hangs fail
quickly. Static eslint coverage fails on warnings as well as errors.

Tests were updated around the new async combat logging flow, structured log
events, transactional undo, replay verification, and toggle-active semantics.
2026-07-06 10:33:28 -04:00
david raistrick 9f8b09c7d9 feat(ui): first-class undo/redo buttons in combat controls
Undo/redo pills in InitiativeControls, always visible when encounter open.

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

TODO updated. 84 FE tests green.
2026-07-04 22:48:58 -04:00
david raistrick ed1783e419 docs: reorg TODO — merge FEAT-2+M6 into FEAT-LOG, add undo/redo UI + Is NPC clarify 2026-07-04 22:36:15 -04:00
david raistrick 6fe2b762ab docs: update TODO — toast/modal + dropdown filter done 2026-07-04 22:30:33 -04:00
david raistrick 9ef3e3bd9e feat(ui): filter already-added chars from participant dropdown
Character dropdown in add-participant picker excluded characters already
in the encounter. Prevents dup-add at the source — user can only select
chars not yet participating.
2026-07-04 22:28:41 -04:00
david raistrick 3423319b9b feat(ui): replace all alert() with toast + info modal
Native alert() vanished instantly (browser focus loss when player display
window or devtools steals focus). Replaced all 23 alert() calls with two
in-app feedback paths via React context:

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

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

244 tests green (App 84, shared 133, server 27).
2026-07-04 22:27:32 -04:00
david raistrick 7bcf01dcf9 fix: custom conditions apply on add; correct server WS env docs
Custom conditions (per-campaign freeform) previously only persisted to the
campaign palette — Add button did NOT apply them to the targeted participant,
and badges rendered via CONDITIONS (missing custom ids) so applied custom
conditions did not display. Now:

- addCustomCondition(label, participantId) persists to palette AND toggles
  onto the participant in one step. Enter + Add button both pass p.id.
- Admin badge lookup uses allConditions (built-ins + custom), not CONDITIONS.

Shared contract: toggleCondition accepts ANY string. Prove it in tests:
- turn.combat.test.js adds CUSTOM_CONDITIONS ('hexed','rager',
  'marked_for_death','🛡️blessed') to the seeded queue + random pool, asserts
  condition-not-string invariant, and a dedicated add/remove round-trip test.
- replay-combat.js mirrors the same CUSTOM_CONDITIONS through the live backend.

Server-mode docs/env: REACT_APP_BACKEND_WS (stale pre-rename) -> correct
REACT_APP_BACKEND_REALTIME_URL=ws://.../ws in README, env.example,
docker/Dockerfile (STORAGE=ws -> server). DEVELOPMENT.md + scripts/README.md
now point to ./scripts/dev-start.sh as the primary dev entrypoint.

133 shared tests green.
2026-07-04 20:41:34 -04:00
david raistrick 1ad4b660a4 feat: custom conditions per campaign
DM can add freeform custom conditions alongside built-ins. Persists to
campaign doc, survives across encounters, shared with display view.

Implementation:
- ParticipantManager subscribes campaign doc via useFirestoreDocument
- customConditions[] merged with built-in CONDITIONS at render
- Input field + Add button in conditions picker (Enter or click)
- Dedup case-insensitive vs built-ins + existing custom
- maxLength 40
- addCustomCondition writes to campaigns/{id}.customConditions
- Badge render: custom conditions show raw label (no emoji)
- DisplayView: same fallback render for custom ids
- toggleCondition unchanged (already accepts any string id)
- campaignId prop passed from EncounterManager -> ParticipantManager

243 tests green. Build clean.
2026-07-04 18:20:32 -04:00
david raistrick 9c90c1500c feat(turn): D&D 5e death saves — success/fail tracking
Old model broken: single deathSaves counter, 3=revive. No fails tracked,
successes conflated with fails. DM couldn't model real death save outcomes.

New model (D&D 5e):
- deathSave(enc, id, type, n) — type='success'|'fail', n=1|2|3
- Participant fields: deathSaves (successes), deathFails, isStable, isDying
- 3 successes = stable (0hp unconscious, safe until healed)
- 3 failures = dead (isDying, caller animates removal)
- Toggling same pip = undo that pip
- Returns { patch, log, status } — status: 'stable'|'dead'|'pending'
  isDying back-compat flag kept for App.js removal animation

App.js:
- UI split into green ✓ saves row + red ✕ fails row
- Status badges: Stabilized (emerald) / Dying (red pulse)
- handleDeathSaveChange(participantId, type, saveNumber) — new sig
- makeParticipant init: deathFails + isStable defaults

Data loss: old single-counter participants lose saves (feature didn't work,
no real state to preserve). User confirmed acceptable.

Tests:
- turn.deathsave.test.js: RED-then-GREEN, 3-success stable, 3-fail dead,
  independent tracking, new signature
- Updated 6 callers across characterization/combat/dead-skip/logging/scenario
- Logs UI tests: new title selectors (Success N / Fail N)

243 tests green.
2026-07-04 17:56:14 -04:00
david raistrick f2797595f4 test: static guard — every mutation logged
Per-op logging test encoded gaps as expected (asserted log:null = pass).
Missed 4 real bugs (BUG-7 + deathSave + addParticipants + updateParticipant).
Bunk. Static scan catches regressions regardless of test enumeration.

static.no-unlogged.test.js: scans turn.js source, extracts function bodies
via brace counting, finds any return with patch!=null AND log:null.
Verified: injected fake unlogged mutation in endEncounter → guard fails
with offender name + return expr. Restored → 128 green.

Logging contract now enforced structurally, not by enumeration.
2026-07-04 17:26:59 -04:00
david raistrick 32b8dfd8a1 fix(turn): log addParticipants + updateParticipant (logging gaps)
addParticipants + updateParticipant returned log:null → invisible in
combat log. Contract test caught them. Now both return
log:{ message, undo:{ participants }}.

App.js handlers wired (handleAddAllCharacters, handleUpdateParticipant,
handleInlineInitiative) — call logAction on logged mutations.

Logging contract now complete: every mutating combat op produces a log
entry. No gaps. turn.logging.test.js proves it.

238 tests green.
2026-07-04 17:25:22 -04:00
david raistrick ab9f8fa2e7 fix(turn): log reorder + deathSave (BUG-7)
reorderParticipants returned log:null → handler skipped logAction → drag
invisible in combat log, no undo payload. Now returns
log:{ message, undo:{ participants, turnOrderIds, currentTurnParticipantId }}.
App.js handleDrop calls logAction on logged reorders.

Found second gap: deathSave also returned null log (both branches).
Fixed — message + undo (participants snapshot).

Added logging contract test (turn.logging.test.js):
- all mutating ops logged (start/next/pause/add/remove/toggle/hp/deathsave/
  condition/reorder/end)
- no-ops return null log (same-id, cross-init, cross-pointer blocks)
- undo payloads valid + restore prior state
- documents addParticipants + updateParticipant gaps (null log, intentional)

235 tests green.
2026-07-04 17:22:52 -04:00
david raistrick 5a09f71d4c refactor(turn): remove dead computeTurnOrderAfterAddition
Zero call sites. Old turn-array addition logic, superseded by 1-list
slotIndexForInit model. Removed func, export, stale comment, and 3
characterization tests that only locked the dead func's behavior.
2026-07-04 17:18:29 -04:00
david raistrick 942545c085 fix(turn): block cross-pointer reorder (BUG-13)
reorderParticipants allowed dragging across current pointer mid-round.
nextTurn walks turnOrderIds forward from pointer, so cross-pointer drag
= ambiguous who-acts-next: upcoming dragged ahead of pointer = skipped
(walk wraps past), acted dragged behind pointer = double-act.

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

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

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

214 tests green.
2026-07-04 17:12:13 -04:00
david raistrick 0ff50561c7 test: BUG-10 deact+reactivate double-act (already fixed by 1-list model)
1-list model keeps slot position on toggle. Reactivate does not re-insert
by initiative, so no second turn. computeTurnOrderAfterAddition is dead code
(no call sites). Tests prove no double-act in both scenarios.
2026-07-04 17:06:07 -04:00
david raistrick 8ad750c60a test: add 10s timeout guard across all suites
Hangs/regressions fail fast instead of stalling CI.

- shared/jest.config.js: testTimeout 10000
- src/setupTests.js: jest.setTimeout(10000) (CRA blocks testTimeout in
  package.json — not in allowlist)
- server/jest.config.js: already had 10000

Verified: injected hanging test -> 'Exceeded timeout of 10000 ms'.
2026-07-04 17:03:34 -04:00
david raistrick 81a1629118 docs: rewrite TODO clean — closed items to history, open items current
Cross-referenced every item vs actual code state. 11 items closed (moved to
history): BUG-4/5/6/8/11/12/14/15/16/17/18, FEAT-1, add-all-characters,
warning=failure, scenario-100-rounds.

Open (7): BUG-7 (reorder undo), BUG-10 (deact/reactivate double-act),
BUG-13 (cross-pointer reorder), FEAT-2 (structured logs), FEAT-3 (initiative
field), FEAT-M6 (transactional undo), death-saves-fails, custom-conditions,
test-timeouts.
2026-07-04 16:59:52 -04:00
david raistrick 4da5ed9bcc Merge remote-tracking branch 'upstream/main' into single-source
# Conflicts:
#	src/App.js
2026-07-04 16:43:14 -04:00
david raistrick c556860e49 refactor(App): remove dead SDK imports (BUG-17)
App.js imported 9 Firestore SDK functions it never called standalone:
doc, setDoc, addDoc, collection, onSnapshot, updateDoc, deleteDoc, query,
writeBatch. All writes go through storage adapter (storage.*). Auth pieces
(initializeApp, getAuth, signInAnonymously, onAuthStateChanged,
signInWithCustomToken, getFirestore) stay — real SDK init in firebase mode.

orderBy/limit stay — now neutral builders from index.js (prev commit).
2026-07-04 16:07:21 -04:00
david raistrick 27b2272ced fix(storage): neutral queryConstraints honored by both adapters
Bug: server adapter accepted queryConstraints (orderBy/limit) but ignored them.
Combat log [orderBy('timestamp','desc'), limit(500)] returned ALL logs unordered
in server mode — unbounded growth. No test caught it: shared contract tested
subscribeCollection with 0 constraints; the firebase-only queryConstraint test
never touched the server adapter. Parity gap.

Fix (Plan A — neutral shape):
- index.js: orderBy()/limit() now NEUTRAL builders returning {__type}. Removed
  SDK orderBy/limit re-export.
- firebase.js: subscribeCollection translates neutral -> SDK query constraints.
- server.js: applyConstraints() helper sorts/limits client-side (backend returns
  all). Constraints stored per collection, applied on initial fetch + WS change.
- contract.js: added 3 queryConstraint tests (orderBy desc, limit, no-constraints)
  to SHARED contract — both adapters now run identical assertions.
- firebase.contract.test.js: removed now-redundant firebase-only constraint
  block (covered by shared contract).

Data impact: ZERO. Constraints are query-time only, never stored. Combat log
docs keep timestamp field. No Firebase data migration needed.

208 tests green.
2026-07-04 16:06:28 -04:00
david raistrick e94e1959ff refactor: rename ws adapter to server across codebase
'ws' conflated storage mode with transport protocol (WebSocket). Renamed for
clarity: the adapter talks the self-hosted server (src/storage/server.js), which
happens to use WebSocket for realtime — but the name should describe what it
connects to, not how.

Renames:
- src/storage/ws.js -> server.js
- createWsStorage() -> createServerStorage()
- storage mode 'ws' -> 'server'
- REACT_APP_BACKEND_WS -> REACT_APP_BACKEND_REALTIME_URL
- factory param wsUrl -> realtimeUrl
- server/tests/ws-*.test.js -> server-*.test.js

Kept literal: 'ws' npm package, ws:// protocol URLs, /ws WebSocket endpoint.
Transport is accurate there; only storage-naming changed.

Updated all docs (DEVELOPMENT, TESTING, REWORK_PLAN, GLOSSARY, TODO), scripts
(dev-start.sh, replay-combat.js), factory + ESM tests. 204 tests green.
2026-07-04 15:47:13 -04:00
david raistrick cb41d9ec8f fix(turn): slot-not-sort in add/update; static guard against stray .sort()
addParticipant + updateParticipant used sortParticipantsByInitiative (stable
sort, tie-break = original array index). That destroyed manually-dragged tie
order when a drag moved a same-init pair — violated the slot-not-sort design
(docs/INITIATIVE_ORDERING.md: 'Re-slotting on add/edit must preserve
drag-established tie order').

Fix: new slotIndexForInit(list, init) returns splice index into the CURRENT
list (initiative-descending). addParticipant inserts there; updateParticipant
re-inserts only when initiative changed (unrelated edits keep slot — avoids
mid-round rotation dupes surfaced by the 100-round combat test).

Tests:
- turn.slot-not-sort.test.js: drag tie order survives add/edit (4 cases).
- static.no-sort.test.js: errs if .sort( added outside allowlist
  (sortParticipantsByInitiative only). Verified by injecting a stray sort —
  guard caught it.
2026-07-04 15:40:39 -04:00
david raistrick 6baecd374e ws adapter: match main truth (id injection + setDoc merge)
- getDoc/getCollection/subscribe inject {id,...data} (main firebase shape)
- getCollection normalizes backend {id,data}[] OR bare data[] → {id,...data}
- setDoc(opts.merge) → PATCH (create-on-miss); else PUT (replace)
- ws-reconnect test asserts id now present

24/24 server green. All suites green: App 83, shared 91, server 24.
2026-07-04 12:35:28 -04:00
david raistrick 79af61cb8b match main firebase behavior; fix contract + mismatches
Contract rewritten to match main prod truth:
- require id injection in all doc/collection results
- honor setDoc({merge:true}) (main L1624 + 4 activeDisplay sites)
- drop invented bare<->prefixed cross-lookup tests (main never does this)
- add setDoc{merge}, batch set-only/update-only contract coverage

Fix mismatches vs main:
- subscribeCollection hook now forwards queryConstraints (was dropped;
  LOG_QUERY sort+limit honored again). Mock onSnapshot honors orderBy+limit.
  2 new contract tests prove the chain.
- subscribeDoc/subscribeCollection adapters now forward errCb. App hooks +
  DisplayView propagate subscribe errors to UI (match main onSnapshot 3rd arg).
- ws adapter signatures accept queryConstraints + errCb (interface match).

activeDisplay writes match main:
- 5 unguarded sites -> setDoc({merge:true}) (create-if-missing, not updateDoc)
- 3 guarded sites stay updateDoc

Delete memory adapter: third storage system added complexity, zero value.
firebase-mock covers fast tests, ws covers server path. Factory throws on
unknown mode now.

Delete phantom 'Phase A/B' comment in firebase.js header.

Tests updated to assert setDoc{merge} (not updateDoc) for activeDisplay writes.
83/83 green.
2026-07-04 12:22:19 -04:00
david raistrick b12ac904a6 test: add storage coverage (firebase contract, factory), silence scenario log
- firebase.contract.test.js: run storage contract against createFirebaseStorage
  via SDK mock. Currently 12 fail (firebase diverges from contract: no norm(),
  injects id). Failures real — firebase copied from main, memory/ws invented
  new requirements. Contract under review vs main prod truth.
- storage.factory.test.js: getStorage() routing per REACT_APP_STORAGE env,
  singleton cache, getStorageMode() reporting.
- Combat.scenario.test.js: remove console.log (test noise).
- package.json: test:all now runs App + shared + server suites (was missing App).
2026-07-03 18:53:58 -04:00
david raistrick 7f60ec140e fix(scenario): run 100 rounds not turns, exercise all combat paths
Previous test lied: called nextTurn 100x = ~100 turns = ~12 rounds (see
docs/GLOSSARY.md turn vs round). Now loops by actual round-wrap count.

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

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

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

Run time: 19ms (was 72s React, lieing).
2026-07-03 18:29:35 -04:00
david raistrick e650cd2710 refactor: single-source display lifecycle + scenario test no React
Move activeDisplay lifecycle to shared (one path, not duplicated in App):
- activateDisplay({campaignId, encounterId})
- clearDisplay()
- toggleHidePlayerHp(current)
All 6 App sites wired (start/end/2×delete-encounter/delete-campaign/hp-toggle).
Existing {patch,log} return shape preserved. No interface changes.

Combat.scenario.test.js rewritten to call shared directly, no React:
- Same actions as old UI version (roster chars, monsters, addAll, hp-toggle×2,
  start, 100 rounds of damage/heal/conditions/toggle/edit/deathsave/pause/
  resume/add/remove, end). Same funcs, same order, same data.
- Models two firestore docs (encounter + activeDisplay) via shared patches.
- Run time: 72s -> 4ms.

Both suites green: shared 91/91, App 77/77.
2026-07-03 18:26:02 -04:00
david raistrick d83d1383c0 refactor: single-source combat logic (App handlers call shared, slot model)
App.js no longer inlines combat logic. All handlers delegate to @ttrpg/shared:
startEncounter, nextTurn, togglePause, endEncounter, addParticipant(s),
updateParticipant, removeParticipant, toggleParticipantActive, applyHpChange,
deathSave, toggleCondition, reorderParticipants. ONE code path for UI.

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

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

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

Both suites green: shared 91/91, App 77/77, 0 warnings.
2026-07-03 17:59:41 -04:00
david raistrick c3d89554e8 docs: initiative ordering design (slot, never sort)
Single list = display = turn order. Slot by initiative, tie-break = add
order. Drag = same-init only (DM tie override). No re-sort on mutation.
Round wrap no rebuild. No cross-init drag.
2026-07-03 17:18:00 -04:00
david raistrick d1cc3a8b9a test: warning=failure guard + fix ambiguous switch selector
- setupTests.js: console.error/warn now throw. Warning = failure.
- Combat.characterization: two role=switch (player HP + NPC HP), scope
  to player-HP label. getByRole('switch') was ambiguous.

Full suite: 77/77 pass, 0 warnings.
2026-07-03 16:05:04 -04:00
david raistrick 31d19c5912 test: kill act env warnings + remove debug console noise
Root cause: mock _notify cb fires during waitFor poll, but testing-library
asyncWrapper flips IS_REACT_ACT_ENVIRONMENT=false. Raw react act() then
warns 'not configured to support act' (146 warnings).

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

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

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

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

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

Note: NOT complete kill-shared. App.js still inlines 8 logic fns
(startEncounter, nextTurn, togglePause, endEncounter, addParticipant,
updateParticipant, reorder/drag). turn.js versions dead in app, used
by replay + turn tests only. Next: make handlers call turn.js.
2026-07-03 15:46:27 -04:00
robert 8cf3dad5b6 Merge pull request 'Rework backend' (#2) from rework-backend into main
Reviewed-on: #2
2026-07-03 08:35:32 -04:00
robertandClaude Sonnet 5 ec578eeef5 Bump to v0.4, document self-hosted backend in README
The rework-backend merge added an optional self-hosted Express/ws/SQLite
backend (npm workspaces, single-container Docker deployment) alongside
the existing Firebase default. Bump APP_VERSION and refresh README to
cover both storage modes and the new repo layout.

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 19:40:23 -04:00
robert e31fe15382 Merge pull request 'Rework backend' (#1) from rework-backend into main
Reviewed-on: #1
2026-07-01 19:29:33 -04:00
83 changed files with 8264 additions and 3076 deletions
+1
View File
@@ -13,3 +13,4 @@ server/data/*.sqlite
server/data/*.sqlite-* server/data/*.sqlite-*
/data /data
/scratch /scratch
tmp/
+288
View File
@@ -0,0 +1,288 @@
# Death Saves 5e - Implementation Plan
TDD required. Tests first, implementation after.
## Gap Analysis
**Current:**
- ✅ Separate success/fail counters (deathSaves, deathFails)
- ✅ 3 success → stable, 3 fail → dead
- ✅ UI shows ✓/✕ buttons, status badges
**Missing (from DEATH_SAVES.md):**
- ❌ Nat 20: 1 HP + conscious, reset counters
- ❌ Nat 1: 2 failures
- ❌ Damage at 0 HP → add failures (normal=1, crit=2)
- ❌ Massive damage: damage >= currentHp + maxHp → instant dead
- ❌ Stabilize manual action
- ❌ Healing resets counters
- ❌ Damage breaks stable → back to dying
**Test Gap:**
- turn.deathsave.test.js: basic counters only
- turn.combat.test.js: single deathSave call, no coverage
- Combat.scenario.test.js: basic fail/death flow
- No nat20, nat1, damage-at-0, massive damage, stabilize, healing integration
## Phase 1: Extend Tests (TDD)
### File: `shared/tests/turn.deathsave.nat20.test.js`
Test cases:
- Nat20 → hp=1, deathSaves=0, deathFails=0, isDying=false, isStable=false, status="conscious"
- Nat20 overrides existing saves/fails
- Nat20 log entry correct
### File: `shared/tests/turn.deathsave.nat1.test.js`
Test cases:
- Nat1 → deathFails += 2
- Nat1 triggers instant death (3 fails total)
- Nat1 log entry correct
### File: `shared/tests/turn.deathsave.damage.test.js`
Test cases:
- Damage at 0 HP → deathFails += 1
- Crit damage at 0 HP → deathFails += 2
- Mixed: save success + damage → independent tracking works
- Damage log includes "while dying"
### File: `shared/tests/turn.deathsave.massive.test.js`
Test cases:
- Damage >= currentHp + maxHp → instant dead
- Bypasses death saves entirely
- Log entry shows "massive damage"
### File: `shared/tests/turn.deathsave.stabilize.test.js`
Test cases:
- Stabilize → hp=0, isStable=true, isDying=false, counters=0
- Stabilize from dying state
- Stabilize log entry correct
### File: `shared/tests/turn.deathsave.heal.test.js`
Test cases:
- Healing at 0 HP → reset counters, isDying=false, isStable=false
- Healing at stable → same reset
- Healing log includes "cleared death saves"
### File: `shared/tests/turn.deathsave.stable.test.js`
Test cases:
- Damage to stable → isStable=false, isDying=true, counters reset
- Stable takes damage → fresh death save loop
### File: `shared/tests/turn.deathsave.undo.test.js`
Test cases:
- Undo deathSave restores previous state
- Undo nat20 restores counters
- Undo damage at 0 HP restores fail count
- Undo stabilize restores dying state
## Phase 2: Combat Test Integration
### Update `shared/tests/turn.combat.test.js`
Add phases:
- Nat20 phase: roll nat20, verify 1 HP + conscious
- Nat1 phase: roll nat1, verify 2 failures
- Damage-at-zero phase: normal damage + crit damage at 0 HP
- Massive damage phase: instant death bypass
- Stabilize phase: stabilize dying character
- Healing-reset phase: heal at 0 HP, verify counters reset
- Assert death save state at each phase
### Update `src/tests/Combat.scenario.test.js`
Add action functions:
- deathSaveActionNat20(name)
- deathSaveActionNat1(name)
- stabilizeAction(name)
- applyDamageAtZero(name, amount, isCrit)
- applyMassiveDamage(name)
- healingResetAction(name, amount)
Add scenario phases:
- Round 10-20: nat20/nat1 scenarios
- Round 30-40: damage-at-zero scenarios (normal + crit)
- Round 50-60: stabilize scenarios
- Round 70-80: massive damage scenarios
- Round 90-100: healing-reset scenarios
- Verify log entries include all death save types
### Update `scripts/combat.js`
Verify death save events:
- Check death_save events in log
- Verify nat20, nat1, damage_at_zero, massive_damage log types
- Verify stabilize events
- Verify healing_reset events
## Phase 3: Extend `deathSave` Function
Add roll result parameter:
```ts
async function deathSave(encounter, participantId, type, n, rollResult, ctx) {
// rollResult: number (1-20) or null for manual input
}
```
Handle nat20:
```ts
if (rollResult === 20) {
updates.hp = 1;
updates.deathSaves = 0;
updates.deathFails = 0;
updates.isDying = false;
updates.isStable = false;
status = 'conscious';
}
```
Handle nat1:
```ts
if (rollResult === 1) {
const currentFails = participant.deathFails || 0;
updates.deathFails = Math.min(3, currentFails + 2);
// Check for death...
}
```
## Phase 4: Extend `applyHpChange` Function
Add damage-at-zero logic:
```ts
if (changeType === 'damage' && participant.currentHp === 0) {
const failuresToAdd = isCritical ? 2 : 1;
const currentFails = participant.deathFails || 0;
updates.deathFails = Math.min(3, currentFails + failuresToAdd);
updates.isStable = false;
// Check for death...
}
```
Add massive damage rule:
```ts
if (changeType === 'damage' && damage >= participant.currentHp + participant.maxHp) {
updates.hp = 0;
updates.isDying = true;
updates.deathSaves = 0;
updates.deathFails = 3;
updates.isStable = false;
}
```
Add healing reset logic:
```ts
if (changeType === 'heal' && (participant.currentHp === 0 || participant.isStable || participant.isDying)) {
updates.deathSaves = 0;
updates.deathFails = 0;
updates.isDying = false;
updates.isStable = false;
}
```
## Phase 5: New `stabilizeParticipant` Function
```ts
async function stabilizeParticipant(encounter, participantId, ctx) {
// hp=0, isStable=true, isDying=false, counters=0
// Log: "Stabilized"
}
```
## Phase 6: UI Extensions
### Phase 6a: Roll Result Modal
Add roll result input:
- Modal when clicking death save button
- Input: d20 result (1-20) or "manual"
- Auto-detect nat20/nat1
### Phase 6b: Nat1/Nat20/Stabilize Buttons
Death save row layout:
```
[Saves: □ □ □] [Fails: □ □ □]
[Nat1] [Nat20] [Stabilize]
```
**Nat1 button:**
- Red icon (AlertTriangle)
- Click → deathSave(participantId, 'fail', null, 1, ctx)
- Adds 2 failures immediately
- Log: "Nat 1! [name] takes 2 death save failures"
- Disable if deathFails >= 2 (would cause death anyway)
**Nat20 button:**
- Gold icon (Zap)
- Click → deathSave(participantId, 'success', null, 20, ctx)
- Sets hp=1, resets counters, conscious
- Log: "Nat 20! [name] restored to 1 HP, conscious"
**Stabilize button:**
- Emerald icon (HeartPulse)
- Only show when dying (hp=0, !isStable, !dead)
- Click → stabilizeParticipant(participantId, ctx)
- Sets isStable=true, counters=0
- Log: "[name] stabilized at 0 HP"
- Hide if already isStable
**Conditional visibility:**
- All buttons: only when hp === 0 && !dead
- Stabilize: hide if already isStable
- Nat1: disable if deathFails >= 2
### Phase 6c: Crit Checkbox for Damage
Add crit checkbox:
- For damage at 0 HP
- Pass crit to applyHpChange
- UI: checkbox "Critical hit?"
### Phase 6d: Enhanced Log Messages
- Nat20: "Nat 20! [name] restored to 1 HP, conscious"
- Nat1: "Nat 1! [name] takes 2 death save failures"
- Damage at 0: "[name] takes [n] damage while dying"
- Massive damage: "Massive damage! [name] instantly killed"
- Stabilize: "[name] stabilized at 0 HP"
- Healing reset: "[name] healed to [n] HP, death saves cleared"
## Phase 7: Integration Tests
File: `shared/tests/turn.deathsave.flow.test.js`
Test full scenarios:
- Dying → nat20 → conscious → take damage → dying
- Dying → nat1 → 2 fails → another fail → dead
- Dying → stabilize → damage → dying again
- Massive damage bypasses all
- Healing resets mid-death-save loop
## Execution Order
1. Phase 1: Write all test files (fail expected)
2. Phase 2: Update combat tests (fail expected)
3. Phase 3: Extend deathSave, run tests
4. Phase 4: Extend applyHpChange, run tests
5. Phase 5: Add stabilizeParticipant, run tests
6. Phase 6a: Roll result modal
7. Phase 6b: Nat1/Nat20/Stabilize buttons
8. Phase 6c: Crit checkbox
9. Phase 6d: Enhanced log messages
10. Phase 7: Integration tests
11. Manual testing: full scenarios in dev server
## Risk Notes
- Roll result input: optional field (DM may not want to input every roll)
- Crit detection: manual checkbox (no way to auto-detect)
- Massive damage: optional rule (some tables ignore)
- Backward compat: existing API must still work with null rollResult
- Nat1/Nat20/Stabilize buttons: DM-optional, not required to use
+4 -1
View File
@@ -7,8 +7,11 @@ LABEL stage="build-local-testing"
WORKDIR /app WORKDIR /app
# Copy package.json and package-lock.json (or yarn.lock) # Copy root package.json/lockfile plus workspace manifests so npm can
# resolve the @ttrpg/shared and server workspace links during install.
COPY package*.json ./ COPY package*.json ./
COPY shared/package.json ./shared/
COPY server/package.json ./server/
# Install dependencies using the lock file for consistency # Install dependencies using the lock file for consistency
RUN npm install RUN npm install
+71 -12
View File
@@ -1,4 +1,4 @@
# TTRPG Initiative Tracker (v0.2.5) # TTRPG Initiative Tracker (v0.4)
![Here it is in use](images/in_use.png) ![Here it is in use](images/in_use.png)
@@ -12,6 +12,8 @@ A web-based application designed to help Dungeon Masters (DMs) manage and displa
Have you tried it? Got feedback or questions? Discuss here: [https://discourse.draft13.com/c/ttrpg-initiative-tracker/16](https://discourse.draft13.com/c/ttrpg-initiative-tracker/16) Have you tried it? Got feedback or questions? Discuss here: [https://discourse.draft13.com/c/ttrpg-initiative-tracker/16](https://discourse.draft13.com/c/ttrpg-initiative-tracker/16)
As of v0.4, the app can optionally run against a self-hosted backend (Node/Express/ws/SQLite, shipped as a single Docker container) instead of Firebase — useful if you'd rather not depend on a Google account. Firebase remains the default; see [Self-Hosted Backend (Optional)](#self-hosted-backend-optional) below.
## Features ## Features
![DM View.](images/dm_view.png) ![DM View.](images/dm_view.png)
@@ -59,7 +61,7 @@ Have you tried it? Got feedback or questions? Discuss here: [https://discourse.d
* A **fullscreen button** (top-right corner) toggles the browser into fullscreen mode — ideal for a dedicated second monitor. * A **fullscreen button** (top-right corner) toggles the browser into fullscreen mode — ideal for a dedicated second monitor.
* A **prevent sleep toggle** (moon/coffee icon, top-right corner) uses the browser Wake Lock API to keep the screen on while active. * A **prevent sleep toggle** (moon/coffee icon, top-right corner) uses the browser Wake Lock API to keep the screen on while active.
* **Combat Action Log:** A running log of combat events (HP changes, condition changes, turn advances, participant additions/removals, encounter starts/ends, etc.) is available at `/logs`. Entries are timestamped and tagged with the encounter name. Most entries include an **↩ Undo** button that rolls back the action in Firestore (restoring HP, conditions, turn order, etc.). Rolled-back entries are greyed out with a strikethrough. The log can be cleared in bulk from that page. * **Combat Action Log:** A running log of combat events (HP changes, condition changes, turn advances, participant additions/removals, encounter starts/ends, etc.) is available at `/logs`. Entries are timestamped and tagged with the encounter name. Most entries include an **↩ Undo** button that rolls back the action in Firestore (restoring HP, conditions, turn order, etc.). Rolled-back entries are greyed out with a strikethrough. The log can be cleared in bulk from that page.
* **Real-time Updates:** Uses Firebase Firestore for real-time synchronization between DM actions and the player display. * **Real-time Updates:** Uses Firebase Firestore for real-time synchronization between DM actions and the player display (or a self-hosted WebSocket backend, see below).
* **Initiative Tie-Breaking:** DMs can drag-and-drop participants with tied initiative scores (before an encounter starts or while paused) to set a manual order. * **Initiative Tie-Breaking:** DMs can drag-and-drop participants with tied initiative scores (before an encounter starts or while paused) to set a manual order.
* **Responsive Design:** Styled with Tailwind CSS. * **Responsive Design:** Styled with Tailwind CSS.
* **Confirmation Modals:** Used for destructive actions like deleting campaigns, characters, encounters, or ending combat. * **Confirmation Modals:** Used for destructive actions like deleting campaigns, characters, encounters, or ending combat.
@@ -68,8 +70,9 @@ Have you tried it? Got feedback or questions? Discuss here: [https://discourse.d
* **Frontend:** React * **Frontend:** React
* **Styling:** Tailwind CSS * **Styling:** Tailwind CSS
* **Backend/Database:** Firebase Firestore (for real-time data) * **Backend/Database:** Firebase Firestore (default), or a self-hosted Node/Express + `ws` + SQLite (`better-sqlite3`) backend behind Caddy (optional, see [Self-Hosted Backend](#self-hosted-backend-optional))
* **Authentication:** Firebase Anonymous Authentication * **Authentication:** Firebase Anonymous Authentication (Firebase mode only; the self-hosted backend has no auth layer — intended for trusted/local networks)
* **Shared logic:** Turn-order state machine (`shared/`) is framework-agnostic and used by both the frontend and the self-hosted backend, covered by a Jest test suite
## App Usage Overview ## App Usage Overview
@@ -128,11 +131,13 @@ This flow allows the DM to prepare and run encounters efficiently while providin
### Prerequisites ### Prerequisites
* **Node.js and npm:** Ensure you have Node.js (which includes npm) installed. You can download it from [nodejs.org](https://nodejs.org/). * **Node.js and npm:** Ensure you have Node.js (which includes npm) installed. You can download it from [nodejs.org](https://nodejs.org/).
* **Firebase Project:** You'll need a Firebase project with: * **Firebase Project** (only if using the default Firebase storage mode): You'll need a Firebase project with:
* Firestore Database created and initialized. * Firestore Database created and initialized.
* Anonymous Authentication enabled in the "Authentication" > "Sign-in method" tab. * Anonymous Authentication enabled in the "Authentication" > "Sign-in method" tab.
* **Git:** For cloning the repository. * **Git:** For cloning the repository.
The project is an npm workspaces monorepo (`server/`, `shared/`, plus the CRA frontend at the root) — a single `npm install` at the repo root installs everything.
### Local Development Setup (using npm) ### Local Development Setup (using npm)
1. **Clone the Repository:** 1. **Clone the Repository:**
@@ -190,7 +195,14 @@ This flow allows the DM to prepare and run encounters efficiently while providin
### Deployment with Docker ### Deployment with Docker
This project includes a `Dockerfile` to containerize the application for deployment. It uses a multi-stage build: There are two Docker paths, depending on which storage mode you want:
* **Firebase mode (default):** the root `Dockerfile` builds a static frontend-only image, described below.
* **Self-hosted mode:** the `docker/` directory builds a single container running the Node backend + SQLite + the frontend behind Caddy, with no Firebase dependency. See [Self-Hosted Backend (Optional)](#self-hosted-backend-optional).
#### Firebase-mode image (root `Dockerfile`)
This project includes a `Dockerfile` to containerize the Firebase-backed application for deployment. It uses a multi-stage build:
* **Stage 1 (build):** Installs dependencies, copies your `.env.local` (for local testing builds), and builds the static React application using `npm run build`. * **Stage 1 (build):** Installs dependencies, copies your `.env.local` (for local testing builds), and builds the static React application using `npm run build`.
* **Stage 2 (nginx):** Uses an Nginx server to serve the static files produced in the build stage. * **Stage 2 (nginx):** Uses an Nginx server to serve the static files produced in the build stage.
@@ -225,6 +237,37 @@ This project includes a `Dockerfile` to containerize the application for deploym
* If your CI/CD pipeline builds the Docker image, ensure these environment variables are securely provided to the build environment. * If your CI/CD pipeline builds the Docker image, ensure these environment variables are securely provided to the build environment.
* **Implement strict Firebase Security Rules** appropriate for a production application to protect your data. * **Implement strict Firebase Security Rules** appropriate for a production application to protect your data.
### Self-Hosted Backend (Optional)
If you'd rather not depend on Firebase/Google, the app can run entirely self-hosted: a Node/Express + `ws` backend backed by SQLite, with the frontend talking to it instead of Firestore. Intended for trusted/local networks (no auth layer yet).
**Quickest path — Docker Compose (single container: Caddy + Node + SQLite):**
```bash
docker compose -f docker/docker-compose.yml up --build
```
This serves the app at `http://localhost:8080` (override with `PORT`), proxies `/api` and `/ws` to the backend, and persists the SQLite database in a named Docker volume. Override the Firestore-style app-id namespace with `TRACKER_APP_ID` if needed.
**Local dev (recommended):**
```bash
npm install # installs root, server/, and shared/ workspaces
./scripts/dev-start.sh # backend :4001 + frontend :3999, server mode
./scripts/dev-stop.sh # stop both
```
**Manual (fallback):**
```bash
npm install
npm run server:dev # starts backend on :4001 (SQLite at data/tracker.sqlite)
# in another terminal:
REACT_APP_STORAGE=server \
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \
REACT_APP_BACKEND_REALTIME_URL=ws://127.0.0.1:4001/ws \
npm start
```
See `docs/DEVELOPMENT.md` for the full architecture (generic KV doc store, storage adapter interface, test layers) and `docs/REWORK_PLAN.md` for the design rationale.
## Project Structure ## Project Structure
<pre> <pre>
@@ -233,21 +276,37 @@ ttrpg-initiative-tracker/
├── .env.example # Example environment variables ├── .env.example # Example environment variables
├── .env.local # Local environment variables (ignored by Git) ├── .env.local # Local environment variables (ignored by Git)
├── .gitignore # Specifies intentionally untracked files that Git should ignore ├── .gitignore # Specifies intentionally untracked files that Git should ignore
├── Dockerfile # Instructions to build the Docker image ├── Dockerfile # Firebase-mode image (frontend-only, served by nginx)
├── package.json # Workspaces root: frontend + server + shared
├── package-lock.json # Records exact versions of dependencies ├── package-lock.json # Records exact versions of dependencies
├── package.json # Project metadata and dependencies
├── postcss.config.js # PostCSS configuration (for Tailwind CSS) ├── postcss.config.js # PostCSS configuration (for Tailwind CSS)
├── tailwind.config.js # Tailwind CSS configuration ├── tailwind.config.js # Tailwind CSS configuration
├── docker/ # Self-hosted deployment: Dockerfile, docker-compose.yml, Caddyfile
├── docs/ # Rework plan, dev setup, testing, encounter-builder guide, glossary
├── scripts/ # Manual demo/ops tooling (e.g. replay-combat.js)
├── tests/ # Exploratory audit tooling (not part of the automated suite)
├── public/ # Static assets ├── public/ # Static assets
│ ├── favicon.ico │ ├── favicon.ico
│ ├── index.html # Main HTML template │ ├── index.html # Main HTML template
│ └── manifest.json │ └── manifest.json
── src/ # React application source code ── src/ # React frontend (Create React App)
├── App.js # Main application component ├── App.js # Main application component
├── index.css # Global styles (including Tailwind directives) │ ├── storage/ # Storage adapter layer: firebase / ws / memory
── index.js # React entry point │ ├── index.css # Global styles (including Tailwind directives)
│ └── index.js # React entry point
├── server/ # Self-hosted backend: Express + ws + SQLite (better-sqlite3)
└── shared/ # Framework-agnostic turn-order logic, used by frontend + server
</pre> </pre>
## Further Reading
* `docs/DEVELOPMENT.md` — setup, running the backend, test suites
* `docs/REWORK_PLAN.md` — why/how the self-hosted backend was added
* `docs/TESTING.md` — test layers and how to run them
* `docs/ENCOUNTER_BUILDER.md` — entity model and storage paths behind the DM interface
* `docs/GLOSSARY.md` — domain terms (turn vs. round, etc.)
* `TODO.md` — known bugs and backlog
## Contributing ## Contributing
If you want to contribute, send me a message here: [https://discourse.draft13.com/c/ttrpg-initiative-tracker/16](https://discourse.draft13.com/c/ttrpg-initiative-tracker/16), and I can add to this Gitea instance and you can feel free to fork the repository and submit pull requests. For major changes, please pose a topic to the Discourse instance above linked above first to discuss what you would like to change. If you want to contribute, send me a message here: [https://discourse.draft13.com/c/ttrpg-initiative-tracker/16](https://discourse.draft13.com/c/ttrpg-initiative-tracker/16), and I can add to this Gitea instance and you can feel free to fork the repository and submit pull requests. For major changes, please pose a topic to the Discourse instance above linked above first to discuss what you would like to change.
+209 -174
View File
@@ -1,204 +1,239 @@
# 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
### CRITICAL BUG - storage
- docker for sql is not using persistant storage...
### feat - campaign section rollup
### feat - add all characters to participants list ### dm list - keep active particpant in view (scroll)
not sure good way to do this
### 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)
### FEAT: player display fade transitions for inactive state
- Inactive is DM-triggered via Mark Inactive.
- Player display should fade inactive participant out, then remove from display list.
- Reactivating should fade participant in.
- DM display keeps inactive participant visible.
- Dead state does not imply inactive/disabled.
- Dying/stable must not fade out or leave layout holes.
- Dead can keep skull/Dead label; create some good visual cues, no removal - but a cool transition to death would be nice.
### TEST GAP: current branch changes need focused coverage
- Storage `where()` contract: firebase + server adapters should honor `[where('encounterPath','==',x), orderBy('ts','desc'), limit(n)]`.
- Server SQL query test: `where + orderBy + limit` should return latest logs for one encounter only.
- Undo/redo stack order: undo A3 then A2, redo must replay A2 first, then A3.
- Combat controls should not subscribe to logs while mounted; undo/redo should query logs only on click.
- Unified CLI smoke: `node scripts/combat.js verify <fixture.json>` returns CLEAN on known-good log.
- Unified CLI replay smoke: `node scripts/combat.js replay ... --out tmp/x.json` writes JSON array and auto-verifies.
- Ctrl-C replay behavior: SIGINT during replay should end encounter, clear active display, write partial log, run verify.
- SQLite schema/index test: `idx_docs_parent_ts` and `idx_docs_parent_encounter_ts` exist for server DB.
### confirm warnings treated as error = fail in all tests, build pipeline, linters, everything. again.
### BUG: addParticipants (batch add) does not slot by initiative
- shared/turn.js addParticipants appends `[...existing, ...new]`, no slotIndexForInit.
- Violates INIT doc: "Add = insert into slot by initiative."
- Single addParticipant slots correct. Batch (add-all-chars) appends.
- Pre-start batch add = wrong order. Post-start worse.
### BUG: nextTurn throws on solo combatant
- nextActiveAfter loop `for step=1; step<n` skips when n=1 → {nextId:null}.
- nextTurn throws "Could not determine next participant."
- Solo active combat cannot pass turn.
### BUG: reorderParticipants cross-pointer drag = silent no-op
- Cross-pointer drag returns encounter unchanged, no log, no toast.
- DM drags across current turn → nothing happens, no feedback.
### BUG: addParticipant undo missing currentTurnParticipantId when started
- undo saves participants + conditional turnOrderIds, no currentTurnParticipantId.
- Pointer can misalign on undo if added near pointer region.
### BUG: computeTurnOrderAfterRemoval isActive uses find() not boolean
- `isActive = id => updatedParticipants.find(p => p.id === id && p.isActive)`
- Returns participant obj (truthy) not boolean. Works by accident, fragile.
### BUG: select campaign during active combat = screen flicker, no action
- In active encounter, click different campaign → flicker, no nav, no end.
- Either block (toast: end encounter first) or auto-end current + switch.
- Decide UX before fix.
### FEAT: generic/non-5e rules mode
- Campaign/encounter ruleset toggle: `5e` vs `generic`.
- Generic mode turns off death saves/state machine.
- Generic mode allows negative HP.
- 5e mode rejects negative damage/heal and clamps HP at 0.
### FEAT: clarify "Is NPC" in add-participant
- Ambiguous label. May expand work based on what NPC means here (ally? monster?
display-only? skip in turn order?). Clarify intent before UX changes.
### FEAT: first-class undo/redo UI buttons (B --- do now)
- Toolbar buttons ↶/↷ in AdminView header, not buried in /logs.
- Undo = revert latest non-undone log. Redo = re-apply latest undone.
- Uses current 2-write undo (non-tx). Race safety = log refactor later.
- Disabled when stack empty. Keyboard shortcuts (cmd+z / cmd+shift+z).
### FEAT-LOG: unified log refactor (was FEAT-2 + M6, batched)
- Single event schema, one source of truth:
`{ ts, type, payload, undo_payload, undone, encounterId,
snapshot:{ round, currentTurnParticipantId, turnOrderIds, activeIds } }`
- Common format consumed by: UI log view, download/copy export,
replay-combat, analyze-turns. One shape, four consumers.
- Transactional undo: server endpoint `POST /api/undo/:eventId`. Single
SQLite tx applies undo_payload + flips `undone`. Replaces fragile 2-write
(log update + encounter update as separate calls).
- Download/copy: exports event stream as JSON for offline analysis.
- replay-combat + analyze-turns rewritten to emit/consume same event shape.
- Migration: keep old log entries readable; new format for new writes.
### quality of life fix: 2. UI says "Campaign Characters", field is players --- naming mismatch (separate concern, flag for later)
## FEAT - parallel campaigns
## FEAT - multi user
## FEAT - clarify what end encounter does and what initiatives reset means
## Done (history)
### FEAT: first-class undo/redo UI buttons (DONE)
- ↶/↷ pills in InitiativeControls, always visible when encounter open.
- Undo = latest non-undone log (per encounter). Redo = latest undone.
- encounterPath added to all 14 log contexts (filter key).
- redo:patch (forward) added to undoData. Real redo replays forward state.
- Disabled when stack empty. Tooltip shows target action.
- Uses current 2-write undo (non-tx). Race safety = FEAT-LOG refactor.
### Architecture: 1-list turn order model (slot, never sort)
- Single source: turnOrderIds === participants.map(id). No re-sort after - Single source: turnOrderIds === participants.map(id). No re-sort after
startEncounter. nextTurn skips inactive (predicate), inactive stay in slot. startEncounter. nextTurn skips inactive (predicate), inactive stay in slot.
- Drag (reorder) overrides initiative --- cross-init allowed, DM choice. - Drag (reorder) = same-init tie-break only. Cross-init blocked.
- startEncounter sorts ALL participants by init once, then frozen. - startEncounter sorts ALL participants by init once, then frozen.
- addParticipant splices by init pos. remove/toggle/reorder sync list. - addParticipant/updateParticipant slot by init (slotIndexForInit), preserve
- Display renders participants[] directly (no sortParticipantsByInitiative). drag order. Display renders participants[] directly (no sort).
- BUG-6 (reorder divergence) fixed structurally. BUG-5 (rotation) held - Static guard test errs if `.sort(` reintroduced outside allowlist.
(500 rounds CLEAN). - Design doc: docs/INITIATIVE_ORDERING.md.
### FEAT-3: initiative first-class entry (add + edit) ### Single source of truth: combat logic
- Current: only initMod at char-build. No initiative field at add-participant - All 15 App.js handlers delegate to @ttrpg/shared. ~498 lines inline dupes deleted.
or edit. 3-step to set after other steps. - shared/turn.js = only place turn logic lives.
- 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 ### Storage parity (firebase + server adapters)
- Fixed: `applyHpChange` no longer flips `isActive` or touches `turnOrderIds` - Neutral queryConstraints ({__type:'orderBy'|'limit'}) honored by both adapters.
on death/revive. Dead stay in rotation, `nextTurn` visits them, PCs get - Shared contract test runs both identically. Memory adapter deleted; factory
death-save turn. `isActive` = DM toggle only. throws on unknown mode.
- Tests: `shared/tests/turn.dead-skip.test.js` (4 green). Char tests updated - ws storage mode renamed to server (env var + adapter name).
to new behavior.
### FEAT-2: upgrade app internal logs to be parseable ### Logging contract
- Goal: combat logs in Firestore store enough structured state to run - Every mutating op logs message + undo payload. No-op = null log.
skip/rotation analysis on ANY historic round --- not just replay stdout. - Structural enforcement: per-op contract test (turn.logging.test.js) +
- Current logs: `{timestamp, message, encounterName, undo}`. Parser must static source-scan guard (static.no-unlogged.test.js).
guess roster from message strings. Brittle.
- Upgrade: add structured fields at turn-state mutation log sites in
App.js (startEncounter, toggleActive, addParticipant, removeParticipant,
applyHpChange death/revive, togglePause, nextTurn):
```
turnSnapshot: { round, currentTurnParticipantId, turnOrderIds, activeIds }
```
- Then `scripts/analyze-turns.js` ingests app logs directly (adapter fetch).
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) ### Custom conditions per campaign (DONE)
- Freeform per-campaign conditions. Add applies to participant + persists to
campaign palette in one step. Badge render uses merged allConditions
(built-ins + custom). toggleCondition accepts any string. Dedup
case-insensitive. maxLength 40. Combat + replay tests prove arbitrary
string ids survive round-trip.
### BUG-1: addParticipant + pause/resume corrupts turn rotation ### Death saves: D&D 5e status model (DONE)
- **RESOLVED** as side effect of BUG-2 fix (dup-id rejection broke chain). - `status` is source of truth: conscious, dying, stable, dead.
- Audit: 0 violations / 100 rounds after BUG-2 fix. - Characters and NPCs use death saves; monsters skip death saves and become dead/inactive at 0 HP.
- Replay: 10 rounds clean, no skip/dupe. - Death-save actions: Success, Fail, Nat1, Nat20, Stabilize.
- Audit: 128 violations / 100 rounds, 4 symptom faces. - Revive: dead → 0 HP, stable/unconscious, active.
- Symptom chain (one bug family): - Dead characters/NPCs stay in encounter/initiative until DM removes or marks inactive.
1. pause blocks nextTurn advance → totalTurns stays frozen (e.g. 120) - Player display hides inactive participants; DM display keeps them visible.
2. addParticipant re-adds same `r${totalTurns}` id (BUG-2: no dedup)
3. togglePause resume rebuilds turnOrderIds → dup id appears x2 ### FEAT-3: initiative first-class entry (DONE)
4. nextTurn gets stuck on dup id → rotation breaks - Initiative field at add-char, add-monster, edit participant.
5. eventually nextTurn throws 'Encounter not running' - Inline edit wired. Tie-break = drag order.
- Symptom counts (audit-state.js, 100 rounds):
62x turnOrder-no-dup, 52x rotation-dupes, 14x nextTurn-throws ### UI feedback: toast + info modal (DONE)
- Repro in replay round 10+: current stuck on one participant forever, - All 23 native alert() replaced. ToastStack (6s auto-dismiss + manual X)
nextTurn returns same id, round never advances. for transient failures. InfoModal (persistent OK) for validations.
- Clean minimal repro (shared/tests/turn.pause-add.test.js) PASSES = combo React context provider wraps all 3 App branches.
needs more state than single add+pause. Audit authoritative repro. - Fixed: native alert vanished instantly on browser focus loss.
- Clean subsystems (zero violations): HP bounds, isActive, deathSave
range, conditions, removeParticipant orphans. ### Filter dup chars from add-participant dropdown (DONE)
- Real repro = `node scripts/audit-state.js` (or audit-rotation.js). - Character dropdown excludes chars already in encounter. Prevents
dup-add at source. No more dup alert path needed.
### Test timeouts (DONE)
- jest.setTimeout(10000) in setupTests.js (CRA blocks config-level timeout).
### Warning = failure in tests (DONE)
- console.error/warn throw in test env.
### BUG-1: addParticipant + pause/resume corrupts rotation
- RESOLVED as side effect of BUG-2 fix.
### 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, ws 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 ### BUG-7: reorderParticipants not logged
- Fixed structurally by 1-list model (commit 5d3a060). turnOrderIds = - FIXED --- returns log:{message, undo}. Handler calls logAction. deathSave,
participants.map(id) always. reorder cross-init allowed (DM override). addParticipants, updateParticipant logging gaps also closed.
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 ### BUG-8: server adapter has no reconnect
- Test: `shared/tests/turn.undo.test.js` 'reorderParticipants has no undo' (GREEN doc). - FIXED --- onclose reconnects + re-subscribes existing paths.
- `reorderParticipants` returns `log: null`. Other ops return `log.undo`.
- Cannot undo drag-drop. Candidate for undo system (M6).
### BUG-8: ws adapter has no reconnect
- Test: `server/tests/ws-reconnect.test.js` (RED).
- 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.
- Crashes whole FE test run (process dies).
### BUG-13: reorderParticipants crossing current pointer = ambiguous acted-semantics ### BUG-12: campaign selection follows activeDisplay
- Discovered 7/1 replay. `reorderParticipants` (shared/turn.js:522) = pure - FIXED.
drag, no pointer logic. Swapping two actors across current pointer mid-round
= ambiguous who-acted-this-round. Earlier replay arbitrary swaps showed ### BUG-13: reorderParticipants crossing current pointer = ambiguous
skip/double (R9 Summon3 2x, R11 Goblin1 2x) before fix restricted swaps to - FIXED --- block cross-pointer reorder during active encounter (both dirs).
upcoming-only. Full fix needs actedThisRound tracking. Pragmatic block prevents skip/double.
- Replay now avoids crossing (adjacent upcoming pair only, commit af165f4). Pre-combat: free reorder. Test: turn.bug13.test.js.
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)
- [ ] BUG-4: fix setDoc→updateDoc for all 5 activeDisplay sites - FIXED --- display renders participants[] directly.
- [x] BUG-5: fixed (1-list model, 500 rounds clean)
- [x] BUG-6: fixed structurally (1-list model) ### BUG-16: subscribeCollection hook drops queryConstraints
- [x] BUG-12: fixed --- campaign selection follows activeDisplay - FIXED --- neutral builders, both adapters honor orderBy/limit.
- [x] BUG-15: fixed --- DisplayView no longer re-sorts (drag order preserved)
- [x] BUG-8: ws adapter reconnect (implemented + GREEN) ### BUG-17: dead SDK imports in App.js
- [ ] BUG-10: deact+reactivate double-act - FIXED --- trimmed (auth + getFirestore + getStorage remain).
- [ ] BUG-11: FE Combat.scenario crash
- [ ] BUG-13: reorder cross-pointer semantics (RED + decide block/allow) ### BUG-18: stale comments reference deleted memory adapter
- [ ] BUG-14: addParticipant init-insert breaks post-drag (append? + RED) - FIXED.
### FEAT-1: Dead characters/NPCs stay in turn order
- DONE --- character/NPC death does not auto-remove. DM controls inactive/remove.
- Non-NPC monsters still become inactive automatically at death.
### combat.scenario 100 rounds not turns
- DONE --- loops by actual round-wrap count.
### feat: add all characters to participants list
- DONE --- addParticipants bulk add wired.
+2 -2
View File
@@ -18,9 +18,9 @@ COPY tailwind.config.js postcss.config.js ./
# better-sqlite3 native build (alpine musl) # better-sqlite3 native build (alpine musl)
RUN cd server && npm rebuild better-sqlite3 RUN cd server && npm rebuild better-sqlite3
# build frontend (ws storage, same-origin via caddy) # build frontend (server storage, same-origin /api + /ws via caddy)
ARG REACT_APP_TRACKER_APP_ID=ttrpg-initiative-tracker-default ARG REACT_APP_TRACKER_APP_ID=ttrpg-initiative-tracker-default
ENV REACT_APP_STORAGE=ws ENV REACT_APP_STORAGE=server
ENV REACT_APP_TRACKER_APP_ID=$REACT_APP_TRACKER_APP_ID ENV REACT_APP_TRACKER_APP_ID=$REACT_APP_TRACKER_APP_ID
RUN NODE_OPTIONS=--openssl-legacy-provider npm run build RUN NODE_OPTIONS=--openssl-legacy-provider npm run build
+142
View File
@@ -0,0 +1,142 @@
# Death Saves (D&D 5e)
Concept and rule structure for the death-save state machine. Reference for how
combat tracking should behave at 0 HP. This is the 5e death-save loop, not a
code spec.
## Scope: tracker tracks, DM decides
This tracker is not a virtual tabletop. It does not roll dice or interpret the
table. The DM rolls at the table, decides what happened, and feeds the result
into the tracker through the UI. The tracker records the result and applies the
automatic pieces.
- DM does: rolls the d20, determines crits vs normal hits, declares damage
amounts, decides when to stabilize or heal.
- Tracker does: increments success/failure counters on DM input, resolves the
terminal states (3 successes → stable, 3 failures → dead), resets counters on
heal or stabilize, records HP changes.
The transitions below describe the rules the tracker must honor once the DM
hands it a result. The DM is the source of truth for what happened; the tracker
is the source of truth for what state that produces.
## State
A character or NPC at 0 HP enters a death-save loop. Track per death-save participant:
```ts
deathSaveSuccesses: 0 | 1 | 2 | 3
deathSaveFailures: 0 | 1 | 2 | 3
status: "conscious" | "dying" | "stable" | "dead"
hp: number
```
## Transitions
**Drop to 0 HP** (damage, character/NPC):
- If damage >= currentHp + maxHp → outright dead (massive damage). Equivalently: after reducing currentHp to 0, overflow damage >= maxHp.
- Otherwise → `status = "dying"`, counters reset to 0.
**Drop to 0 HP** (monster):
- Non-NPC monsters do not use death saves.
- `status = "dead"`, counters reset, `isActive = false`.
**Death save** (d20 roll on a dying character/NPC turn):
```ts
if roll === 1: failures += 2 // natural 1 = two failures
else if roll === 20: hp = 1; successes = 0; failures = 0; status = "conscious" // nat 20 = 1 HP, conscious
else if roll >= 10: successes += 1
else: failures += 1
```
Then resolve immediately on the click/action that reaches 3:
```ts
if failures >= 3: status = "dead"; successes = 0; failures = 0
if successes >= 3: status = "stable"; successes = 0; failures = 0
```
The UI must transition immediately. A third failure shows Dead state, not three
active failure boxes. A third success shows Stable state, not three active
success boxes.
**Damage while dying** (at 0 HP):
```ts
failures += isCriticalHit ? 2 : 1
if failures >= 3: status = "dead"
```
Attacks from within 5 ft against an unconscious creature are auto-crits, so
melee vs a dying character/NPC adds 2 failures.
**Healing while dying or stable**:
Any +HP received while `dying` or `stable` makes the character/NPC conscious.
This usually comes from magic, or from the stable-state time-delayed recovery
(1 HP after 1d4 hours, if the DM chooses to track it here).
```ts
hp = max(1, hp + amount)
successes = 0
failures = 0
status = "conscious"
```
Any HP regained from `dying` or `stable` clears all counters and ends the
death-save loop. This does **not** apply to `dead`; dead requires revive flow,
revival magic, or DM state override, not normal healing.
**Stabilize** (manual action, not a save):
```ts
hp = 0
successes = 0
failures = 0
status = "stable"
```
## Stable state
- Stays at 0 HP, unconscious, no longer makes death saves.
- 3 successes = **stable (unconscious)**, NOT conscious. NOT 1 HP.
1 HP only comes from a natural 20 or healing.
- Taking any damage ends stable → back to `dying` (counters reset, then damage
applies its failure per "damage while dying").
## Tool-specific initiative behavior
Death is a combat state, not an automatic removal action.
When a character or NPC becomes `dead`:
- Do **not** remove them from initiative.
- Do **not** automatically deactivate them.
- Do **not** remove them from the encounter participants list.
- Leave them in turn order. Other table/tool events may still matter on that
initiative count.
- DM may manually mark them inactive or remove them.
When a non-NPC monster becomes `dead`:
- Set `isActive = false` automatically.
- Keep it in the encounter participants list until DM removes it.
- Player display hides it because inactive participants are hidden.
## Invariants
- `status === "dying"` ⟺ hp === 0 AND no terminal flag.
- `status === "stable"` ⟹ hp === 0, unconscious, counters 0.
- `status === "dead"` ⟹ hp === 0, no further saves, counters 0.
- `status === "dead"` ⟹ participant remains in initiative and participants until DM removes them.
- `status === "dead"` does not itself mean inactive; monster death sets inactive as a separate rule.
- Normal +HP applies to `dying` or `stable`, not `dead`; dead requires revive, revival, or DM override.
- Successes and failures tracked **independently**. 2 fail + 3 success = stable.
Counters do not need to be consecutive.
- A roll of 10+ is a success. 9 or lower is a failure.
## Sources
- [PHB 5e: Death Saving Throws](https://roll20.net/compendium/dnd5e/Rules%3ADamage%20and%20Healing)
- [Reddit: What happens on 3 successful death saves?](https://www.reddit.com/r/DnD/comments/io5js7/what_happens_if_you_succeed_in_3_death_saving/)
- [Roll20: Conditions (Unconscious)](https://roll20.net/compendium/dnd5e/Conditions)
+24 -11
View File
@@ -50,14 +50,19 @@ git config core.hooksPath .githooks # enable pre-push test gate
## Run ## Run
### Backend (dev) ### Local dev stack (one command)
```bash ```bash
npm run server:dev # :4001, db: server/data/tracker.sqlite ./scripts/dev-start.sh # backend :4001 + frontend :3999, server mode
# or direct: ./scripts/dev-stop.sh # stop both
DB_PATH=./data/tracker.sqlite PORT=4001 node server/index.js
``` ```
- backend: `npm run server:dev` (:4001, sqlite `data/tracker.sqlite`)
- frontend: `npm start` (:3999, `REACT_APP_STORAGE=server`)
- logs: `tmp/server.log`, `tmp/fe.log`
Idempotent: if port busy, leaves existing proc as-is.
Smoke check: Smoke check:
```bash ```bash
curl http://127.0.0.1:4001/health # -> {"ok":true} curl http://127.0.0.1:4001/health # -> {"ok":true}
@@ -65,12 +70,20 @@ 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, ws mode) ### Manual (fallback)
Backend:
```bash ```bash
REACT_APP_STORAGE=ws \ npm run server:dev # :4001, db: server/data/tracker.sqlite
# or direct:
DB_PATH=./data/tracker.sqlite PORT=4001 node server/index.js
```
Frontend (server mode):
```bash
REACT_APP_STORAGE=server \
REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \ REACT_APP_BACKEND_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 \
BROWSER=none PORT=3999 \ BROWSER=none PORT=3999 \
npm start npm start
``` ```
@@ -161,7 +174,7 @@ Runs pure turn.js combat, audits 9 invariant classes per round:
1. rotation integrity (skip/dupe) 1. rotation integrity (skip/dupe)
2. HP bounds (0 ≤ hp ≤ max, no NaN) 2. HP bounds (0 ≤ hp ≤ max, no NaN)
3. isActive consistency (dead = inactive) 3. isActive consistency (inactive skipped; monster death auto-inactive; character/NPC death DM-controlled)
4. turnOrder no dup ids 4. turnOrder no dup ids
5. turnOrder ids all active 5. turnOrder ids all active
6. currentTurn valid + active 6. currentTurn valid + active
@@ -225,12 +238,12 @@ App passes firebase-prefixed paths (`artifacts/{APP_ID}/public/data/campaigns/..
`getStorageMode()` reads `REACT_APP_STORAGE` env (default `firebase`). `getStorageMode()` reads `REACT_APP_STORAGE` env (default `firebase`).
- `firebase` → real SDK init - `firebase` → real SDK init
- `ws`/`memory` → stub auth + db sentinel, route via `storage.*` adapter - `server` → stub auth + db sentinel, route via `storage.*` adapter
## Test layers ## Test layers
- **Layer 1**: App vs firebase mock. Proves adapter call shape. Never exercises ws adapter. - **Layer 1**: App vs firebase mock. Proves adapter call shape. Never exercises server adapter.
- **Layer 2**: ws adapter vs live backend. Proves translation + path identity. - **Layer 2**: server adapter vs live backend. Proves translation + path identity.
Both required — Layer 1 alone misses adapter bugs (path mismatch, no-op players, ws.on EventEmitter vs browser handlers). Both required — Layer 1 alone misses adapter bugs (path mismatch, no-op players, ws.on EventEmitter vs browser handlers).
+28 -18
View File
@@ -53,16 +53,16 @@ Object in `encounter.participants[]`:
|---|---|---| |---|---|---|
| `id` | string | `generateId()` | | `id` | string | `generateId()` |
| `name` | string | | | `name` | string | |
| `type` | `'character'` \| `'monster'` | character = PC (death saves), monster = hostile/NPC | | `type` | `'character'` \| `'npc'` \| `'monster'` | character/NPC = death saves, monster = dead at 0 HP |
| `originalCharacterId` | string\|null | links back to campaign character if type=character | | `originalCharacterId` | string\|null | links back to campaign character if type=character |
| `initiative` | int | rolled once at add (`rollD20() + mod`). Stored value, not re-derived. | | `initiative` | int | rolled once at add (`rollD20() + mod`). Stored value, not re-derived. |
| `maxHp` | int | | | `maxHp` | int | |
| `currentHp` | int | 0 = dead/dying | | `currentHp` | int | 0 = dead/dying/stable by `status` |
| `isNpc` | bool | monster flagged NPC (display color, no death saves) |
| `conditions` | array | condition ids from `CONDITIONS` list | | `conditions` | array | condition ids from `CONDITIONS` list |
| `isActive` | bool | in turn rotation? false = skipped by nextTurn | | `isActive` | bool | in turn rotation? false = skipped by nextTurn |
| `deathSaves` | int | PC only, 0-3 fails | | `status` | `'conscious'` \| `'dying'` \| `'stable'` \| `'dead'` | death-save source of truth |
| `isDying` | bool | death animation flag (player display) | | `deathSaveSuccesses` | int | character/NPC only, 0-3 successes |
| `deathSaveFailures` | int | character/NPC only, 0-3 failures |
## Build flow (UI) ## Build flow (UI)
@@ -99,7 +99,7 @@ ParticipantManager section. Two paths:
- **Monster Name** (`placeholder: "e.g., Dire Wolf"`) - **Monster Name** (`placeholder: "e.g., Dire Wolf"`)
- **Init Mod** (`MONSTER_DEFAULT_INIT_MOD` = 2) - **Init Mod** (`MONSTER_DEFAULT_INIT_MOD` = 2)
- **Max HP** (`DEFAULT_MAX_HP` = 10) - **Max HP** (`DEFAULT_MAX_HP` = 10)
- **Is NPC?** checkbox (flag, changes display color) - **Is NPC?** checkbox (sets `type: 'npc'`, keeps monster/DM-control color, enables death saves)
- Click **Add to Encounter** - Click **Add to Encounter**
- Initiative auto-rolled: `rollD20() + mod` - Initiative auto-rolled: `rollD20() + mod`
@@ -113,13 +113,13 @@ ParticipantManager section. Two paths:
Participant object added: Participant object added:
```js ```js
{ id, name, type, originalCharacterId, initiative, maxHp, currentHp:maxHp, { id, name, type, originalCharacterId, initiative, maxHp, currentHp:maxHp,
isNpc, conditions:[], isActive:true, deathSaves:0, isDying:false } conditions:[], isActive:true, status:'conscious', deathSaveSuccesses:0, deathSaveFailures:0 }
``` ```
### 6. Reorder before start (tie-break) ### 6. Reorder before start (tie-break)
Pre-combat only (`!isStarted || isPaused`). Drag handles shown for **tied initiative** values only. Drop reorders `participants[]` + `turnOrderIds`. Pre-combat only (`!isStarted || isPaused`). Drag handles shown for **tied initiative** values only. Drop reorders `participants[]` + `turnOrderIds`.
Post-start drag: see BUG-13/14 in `TODO.md` (cross-init + pointer semantics untested). During active combat, cross-current-pointer drag is blocked to avoid skip/double-act ambiguity. Paused combat can reorder same-initiative ties freely.
## Combat flow (UI) ## Combat flow (UI)
@@ -135,8 +135,9 @@ InitiativeControls panel (sticky, right side).
### Next Turn ### Next Turn
- **Next Turn** button (disabled if paused) - **Next Turn** button (disabled if paused)
- Advances to next active participant in `turnOrderIds` - Advances to next active participant in `turnOrderIds`
- Wraps at end → `round += 1`, re-sorts active by initiative at round start - Wraps at end → `round += 1`
- Dead (`isActive:false`) skipped, stay in rotation - No re-sort after start; 1-list order remains source of truth
- Inactive (`isActive:false`) participants are skipped, but stay in slot
### Pause / Resume ### Pause / Resume
- **Pause Combat** → `isPaused=true`, Next Turn disabled - **Pause Combat** → `isPaused=true`, Next Turn disabled
@@ -147,11 +148,19 @@ InitiativeControls panel (sticky, right side).
Per-participant input + buttons: Per-participant input + buttons:
- Number input - Number input
- **Damage** (HeartCrack icon) — `currentHp = max(0, hp - amt)` - **Damage** (HeartCrack icon) — `currentHp = max(0, hp - amt)`
- **Heal** (Heart icon) — `currentHp = min(maxHp, hp + amt)` - **Heal** (Heart icon) — `currentHp = min(maxHp, hp + amt)` for living/dying/stable participants; normal heal does not affect dead participants
- Death: hp→0 sets `isActive:false`, PC gets `deathSaves` tracking - Character/NPC at 0 HP → `status:'dying'`, death-save controls, unconscious condition
- Monster at 0 HP → `status:'dead'`, `isActive:false`
- Massive damage (`damage >= currentHp + maxHp`) → dead
### Death saves (PC only, at 0 HP) ### Death saves (character/NPC only, at 0 HP)
3 buttons. Click marks fail. 3 fails = dead. Reset on revive/heal. Action buttons: Success, Fail, Nat1, Nat20, Stabilize.
- Success: +1 success; 3 successes → stable/unconscious at 0 HP
- Fail: +1 failure; 3 failures → dead
- Nat1: +2 failures
- Nat20: 1 HP, conscious, counters reset
- Stabilize: stable/unconscious at 0 HP, counters reset
- Revive: dead → 0 HP, stable/unconscious, active
### Conditions ### Conditions
- Click participant → expand conditions picker (all 22 from `CONDITIONS`) - Click participant → expand conditions picker (all 22 from `CONDITIONS`)
@@ -170,7 +179,7 @@ What it shows:
- Round + current turn participant - Round + current turn participant
- All participants in `participants[]` order (drag order, NOT init-sorted — BUG-15 fix) - All participants in `participants[]` order (drag order, NOT init-sorted — BUG-15 fix)
- HP bars, conditions, death saves - HP bars, conditions, death saves
- Inactive monsters hidden (pre-staged reserves) - Inactive participants hidden (including auto-inactive dead monsters and DM-hidden reserves)
Driven by `activeDisplay/status` doc. Controlled by **Open Player Window** button (sets active campaign+encounter) or Start Combat (auto-sets). Driven by `activeDisplay/status` doc. Controlled by **Open Player Window** button (sets active campaign+encounter) or Start Combat (auto-sets).
@@ -181,11 +190,11 @@ Key architecture. `turnOrderIds === participants.map(p => p.id)` always. Single
- **Display** = `participants[]` order (AdminView + DisplayView, no re-sort) - **Display** = `participants[]` order (AdminView + DisplayView, no re-sort)
- **Turn rotation** = `turnOrderIds` (mirrors participants[]) - **Turn rotation** = `turnOrderIds` (mirrors participants[])
- **Drag** = source of truth, overrides initiative - **Drag** = source of truth, overrides initiative
- **Add mid-combat** = append to participants[] + sync (BUG-14: init-insert broken post-drag) - **Add mid-combat** = slot by initiative into participants[] + sync
- **Toggle active** = flip `isActive` only, stay in slot - **Toggle active** = flip `isActive` only, stay in slot
- **Remove** = drop from participants[] + sync, advance current if needed - **Remove** = drop from participants[] + sync, advance current if needed
No re-sort after `startEncounter` except round-wrap (re-sorts active by init at top of round). No re-sort after `startEncounter`.
## Storage paths quick reference ## Storage paths quick reference
@@ -202,7 +211,8 @@ logs/{logId} action log entry
- Initiative rolled ONCE at add time. Stored. Edit via EditParticipantModal to override. - Initiative rolled ONCE at add time. Stored. Edit via EditParticipantModal to override.
- Pause before big roster changes (adds/removes). Resume re-syncs cleanly. - Pause before big roster changes (adds/removes). Resume re-syncs cleanly.
- Campaign chars = templates. Edit campaign char doesn't touch encounter participants (already added). - Campaign chars = templates. Edit campaign char doesn't touch encounter participants (already added).
- Dead monsters stay in rotation, skipped. Remove via trash icon to clean list. - Dead monsters become inactive automatically and are skipped. Remove via trash icon to clean list.
- Dead characters/NPCs stay active until DM marks inactive, removes, or revives them.
- Player display auto-follows Start Combat. Manual control via Open Player Window. - Player display auto-follows Start Combat. Manual control via Open Player Window.
See `docs/GLOSSARY.md` for domain terms, `TODO.md` for known bugs. See `docs/GLOSSARY.md` for domain terms, `TODO.md` for known bugs.
+2 -2
View File
@@ -52,8 +52,8 @@ Round 2: turn 1 (Fighter) → ... → turn 8 (Merchant) [round counter +=1 at
| Term | Meaning | | Term | Meaning |
|------|---------| |------|---------|
| **Adapter** | `src/storage/{firebase,ws,memory}.js`. Contract boundary between App and backend. App only calls `storage.*`, never raw SDK/fetch. | | **Adapter** | `src/storage/{firebase,server}.js`. Contract boundary between App and backend. App only calls `storage.*`, never raw SDK/fetch. |
| **Path normalization** (`norm()`) | Strip firebase prefix (`artifacts/{APP_ID}/public/data/`) → bare canonical path (`campaigns/X`). Runs inside every adapter method. | | **Path normalization** (`norm()`) | Strip firebase prefix (`artifacts/{APP_ID}/public/data/`) → bare canonical path (`campaigns/X`). Runs inside every adapter method. |
| **Generic KV doc store** | Backend stores opaque JSON at arbitrary path strings. No shape-specific endpoints. Backend = firebase mirror, not REST API for app entities. | | **Generic KV doc store** | Backend stores opaque JSON at arbitrary path strings. No shape-specific endpoints. Backend = firebase mirror, not REST API for app entities. |
| **Layer 1 test** | App vs firebase mock. Proves adapter call shape. | | **Layer 1 test** | App vs firebase mock. Proves adapter call shape. |
| **Layer 2 test** | ws adapter vs live backend. Proves translation + path identity. | | **Layer 2 test** | server adapter vs live backend. Proves translation + path identity. |
+73
View File
@@ -0,0 +1,73 @@
# Initiative Ordering — Design
## Core Principle
**Slot, never sort.**
Initiative order = a single list. Participants occupy slots determined by
initiative value. The list is mutated by insert/move operations — never
re-sorted wholesale.
## Single Source of Truth
One list: `participants[]`.
- Display order = `participants[]` order (both Player view + DM view).
- Turn order = `participants[]` order.
- No derived/re-sorted copy. `turnOrderIds`, if present, is always an exact
mirror (`participants.map(p => p.id)`) — never an independent ordering.
## When Ordering Changes
| Action | Effect on order |
|--------|-----------------|
| **Add** (roll or manual) | Insert participant into slot by initiative |
| **Edit initiative** | Move participant to new slot |
| **Drag** | Reorder within a tie (same initiative) only |
| **Remove** | Splice out; others keep position |
| **Start encounter** | Freeze current list. No re-sort. |
| **Round wrap** | No rebuild. Continue rotation through existing list. |
| **Damage/heal/death/save** | No order change. |
| **Toggle active** | No position change. Does **not** advance current turn. Skip on next explicit turn advance only. |
## Slotting Rules
- Insert/move positions participants so the list stays initiative-descending.
- **Tie-break = original add order.** Later additions slot *after* existing
same-init participants. Stable insertion.
- Tie order is only changed by explicit DM drag. Never auto-changed.
## Drag (DM Tie-Break Override)
- Only participants with the **same initiative** are draggable onto each other.
- Cross-initiative drag is **not allowed**. (Use edit-initiative field instead.)
- Drag persists in `participants[]`. Survives all subsequent operations.
- **Re-slotting on add/edit must preserve drag-established tie order.**
## Toggle Active Semantics
Toggle active is a roster/status edit, not a turn action.
- Deactivating the current participant leaves `currentTurnParticipantId` unchanged.
- Deactivating the current participant does **not** increment `round`.
- DM must click Next Turn to pass turn after deactivating current.
- Next Turn skips inactive participants during rotation.
- Reactivating a participant restores them to rotation at their existing slot.
- Toggle active never changes participant position or tie order.
Reason: auto-advancing from toggle active makes a status edit count like a turn pass,
can wrap the round accidentally, and causes double-act/skip drift.
## Explicitly Forbidden
- `sort()` on every mutation. Overwrites drag order. Root cause of past drift.
- Separate display order vs turn order. Causes player/DM divergence.
- Re-sort on round wrap. Replays rounds, introduces skips.
- Cross-initiative drag. Contradicts initiative as the primary key.
- Auto-changing tie order on add/edit. Silently loses DM intent.
## Why
Sorting is destructive to manual ordering. A stable slot list with drag for
ties gives deterministic, DM-controllable order that never drifts between
display surfaces or combat rounds.
+133
View File
@@ -0,0 +1,133 @@
# Log System Redesign Plan
**Status:** DRAFT — not started
**Branch:** `feat-turn-writes-logs`
**Goal:** Lean mutation deltas. Kill bloated payload/undo duplication.
## Problem
Current events = 10-16KB each. `add_participant` row = 16KB to say "Fighter added, init 22." Root cause: full participant roster stored 3x (payload + undo_payload.updates + undo_payload.redo).
DB: 2486 docs, most bloated. Replay trace 1.6-5.5MB for ~250 steps.
Old log (pre-refactor) = message string + small undo object. Proven worked for undo/redo + viewer. Just wanted MORE STRUCTURE, not MORE DATA. Overbuilt instead.
## Intention
Each event = mutation delta + lean snapshot. Structured for filter/group/analyzer. No roster arrays.
### Canonical lean event shape
```js
{
id, ts, type, // identity + filter key
message, // human text (viewer displays)
encounterId, encounterName, // which encounter
participantId, participantName, // who (nullable: pause/nextTurn have none)
// type-specific scalar delta (one of these clusters per type):
// add: { init, maxHp, type: 'character'|'monster' }
// remove: { dead: bool }
// damage/heal:{ amount }
// condition: { condition }
// toggleActive:{ revive: bool }
// reorder: { draggedId, targetId }
// update: { changedFields: ['notes', ...] }
// nextTurn: (none — snapshot carries new round/pointer)
// pause/resume/start/end: (none)
snapshot: { // lean, ALREADY GOOD — keep as-is
round, currentTurnParticipantId, turnOrderIds, activeIds
},
undo: <type-specific inverse, id-based> // minimal
}
```
### Undo inverses (id-based, minimal)
| type | undo |
|------|------|
| add_participant | `{ participantId }` → removeParticipant by id |
| remove_participant | full participant obj (must restore — only place full obj needed) |
| damage | `{ amount: -amount }` or heal back |
| heal | `{ amount: -amount }` or damage back |
| condition | `{ condition }` (toggle is its own inverse) |
| toggleActive | `{ revive: !revive }` |
| reorder | `{ draggedId, targetId }` (swap back) |
| update | `{ changedFields: [...], oldValues: {...} }` |
| nextTurn | `{ currentTurnParticipantId, round }` from snapshot delta |
| pause/resume | toggle |
| start/end | full prior scalar state |
**No `redo` field.** Redo = re-apply forward delta from event itself.
**No `undo_payload` wrapper.** Just `undo` object.
## 4 Consumers
1. **Viewer** — shows `message` + `type` + `participantName` + `undone` flag. Legacy logs (no type) display message only.
2. **Undo/redo** — apply `undo` inverse by id. Id-based, minimal.
3. **Replay** — turn.js funcs write own lean events. Trust func return. NO read-back. JSONL trace = lean snapshots + types.
4. **Analyzer** — reads `snapshot` sequence + `type`. Reconstructs roster from event stream (add/remove by id). NO payload arrays needed.
## Legacy Support
- **Viewer:** yes — show old message-only logs, no type parse
- **Undo:** no — old logs unundoable (acceptable, sit inert)
- **Analyzer:** best-effort — snapshot may be absent on legacy → skip invariant check for that run
## Why Lean
- `add_participant` 16KB → ~150 bytes
- Replay trace 5.5MB → ~250KB
- Undo still works (proven old log did same)
- Analyzer unaffected (uses snapshot+type, not payload)
## Migration Plan
### Phase 1: Redesign event writers (shared/turn.js)
- [ ] Rewrite `buildEntry()` to produce lean shape (delta fields per type)
- [ ] Rewrite `commit()` — no patch storage, write lean event
- [ ] Per-func: compute minimal delta + inverse from enc before/after
- [ ] Drop `payload`, `undo_payload`, `undo_payload.redo`
### Phase 2: Update undo executor
- [ ] Find undo consumer (App.js `applyUndo` or similar)
- [ ] Switch to id-based inverse application
- [ ] Handle each type's undo shape
- [ ] Drop `transactionalUndo` full-patch path if exists
### Phase 3: Update replay trace
- [ ] `replay-combat.js` callStep: trust return, snapshot only (no read-back)
- [ ] Trace = `{step, ts, type, pre:leanSnap, post:leanSnap}` — no participant arrays
### Phase 4: Update analyzer
- [ ] `analyze-turns.js` already uses snapshot+type only — verify no payload dep
- [ ] Reconstruct roster from event stream for skip/double-act checks (id-level)
### Phase 5: Legacy compat
- [ ] `logEvent.js normalizeEvent` — lift legacy into lean shape (type='legacy', message only)
- [ ] Viewer handles legacy gracefully
- [ ] Download = lean events only (legacy excluded or message-only)
### Phase 6: Cleanup
- [ ] Delete old bloated docs from DB? Or leave (inert, viewer reads message)?
- [ ] Verify build CI=true passes
- [ ] Verify analyzer clean on fresh replay
- [ ] Verify undo works on new events
## NOT in scope
- Fixing 5 pre-existing initiative bugs (logged in TODO.md, separate)
- Changing snapshot shape (already lean)
- Changing viewer UI (just data source)
## Open Questions
- Undo for `remove_participant`: must restore full obj (only place full obj needed). Acceptable?
- Should legacy logs be deleted from DB or left inert?
- Download format: lean JSON array (no legacy) — confirm
+10 -10
View File
@@ -61,7 +61,7 @@ The storage interface is the test seam and the upstream-compat layer.
| Impl | When used | Automated-tested? | | Impl | When used | Automated-tested? |
|---|---|---| |---|---|---|
| `firebase.js` | default (`STORAGE=firebase`) — upstream path | No — requires live Firebase project | | `firebase.js` | default (`STORAGE=firebase`) — upstream path | No — requires live Firebase project |
| `ws.js` | `STORAGE=ws` — our fork, talks to backend | Yes — against running backend | | `server.js` | `STORAGE=server` — our fork, talks to backend | Yes — against running backend |
| `memory.js` | test-only, in-process | Yes — fast, deterministic | | `memory.js` | test-only, in-process | Yes — fast, deterministic |
**Frontend interface contract** (all three implement): **Frontend interface contract** (all three implement):
@@ -82,7 +82,7 @@ Memory impl: in-memory Map + EventEmitter, for tests (M3).
storage/ storage/
index.js # factory: pick impl from STORAGE env index.js # factory: pick impl from STORAGE env
firebase.js # extracted from current App.js (verbatim) firebase.js # extracted from current App.js (verbatim)
ws.js # NEW — talks to backend server.js # talks to backend (was ws.js)
memory.js # NEW — test only memory.js # NEW — test only
contract.js # interface spec (runStorageContract) contract.js # interface spec (runStorageContract)
tests/ # frontend tests tests/ # frontend tests
@@ -116,7 +116,7 @@ Each milestone = independently mergeable PR upstream (unless marked ❌).
| M | Does | Tests? | | M | Does | Tests? |
|---|---|---| |---|---|---|
| 0 | repo, branch, remotes | no | | 0 | repo, branch, remotes | no |
| 1 | build backend (Node+Express+ws+better-sqlite3) | unit tests as built | | 1 | build backend (Node+Express+WebSocket+better-sqlite3) | unit tests as built |
| 2 | frontend WS adapter — app runs vs backend, cross-device works | yes | | 2 | frontend WS adapter — app runs vs backend, cross-device works | yes |
| 3 | characterization tests lock current behavior | yes | | 3 | characterization tests lock current behavior | yes |
| 4 | resolve initiative rotation corruption (BUG-5) | yes | | 4 | resolve initiative rotation corruption (BUG-5) | yes |
@@ -135,7 +135,7 @@ Each milestone = independently mergeable PR upstream (unless marked ❌).
- **Upstream-PRable:** n/a (fork infra) - **Upstream-PRable:** n/a (fork infra)
### Milestone 1 — Build backend ✅ ### Milestone 1 — Build backend ✅
- `server/`: Express + ws + better-sqlite3. - `server/`: Express + WebSocket + better-sqlite3.
- Generic KV doc store (firebase mirror): `docs` table (path PK, parent, data JSON, updated_at). REST: GET/PUT/PATCH/DELETE `/api/doc?path=`, GET `/api/collection?path=`, POST `/api/collection`, POST `/api/batch`. WS: subscribe by path. - Generic KV doc store (firebase mirror): `docs` table (path PK, parent, data JSON, updated_at). REST: GET/PUT/PATCH/DELETE `/api/doc?path=`, GET `/api/collection?path=`, POST `/api/collection`, POST `/api/batch`. WS: subscribe by path.
- Server holds authoritative state. No turn logic server-side (logic stays client-side in `shared/turn.js`). - Server holds authoritative state. No turn logic server-side (logic stays client-side in `shared/turn.js`).
- **Exit criteria:** backend boots, serves state over WS, persists to SQLite, unit tests green. ✅ DONE. - **Exit criteria:** backend boots, serves state over WS, persists to SQLite, unit tests green. ✅ DONE.
@@ -144,10 +144,10 @@ Each milestone = independently mergeable PR upstream (unless marked ❌).
### Milestone 2 — Frontend WS adapter ✅ ### Milestone 2 — Frontend WS adapter ✅
- Define `storage/contract.js` interface spec. - Define `storage/contract.js` interface spec.
- Move all Firestore call sites from `App.js` into `storage/firebase.js` behind interface (verbatim). - Move all Firestore call sites from `App.js` into `storage/firebase.js` behind interface (verbatim).
- Implement `storage/ws.js` per interface, talking to backend. Generic KV ops, subscribes to WS. - Implement `storage/server.js` per interface, talking to backend. Generic KV ops, subscribes via WebSocket.
- Implement `storage/memory.js` for frontend unit tests. - Implement `storage/memory.js` for frontend unit tests.
- `storage/index.js` factory: `STORAGE` env → pick impl. Default `firebase` (upstream unchanged). - `storage/index.js` factory: `STORAGE` env → pick impl. Default `firebase` (upstream unchanged).
- App runs against backend with `STORAGE=ws`. - App runs against backend with `STORAGE=server`.
- Cross-device verified manually: DM view + player display + tablet. - Cross-device verified manually: DM view + player display + tablet.
- **Exit criteria:** app runs fully against local backend, no Firebase. Multi-device sync works. ✅ DONE. - **Exit criteria:** app runs fully against local backend, no Firebase. Multi-device sync works. ✅ DONE.
- **Upstream-PRable:** ⚠️ partial. Storage interface + firebase extract = ✅. WS impl = ❌. - **Upstream-PRable:** ⚠️ partial. Storage interface + firebase extract = ✅. WS impl = ❌.
@@ -155,7 +155,7 @@ Each milestone = independently mergeable PR upstream (unless marked ❌).
### Milestone 3 — Characterization tests lock current behavior ✅ ### Milestone 3 — Characterization tests lock current behavior ✅
- Lock current behavior via tests. - Lock current behavior via tests.
- Cover: START, NEXT_TURN, PAUSE, RESUME, ADD_PARTICIPANT, REMOVE_PARTICIPANT, TOGGLE_ACTIVE, REORDER, APPLY_DAMAGE/HEAL, DEATH_SAVE, END. - Cover: START, NEXT_TURN, PAUSE, RESUME, ADD_PARTICIPANT, REMOVE_PARTICIPANT, TOGGLE_ACTIVE, REORDER, APPLY_DAMAGE/HEAL, DEATH_SAVE, END.
- Two layers: Layer 1 (App + firebase mock, proves call shape), Layer 2 (ws adapter vs live backend, proves translation). - Two layers: Layer 1 (App + firebase mock, proves call shape), Layer 2 (server adapter vs live backend, proves translation).
- Iterate until confident: baseline solid, regressions impossible to silently slip. - Iterate until confident: baseline solid, regressions impossible to silently slip.
- **Exit criteria:** characterization suite green. Baseline locked. ✅ DONE. - **Exit criteria:** characterization suite green. Baseline locked. ✅ DONE.
- **Upstream-PRable:** ✅ if kept storage-agnostic (tests target turn logic shape). - **Upstream-PRable:** ✅ if kept storage-agnostic (tests target turn logic shape).
@@ -173,7 +173,7 @@ Each milestone = independently mergeable PR upstream (unless marked ❌).
- Single container: caddy (front, static + proxy) + node backend (internal :4001). - Single container: caddy (front, static + proxy) + node backend (internal :4001).
- Files in `docker/` tree (kept separate from upstream root Dockerfile): - Files in `docker/` tree (kept separate from upstream root Dockerfile):
- `docker/Dockerfile` — build FE + BE, runtime caddy+node - `docker/Dockerfile` — build FE + BE, runtime caddy+node
- `docker/Caddyfile` — proxy /api + /ws to node, static SPA fallback - `docker/Caddyfile` — proxy /api + /ws (WebSocket path) to node, static SPA fallback
- `docker/entrypoint.sh` — node bg + caddy fg - `docker/entrypoint.sh` — node bg + caddy fg
- `docker/docker-compose.yml` — one `app` service, volume for sqlite - `docker/docker-compose.yml` — one `app` service, volume for sqlite
- Run: `docker compose -f docker/docker-compose.yml up --build` (or `cd docker && docker compose up --build`). Port 8080. - Run: `docker compose -f docker/docker-compose.yml up --build` (or `cd docker && docker compose up --build`). Port 8080.
@@ -214,7 +214,7 @@ Each milestone = independently mergeable PR upstream (unless marked ❌).
### Manual smoke via config flags ### Manual smoke via config flags
- `STORAGE=firebase` → current behavior (friend's path, upstream default). - `STORAGE=firebase` → current behavior (friend's path, upstream default).
- `STORAGE=ws` → our path, local backend. - `STORAGE=server` → our path, local backend.
- docker-compose profiles mirror the above. - docker-compose profiles mirror the above.
### Accepted test gap ### Accepted test gap
@@ -261,6 +261,6 @@ Default `STORAGE=firebase` + `AUTH_MODE=none` (unset) = upstream sees literally
- M0 ✅, M1 ✅, M2 ✅, M3 ✅, M4 ✅, M5 ✅ - M0 ✅, M1 ✅, M2 ✅, M3 ✅, M4 ✅, M5 ✅
- Backend live: port 4001, db `./data/tracker.sqlite` - Backend live: port 4001, db `./data/tracker.sqlite`
- Frontend: port 3999 with `REACT_APP_STORAGE=ws` - Frontend: port 3999 with `REACT_APP_STORAGE=server`
- Test suite: ~160 tests (shared + server + FE). Bugs tracked in `TODO.md`. - Test suite: ~160 tests (shared + server + FE). Bugs tracked in `TODO.md`.
- Next milestones: M5 docker-compose. Undo moved to TODO backlog. - Next milestones: M5 docker-compose. Undo moved to TODO backlog.
+6 -7
View File
@@ -118,7 +118,7 @@ node tests/audit/audit-rotation.js
Runs pure turn.js combat. Audits 9 invariant classes per round: Runs pure turn.js combat. Audits 9 invariant classes per round:
1. rotation integrity (skip/dupe) 1. rotation integrity (skip/dupe)
2. HP bounds (0 ≤ hp ≤ max, no NaN) 2. HP bounds (0 ≤ hp ≤ max, no NaN)
3. isActive consistency (dead = inactive) 3. isActive consistency (inactive skipped; monster death auto-inactive; character/NPC death DM-controlled)
4. turnOrder no dup ids 4. turnOrder no dup ids
5. turnOrder ids all active 5. turnOrder ids all active
6. currentTurn valid + active 6. currentTurn valid + active
@@ -194,11 +194,11 @@ curl http://127.0.0.1:4001/health # → {"ok":true}
Never db in `/tmp` (wipe risk). Use `./data/` (gitignored) or docker volume. Never db in `/tmp` (wipe risk). Use `./data/` (gitignored) or docker volume.
### Frontend (ws mode) ### Frontend (server mode)
```bash ```bash
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 \
BROWSER=none PORT=3999 \ BROWSER=none PORT=3999 \
npm start npm start
``` ```
@@ -210,10 +210,9 @@ Firebase mode (default): set `REACT_APP_FIREBASE_*` in `.env.local` (copy `env.e
`STORAGE_MODE = getStorageMode()` reads `REACT_APP_STORAGE`: `STORAGE_MODE = getStorageMode()` reads `REACT_APP_STORAGE`:
- `firebase` (default) → real SDK - `firebase` (default) → real SDK
- `ws` → backend (docker/prod) - `server` → backend (docker/prod)
- `memory` → in-process (test seed)
All adapters ESM. Adapter contract: `src/storage/contract.js` — same spec vs memory/ws/firebase. All adapters ESM. Adapter contract: `src/storage/contract.js` — same spec vs server/firebase.
## Known RED / backlog ## Known RED / backlog
+7
View File
@@ -7,3 +7,10 @@ REACT_APP_FIREBASE_MESSAGING_SENDER_ID="YOUR_FIREBASE_MESSAGING_SENDER_ID_HERE"
REACT_APP_FIREBASE_APP_ID="YOUR_FIREBASE_APP_ID_HERE" REACT_APP_FIREBASE_APP_ID="YOUR_FIREBASE_APP_ID_HERE"
REACT_APP_TRACKER_APP_ID="ttrpg-initiative-tracker-default" REACT_APP_TRACKER_APP_ID="ttrpg-initiative-tracker-default"
# --- Self-hosted backend mode (optional) ---
# Default storage = firebase (SDK). Set REACT_APP_STORAGE=server to use the
# bundled Express + WebSocket backend (server/) instead.
# REACT_APP_STORAGE="server"
# REACT_APP_BACKEND_URL="http://127.0.0.1:4001"
# REACT_APP_BACKEND_REALTIME_URL="ws://127.0.0.1:4001/ws"
+24 -10
View File
@@ -13,7 +13,6 @@
], ],
"dependencies": { "dependencies": {
"@testing-library/jest-dom": "^5.17.0", "@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"firebase": "^10.12.2", "firebase": "^10.12.2",
@@ -24,6 +23,9 @@
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"tailwindcss": "^3.4.3", "tailwindcss": "^3.4.3",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
},
"devDependencies": {
"@testing-library/react": "^14.3.1"
} }
}, },
"node_modules/@adobe/css-tools": { "node_modules/@adobe/css-tools": {
@@ -5237,17 +5239,18 @@
} }
}, },
"node_modules/@testing-library/react": { "node_modules/@testing-library/react": {
"version": "13.4.0", "version": "14.3.1",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-13.4.0.tgz", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz",
"integrity": "sha512-sXOGON+WNTh3MLE9rve97ftaZukN3oNf2KjDy7YTx6hcTO2uuLHuCGynMDhFwGw/jYf4OJ2Qk0i4i79qMNNkyw==", "integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
"@testing-library/dom": "^8.5.0", "@testing-library/dom": "^9.0.0",
"@types/react-dom": "^18.0.0" "@types/react-dom": "^18.0.0"
}, },
"engines": { "engines": {
"node": ">=12" "node": ">=14"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^18.0.0", "react": "^18.0.0",
@@ -5255,9 +5258,10 @@
} }
}, },
"node_modules/@testing-library/react/node_modules/@testing-library/dom": { "node_modules/@testing-library/react/node_modules/@testing-library/dom": {
"version": "8.20.1", "version": "9.3.4",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz",
"integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.10.4", "@babel/code-frame": "^7.10.4",
@@ -5270,13 +5274,14 @@
"pretty-format": "^27.0.2" "pretty-format": "^27.0.2"
}, },
"engines": { "engines": {
"node": ">=12" "node": ">=14"
} }
}, },
"node_modules/@testing-library/react/node_modules/aria-query": { "node_modules/@testing-library/react/node_modules/aria-query": {
"version": "5.1.3", "version": "5.1.3",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz",
"integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"deep-equal": "^2.0.5" "deep-equal": "^2.0.5"
@@ -5286,6 +5291,7 @@
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-styles": "^4.1.0", "ansi-styles": "^4.1.0",
@@ -5635,6 +5641,7 @@
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
@@ -5660,6 +5667,7 @@
"version": "18.3.27", "version": "18.3.27",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
@@ -5671,6 +5679,7 @@
"version": "18.3.7", "version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0" "@types/react": "^18.0.0"
@@ -9383,6 +9392,7 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
@@ -9505,6 +9515,7 @@
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz",
"integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"array-buffer-byte-length": "^1.0.0", "array-buffer-byte-length": "^1.0.0",
@@ -10118,6 +10129,7 @@
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz",
"integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind": "^1.0.2", "call-bind": "^1.0.2",
@@ -12560,6 +12572,7 @@
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bound": "^1.0.2", "call-bound": "^1.0.2",
@@ -16816,6 +16829,7 @@
"version": "1.1.6", "version": "1.1.6",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind": "^1.0.7", "call-bind": "^1.0.7",
+7 -3
View File
@@ -8,7 +8,6 @@
], ],
"dependencies": { "dependencies": {
"@testing-library/jest-dom": "^5.17.0", "@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"firebase": "^10.12.2", "firebase": "^10.12.2",
@@ -22,13 +21,15 @@
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",
"build": "react-scripts build", "build": "CI=true react-scripts build",
"test": "react-scripts test", "test": "react-scripts test",
"test:ci": "./scripts/cap.sh 60 npx react-scripts test --watchAll=false",
"test:one": "./scripts/cap.sh 60 npx react-scripts test --watchAll=false --testPathPattern",
"eject": "react-scripts eject", "eject": "react-scripts eject",
"server:dev": "npm run dev --workspace server", "server:dev": "npm run dev --workspace server",
"server:test": "npm test --workspace server", "server:test": "npm test --workspace server",
"shared:test": "npm test --workspace shared", "shared:test": "npm test --workspace shared",
"test:all": "npm run shared:test && npm run server:test" "test:all": "./scripts/run-tests.sh"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
@@ -47,5 +48,8 @@
"last 1 firefox version", "last 1 firefox version",
"last 1 safari version" "last 1 safari version"
] ]
},
"devDependencies": {
"@testing-library/react": "^14.3.1"
} }
} }
+12 -2
View File
@@ -1,10 +1,20 @@
# 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
Live backend demo. Drives full combat via ws adapter (same contract as App). Live backend demo. Drives full combat via server adapter (same contract as App).
Player display live-updates. Watch UI react to state changes. Player display live-updates. Watch UI react to state changes.
```bash ```bash
+415 -294
View File
@@ -1,315 +1,436 @@
// scripts/analyze-turns.js // scripts/analyze-turns.js
// Ingest replay-combat.js stdout (or any text matching its format), reconstruct // Invariant checker for combat rotation. Source-agnostic.
// rounds, report real skips + double-acts. Deterministic — no eyeballing.
// //
// Usage: // Input (autodetect):
// node scripts/analyze-turns.js [path] # analyze a saved log file // .jsonl file — replay-combat trace: per-step {step,ts,type,call:{fn,args},
// node scripts/replay-combat.js 100 100 | node scripts/analyze-turns.js // pre,post}. pre/post = backend read-back snapshots.
// cat /tmp/replay.log | node scripts/analyze-turns.js // .json array — downloaded log OR exported events. {ts,type,...,snapshot}.
// snapshot = what turn.js logged (lighter: no participants[]).
// .log file — replay stdout: extract trace path from 'trace written:' line.
// stdin — either jsonl or json.
// no arg = usage. User must specify path.
// //
// Skip = participant active for WHOLE round (never deactivated/removed mid-round // INVARIANTS (define correctness; no prediction):
// before their slot, never added mid-round) but never appeared as a turn actor. // 1. round monotonically ascends; +1 only on pointer wrap (last→first active).
// Double-act = same participant takes 2+ turns in one round. // No backward, no double-increment, no skip.
// 2. pointer advances forward in turnOrderIds (mod wrap), skipping inactive.
// Never backward, never stationary except on pause/non-rotation mutations.
// 3. no double-act: in one rotation cycle each active participant becomes
// current ≤1 time.
// 4. no real skip: participant active for full cycle, never removed/deactivated,
// but never became current = skipped.
// 5. order stable across non-reorder mutations. turnOrderIds shift without
// add/remove/reorder = display divergence.
// 6. slot order (initiative desc, tie-break stable) maintained except after
// explicit reorder. Replay-trace only (needs participants[].initiative).
// //
// FEAT-2 (structured turn snapshot in app logs) will let this ingest live app // Exit 0 clean, 1 issues found.
// logs too, not just replay stdout. Format-agnostic core lives in parseReplay().
'use strict'; 'use strict';
const fs = require('fs'); const fs = require('fs');
// ---------- parsing ---------- // ---------- input ----------
const TURN_RE = /^\s*turn\s+(\d+)\s+\(round\s+(\d+)\):\s+(.+?)(?:\s*\|\s*order=\[(.*)\](?:\s*cur=.*)?)?\s*$/;
const DEACTIVATE_RE = /^\s*\[(?:deactivate)\s+(.+?)\]\s*$/;
const REACTIVATE_RE = /^\s*\[(?:revive-reactivate|reactivate)\s+(.+?)\]\s*$/;
const ADD_RE = /^\s*\[(?:add)\s+(.+?)\]\s*$/;
const REMOVE_RE = /^\s*\[(?:remove dead|remove)\s+(.+?)\]\s*$/;
const PAUSE_RE = /^\s*\[pause\]\s*$/;
const RESUME_RE = /^\s*\[resume\]\s*$/;
const ROUND_COMPLETE_RE = /^\s*---\s*round\s+(\d+)\s+(?:complete|starting)/;
const FIRST_RE = /^combat started:\s+round\s+\d+,\s+first=(.+?)\s*$/;
const REORDER_RE = /^\s*\[reorder\s+(.+?)→before\s+(.+?)\]\s*$/;
const POINTER_RE = /^\s*\[pointer\s+(.+?)→(.+?)( wrap)?\]\s*$/;
function parseLine(line) {
if (TURN_RE.test(line)) {
const m = line.match(TURN_RE);
const orderStr = m[4] || '';
// parse Name:init pairs
const order = orderStr.split(',').map(s => s.trim()).filter(Boolean).map(pair => {
const [name, init] = pair.split(':');
return { name: name.trim(), init: init !== undefined ? +init : null };
});
return { kind: 'turn', turn: +m[1], round: +m[2], actor: m[3].trim(), order };
}
if (FIRST_RE.test(line)) {
const m = line.match(FIRST_RE);
return { kind: 'turn', turn: 0, round: 1, actor: m[1].trim() };
}
if (DEACTIVATE_RE.test(line)) return { kind: 'deactivate', name: line.match(DEACTIVATE_RE)[1].trim() };
if (REACTIVATE_RE.test(line)) return { kind: 'reactivate', name: line.match(REACTIVATE_RE)[1].trim() };
if (ADD_RE.test(line)) return { kind: 'add', name: line.match(ADD_RE)[1].trim() };
if (REMOVE_RE.test(line)) return { kind: 'remove', name: line.match(REMOVE_RE)[1].trim() };
if (PAUSE_RE.test(line)) return { kind: 'pause' };
if (RESUME_RE.test(line)) return { kind: 'resume' };
if (POINTER_RE.test(line)) {
const m = line.match(POINTER_RE);
return { kind: 'pointer', from: m[1].trim(), to: m[2].trim(), wrap: m[3] === ' wrap' };
}
if (REORDER_RE.test(line)) {
const m = line.match(REORDER_RE);
return { kind: 'reorder', dragged: m[1].trim(), target: m[2].trim() };
}
if (ROUND_COMPLETE_RE.test(line)) return { kind: 'round-complete', round: +line.match(ROUND_COMPLETE_RE)[1] };
return null;
}
// ---------- reconstruction ----------
// Build per-round timeline: round -> { turns: [actor], mutations: [{stepIdx,...}] }
// Then compute skips + double-acts.
function reconstruct(events) {
// global state: active set by name. Start populated lazily from first turn.
const active = new Set();
const rounds = new Map(); // round -> { turns: [name], events: [{...}] }
let curRound = 1;
let sawFirstTurn = false;
for (const ev of events) {
if (ev.kind === 'turn') {
sawFirstTurn = true;
curRound = ev.round;
if (!rounds.has(curRound)) rounds.set(curRound, { turns: [], events: [], complete: false });
const r = rounds.get(curRound);
r.turns.push(ev.actor);
r.events.push({ ...ev, idx: r.events.length });
if (!active.has(ev.actor)) active.add(ev.actor); // first sighting = active
} else if (ev.kind === 'deactivate') {
active.delete(ev.name);
const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound);
r.events.push({ ...ev, idx: r.events.length });
} else if (ev.kind === 'reactivate' || ev.kind === 'add') {
active.add(ev.name);
const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound);
r.events.push({ ...ev, idx: r.events.length });
} else if (ev.kind === 'remove') {
active.delete(ev.name);
const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound);
r.events.push({ ...ev, idx: r.events.length });
} else if (ev.kind === 'pointer') {
// wrap pointer advances to next round — credit there.
if (ev.wrap) curRound += 1;
const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound);
r.events.push({ ...ev, idx: r.events.length });
} else if (ev.kind === 'reorder') {
const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound);
r.events.push({ ...ev, idx: r.events.length });
} else if (ev.kind === 'round-complete') {
if (rounds.has(ev.round)) rounds.get(ev.round).complete = true;
}
// pause/resume: rotation-affecting but no roster change; tracked in events
else if (ev.kind === 'pause' || ev.kind === 'resume') {
const r = rounds.get(curRound) || rounds.set(curRound, { turns: [], events: [], complete: false }).get(curRound);
r.events.push({ ...ev, idx: r.events.length });
}
}
return rounds;
}
// For each round, recompute active-at-start and acted, then find real skips.
function analyze(rounds) {
const report = [];
for (const [roundN, r] of [...rounds.entries()].sort((a, b) => a[0] - b[0])) {
// Replay stdout doesn't dump roster, so infer "active at round start":
// walk events IN ORDER, snapshot active set at first turn of this round.
// We replay from a clean per-round pass using a carry-over active set.
report.push(analyzeRound(roundN, r));
}
return report;
}
// Re-run per-round with active-set carry-over across rounds (module scope).
function analyzeRounds(rounds) {
// Carry active set + current-name forward round to round.
let activeCarry = new Set();
let currentCarry = null;
const reports = [];
const sortedRounds = [...rounds.entries()].sort((a, b) => a[0] - b[0]);
for (const [roundN, r] of sortedRounds) {
if (!r.complete) continue; // incomplete final round — can't judge skips
if (roundN === 1) { activeCarry = new Set(); currentCarry = null; }
const result = analyzeRoundWithCarry(roundN, r, activeCarry, currentCarry);
reports.push(result.report);
activeCarry = result.activeAfter;
currentCarry = result.currentAfter;
}
return reports;
}
// When current participant is deactivated/removed, code advances current to
// next active. That target gets the turn pointer = acts. Parser can't see
// roster/order from stdout, so on deact-current the NEXT turn actor is the
// advance target and is credited an extra "pointer turn" (not a logged turn).
function analyzeRoundWithCarry(roundN, r, activeAtStart, currentAtStart) {
// activeAtStart: Set copy. Mutations during round adjust a working copy.
const active = new Set(activeAtStart);
const activeWholeRound = new Set(activeAtStart); // participants never toggled off/removed
const addedThisRound = new Set();
const turns = []; // ordered actor names (logged)
const pointerTurns = new Set(); // names that got the turn pointer this round
let current = currentAtStart; // current participant name (carry)
for (const ev of r.events) {
if (ev.kind === 'turn') {
turns.push(ev.actor);
pointerTurns.add(ev.actor);
if (!active.has(ev.actor)) active.add(ev.actor); // first-ever sighting
current = ev.actor;
} else if (ev.kind === 'pointer') {
// mutation advanced current pointer: ev.to now holds it = got the turn.
// Credit ev.to. Update tracking.
pointerTurns.add(ev.to);
current = ev.to;
} else if (ev.kind === 'deactivate' || ev.kind === 'remove') {
// deact/REMOVE of current → code auto-advances (emitted as pointer line).
// Disqualify from whole-round (roster mutation = not "whole round").
activeWholeRound.delete(ev.name);
active.delete(ev.name);
} else if (ev.kind === 'reactivate' || ev.kind === 'add') {
activeWholeRound.delete(ev.name);
active.add(ev.name);
}
}
// acted = names that took a turn OR got pointer via mutation-advance
// (deact/remove of current advances to target — that target acts).
// Pointer lines from replay tell us the target explicitly.
const acted = new Set([...turns, ...pointerTurns]);
// double-acts: logged turns with count > 1 (pointer-credits excluded —
// a deact-advance target acting once via pointer then once via nextTurn
// is correct, not a bug).
const counts = {};
for (const n of turns) counts[n] = (counts[n] || 0) + 1;
const doubleActs = Object.entries(counts).filter(([_, c]) => c > 1).map(([n, c]) => ({ name: n, count: c }));
// real skip: active for WHOLE round (no roster mutation) AND never got
// turn/pointer. Mutations disqualify from whole-round already.
const realSkips = [...activeWholeRound].filter(n => !acted.has(n));
return {
report: {
round: roundN,
turnCount: turns.length,
uniqueActors: acted.size,
realSkips,
doubleActs,
turns,
},
activeAfter: active,
currentAfter: current,
};
}
// ---------- order-shift detection ----------
// Compare order+init between consecutive turn lines. Flag shifts NOT explained
// by: logged reorder, add/remove (roster change), or initiative change.
// DM drag-reorder = legit (logged reorder line). Phantom shifts = display/rotation
// divergence bug (invariant: display === turnOrderIds === nextTurn).
function detectOrderShifts(events) {
const shifts = [];
let prev = null;
let prevTurnNo = null;
// mutations since last turn (reorder/add/remove/reactivate/pointer)
let pending = [];
let initMap = {}; // name -> last known initiative
for (const ev of events) {
if (ev.kind === 'turn' && ev.order && ev.order.length) {
const curNames = ev.order.map(o => o.name);
const curInits = {};
ev.order.forEach(o => { curInits[o.name] = o.init; });
if (prev) {
const sameRoster = prev.length === curNames.length &&
prev.every((n, i) => n === curNames[i]);
if (!sameRoster) {
// roster change (add/remove) — skip, expected order shift
} else {
// same roster, different order → explainable by reorder OR init change?
const orderChanged = JSON.stringify(prev) !== JSON.stringify(curNames);
const initChanged = ev.order.some(o => initMap[o.name] !== null && initMap[o.name] !== undefined && initMap[o.name] !== o.init);
const hasReorder = pending.some(p => p.kind === 'reorder');
if (orderChanged && !hasReorder && !initChanged) {
shifts.push({ turn: ev.turn, from: prev, to: curNames, reason: 'no logged reorder/init change' });
}
}
}
prev = curNames;
curInits && Object.keys(curInits).forEach(k => { initMap[k] = curInits[k]; });
pending = [];
prevTurnNo = ev.turn;
} else if (ev.kind === 'reorder' || ev.kind === 'add' || ev.kind === 'remove' ||
ev.kind === 'reactivate' || ev.kind === 'pointer') {
pending.push(ev);
}
}
return shifts;
}
// ---------- CLI ----------
function readInput() { function readInput() {
const arg = process.argv[2]; const arg = process.argv[2];
if (arg) return fs.readFileSync(arg, 'utf8');
// stdin
return fs.readFileSync(0, 'utf8');
}
function main() { // No arg + no stdin = usage.
const text = readInput(); if (!arg && process.stdin.isTTY) {
const lines = text.split('\n'); console.error('Usage: node scripts/analyze-turns.js <trace.jsonl | logs.json | replay.log>');
const events = lines.map(parseLine).filter(Boolean); console.error(' cat events | node scripts/analyze-turns.js');
const rounds = reconstruct(events); process.exit(2);
const reports = analyzeRounds(rounds);
let totalSkips = 0;
let totalDoubles = 0;
const problemRounds = [];
for (const rep of reports) {
const hasIssue = rep.realSkips.length > 0 || rep.doubleActs.length > 0;
if (hasIssue) problemRounds.push(rep);
totalSkips += rep.realSkips.length;
totalDoubles += rep.doubleActs.length;
} }
for (const rep of problemRounds) { if (!arg) {
console.log(`R${rep.round}: turns=${rep.turnCount} unique=${rep.uniqueActors}`); const stdin = fs.readFileSync(0, 'utf8');
if (rep.realSkips.length) console.log(` REAL SKIPS: ${rep.realSkips.join(', ')}`); if (!stdin.trim()) {
if (rep.doubleActs.length) console.log(` DOUBLE-ACTS: ${rep.doubleActs.map(d => `${d.name}(${d.count}x)`).join(', ')}`); console.error('Usage: node scripts/analyze-turns.js <trace.jsonl | logs.json | replay.log>');
console.log(` sequence: ${rep.turns.join(' -> ')}`); console.error(' cat events | node scripts/analyze-turns.js');
} process.exit(2);
// order-shift detection: flag unexplained display/rotation divergence
const shifts = detectOrderShifts(events);
if (shifts.length) {
console.log(`\n--- order shifts (${shifts.length}) ---`);
for (const s of shifts.slice(0, 10)) {
console.log(` turn ${s.turn}: [${s.from.join(',')}] → [${s.to.join(',')}] (${s.reason})`);
} }
if (shifts.length > 10) console.log(` ... +${shifts.length - 10} more`); return stdin;
} }
console.log(`\n=== ${reports.length} rounds analyzed ===`); // Replay stdout .log: extract trace path from `trace written:` line.
console.log(`real skips: ${totalSkips}`); if (/\.log$/i.test(arg)) {
console.log(`double-acts: ${totalDoubles}`); const logText = fs.readFileSync(arg, 'utf8');
console.log(`order shifts: ${shifts.length}`); const m = logText.match(/trace written: \d+ steps -> (.+)$/m);
const clean = totalSkips === 0 && totalDoubles === 0 && shifts.length === 0; if (m) {
console.log(clean ? 'CLEAN — no rotation bugs' : 'ISSUES FOUND'); const tracePath = m[1].trim();
if (fs.existsSync(tracePath)) {
console.error(`[analyze] trace: ${tracePath}`);
return fs.readFileSync(tracePath, 'utf8');
}
console.error(`[analyze] trace path from .log not found: ${tracePath}`);
process.exit(2);
}
console.error(`[analyze] no 'trace written:' line in ${arg}`);
process.exit(2);
}
process.exit(clean ? 0 : 1); return fs.readFileSync(arg, 'utf8');
} }
main(); // snake_case log type → camelCase fn (invariant checks match both shapes).
// replay JSONL already camelCase; unchanged.
function normalizeFn(fn) {
if (!fn) return fn;
if (!fn.includes('_')) return fn;
return fn.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
}
// Parse input text → array of step-arrays (one per encounter for downloaded
// logs, one for replay JSONL trace). Each step:
// { step, ts, type, fn, args, pre, post, error }
// JSONL: pre/post from backend read-back. JSON array: post=snapshot, no pre.
function loadSteps(text) {
const trimmed = text.trim();
if (!trimmed) return [];
// JSONL: one JSON obj per line (each starts '{' + parses standalone).
const looksJsonl = (() => {
const lines = trimmed.split('\n');
if (lines.length < 2) return false;
const first = lines[0].trim();
const second = lines[1].trim();
if (!first.startsWith('{') || !second.startsWith('{')) return false;
try { JSON.parse(first); JSON.parse(second); return true; }
catch { return false; }
})();
if (looksJsonl) {
const steps = trimmed.split('\n').filter(l => l.trim()).map(l => {
const r = JSON.parse(l);
return {
step: r.step, ts: r.ts, type: r.type,
fn: normalizeFn(r.call ? r.call.fn : r.type),
args: r.call ? r.call.args : null,
pre: r.pre || null, post: r.post || null, error: r.error || null,
};
});
return [steps]; // single trace
}
// JSON array. Downloaded logs may merge multiple encounters → split by id.
const raw = JSON.parse(trimmed);
const arr = Array.isArray(raw) ? raw : [raw];
const groups = new Map(); // encounterId -> []
for (const e of arr) {
const key = e.encounterId || '_none_';
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(e);
}
const out = [];
const toSteps = (evs) => evs.map((e, i) => ({
step: i + 1, ts: e.ts || 0, type: e.type, fn: normalizeFn(e.type), args: null,
pre: null,
post: e.snapshot ? {
round: e.snapshot.round,
currentTurnParticipantId: e.snapshot.currentTurnParticipantId,
isStarted: true, isPaused: false,
turnOrderIds: e.snapshot.turnOrderIds || [],
activeIds: e.snapshot.activeIds || [],
participants: null, // downloaded logs lack full participant roster
} : null,
error: null,
}));
for (const evs of groups.values()) {
// Same encounterId may span multiple combat runs (restart via
// start_encounter). Sub-split so each rotation cycle analyzed within one
// continuous run. start_encounter = run boundary — BUT only flush if a
// prior run already started (i.e. true restart). First start_encounter
// after pure setup (add_participant etc.) stays with its setup events.
let cur = [];
let started = false;
const flush = () => { if (cur.length) { out.push(toSteps(cur)); cur = []; } started = false; };
for (const e of evs) {
if (e.type === 'start_encounter' && started) flush();
if (e.type === 'start_encounter') started = true;
cur.push(e);
}
flush();
}
return out;
}
// ---------- helpers ----------
const nameMap = new Map(); // id -> name (built lazily from snapshots)
function learnNames(steps) {
for (const s of steps) {
for (const snap of [s.pre, s.post]) {
if (snap && Array.isArray(snap.participants)) {
for (const p of snap.participants) if (p.id && p.name) nameMap.set(p.id, p.name);
}
}
}
}
function nm(id) { return id ? (nameMap.get(id) || id.slice(0, 8)) : '(none)'; }
// next active position after fromPos in order, skipping inactive. Mirrors
// turn.js nextActiveAfter so we know what SHOULD have happened — but this is
// invariant definition, not prediction: we check the ACTUAL post-current.
function expectedAdvance(order, fromPos, isActive) {
const n = order.length;
if (n === 0) return { nextId: null, wrapped: false };
for (let step = 1; step < n; step++) {
const idx = (fromPos + step) % n;
const id = order[idx];
if (isActive(id)) return { nextId: id, wrapped: idx <= fromPos };
}
// solo active = stays itself (turn.js would throw; treat as no-advance)
return { nextId: null, wrapped: false };
}
// ---------- invariant checks ----------
// Split analysis into independent passes. Each invariant = own function.
// Entangling cycle-skip tracking with per-step mutation handling caused
// stale-set false positives (active-set rebuilt on every roster mutation
// discarded the cycle-start snapshot).
function analyze(steps) {
const issues = [];
const rounds = new Map();
function ensureRound(r) {
if (!rounds.has(r)) rounds.set(r, { turnCount: 0, issues: [] });
return rounds.get(r);
}
// ---- per-step: round monotonic, advance direction, order stability ----
for (let i = 0; i < steps.length; i++) {
const s = steps[i];
const pre = s.pre || (i > 0 ? steps[i - 1].post : null);
const post = s.post;
if (!post) continue;
const isNextTurn = s.fn === 'nextTurn' || s.type === 'next_turn';
const isStart = s.fn === 'startEncounter' || s.type === 'start_encounter';
const isEnd = s.fn === 'endEncounter' || s.type === 'end_encounter' || s.fn === 'auto_end' || s.type === 'auto_end';
if (isStart) { ensureRound(post.round || 1).turnCount++; continue; }
if (isEnd) continue;
if (isNextTurn) {
ensureRound(post.round || 0).turnCount++;
if (!pre) continue;
const order = pre.turnOrderIds || [];
const fromPos = order.indexOf(pre.currentTurnParticipantId);
const isActive = id => (pre.activeIds || []).includes(id);
const exp = expectedAdvance(order, fromPos, isActive);
const actual = post.currentTurnParticipantId;
// invariant 2: correct advance target
if (exp.nextId && actual && actual !== exp.nextId) {
issues.push({ step: s.step, round: post.round, kind: 'wrong_advance',
expected: nm(exp.nextId), actual: nm(actual),
detail: `nextTurn → ${nm(actual)}, expected ${nm(exp.nextId)}` });
}
// invariant 1: round monotonic + no phantom/skip
if (pre.round !== undefined && post.round !== undefined) {
if (post.round < pre.round)
issues.push({ step: s.step, kind: 'round_backward', from: pre.round, to: post.round,
detail: `round backward ${pre.round}${post.round}` });
if (post.round > pre.round + 1)
issues.push({ step: s.step, kind: 'round_skip', from: pre.round, to: post.round,
detail: `round jumped ${pre.round}${post.round}` });
if (post.round === pre.round + 1 && !exp.wrapped)
issues.push({ step: s.step, kind: 'round_phantom', from: pre.round, to: post.round,
detail: `round incremented without pointer wrap` });
}
continue;
}
// non-rotation mutation: invariant 5 order stability
if (pre && post && !orderChangedByRosterOrReorder(s.fn)) {
const before = JSON.stringify(pre.turnOrderIds || []);
const after = JSON.stringify(post.turnOrderIds || []);
if (before !== after && pre.turnOrderIds && pre.turnOrderIds.length) {
issues.push({ step: s.step, kind: 'order_shift', fn: s.fn,
detail: `turnOrderIds changed without add/remove/reorder (${s.fn})` });
}
}
}
// ---- dedicated cycle pass: skip + double-act (invariants 3+4) ----
// Cycle = all-act-once between pointer wraps. Snapshot active-set at cycle
// start. Track removals/deactivations as legitimate disqualifications.
// Skip = in start-set, never disqualified, never acted.
issues.push(...checkCycles(steps));
return { issues, rounds };
}
// checkCycles: walk steps, maintain rotation cycle state. On wrap/end,
// finalize: skip = activeAtStart minus (acted disqualified). Double-act =
// current that became current >1 in cycle (excluding the legit starter).
function checkCycles(steps) {
const out = [];
let cycleActive = new Set(); // snapshot at cycle start (immutable for cycle)
let cycleActed = new Set();
let cycleRemoved = new Set(); // removed/disqualified mid-cycle (no skip flag)
let cycleStarter = null;
let cycleRound = null;
let started = false;
// disqualify by active-set delta, not fn name. Log types vary (deactivate,
// reactivate, remove_participant, add_participant...). Any step where an id
// leaves activeIds = disqualified. Any id entering = joins cycle.
function finalize(endStep) {
if (!started) return;
// disqualify: anyone removed/deactivated mid-cycle is gone (legit)
// skip = was active at start, never acted, never disqualified
const skipped = [...cycleActive].filter(id => !cycleActed.has(id));
if (skipped.length) {
out.push({ step: endStep, round: cycleRound, kind: 'real_skip',
actors: skipped.map(nm), detail: `active full cycle, never acted` });
}
}
for (let i = 0; i < steps.length; i++) {
const s = steps[i];
const pre = s.pre || (i > 0 ? steps[i - 1].post : null);
const post = s.post;
if (!post) continue;
const isNextTurn = s.fn === 'nextTurn' || s.type === 'next_turn';
const isStart = s.fn === 'startEncounter' || s.type === 'start_encounter';
const isEnd = s.fn === 'endEncounter' || s.type === 'end_encounter' || s.fn === 'auto_end' || s.type === 'auto_end';
if (isStart) {
finalize(s.step);
cycleRound = post.round || 1;
cycleActive = new Set(post.activeIds || []);
cycleActed = new Set(post.currentTurnParticipantId ? [post.currentTurnParticipantId] : []);
cycleStarter = post.currentTurnParticipantId;
started = true;
continue;
}
if (isEnd) { started = false; continue; } // end: abandon cycle, no skip verdict (incomplete)
// CRITICAL: pointer (currentTurnParticipantId) changes via BOTH nextTurn
// AND mutation-advance (toggleActive/remove of current auto-advances via
// computeTurnOrderAfterRemoval). Any new current = got the turn = acted.
// Only count this on nextTurn (normal) or when a mutation actually moved
// the pointer (pre.current != post.current).
if (started && pre && post.currentTurnParticipantId &&
pre.currentTurnParticipantId !== post.currentTurnParticipantId) {
const wrapped = pre.round !== undefined && post.round !== undefined && post.round !== pre.round;
if (wrapped && isNextTurn) {
finalize(s.step);
cycleRound = post.round;
cycleActive = new Set(post.activeIds || []);
cycleActed = new Set(post.currentTurnParticipantId ? [post.currentTurnParticipantId] : []);
cycleStarter = post.currentTurnParticipantId;
} else {
const c = post.currentTurnParticipantId;
if (cycleActed.has(c) && c !== cycleStarter) {
out.push({ step: s.step, round: post.round, kind: 'double_act', actor: nm(c),
detail: `${nm(c)} acted twice in round ${post.round} (via ${s.fn})` });
}
cycleActed.add(c);
}
}
if (isNextTurn) continue;
// roster mutation mid-cycle: cycleActive = cycle-start snapshot (immutable).
// invariant 4 = active FULL cycle → only removals disqualify (can't have
// been full-cycle if removed). Mid-cycle additions don't qualify for skip
// check, so never enroll them. Revivals: stay out (can act, not skip-flag).
if (pre && post && pre.activeIds && post.activeIds) {
const postSet = new Set(post.activeIds);
for (const id of [...cycleActive]) {
if (!postSet.has(id)) {
cycleRemoved.add(id);
cycleActive.delete(id);
cycleActed.delete(id);
}
}
// no mid-cycle enrollment: new ids weren't active at cycle start.
}
}
// no final finalize: incomplete cycle can't be judged for skips.
return out;
}
// roster/order-affecting fns where turnOrderIds change is EXPECTED.
// Handle both camelCase (replay trace fn) + snake_case (log type).
function orderChangedByRosterOrReorder(fn) {
return [
'addParticipant','addParticipants','removeParticipant','reorderParticipants',
'startEncounter','endEncounter','setup_encounter','setup_campaign',
'add_participant','add_participants','remove_participant','reorder',
'start_encounter','end_encounter',
].includes(fn);
}
// slot order (invariant 6) — replay trace only (needs participants[].initiative)
function checkSlotOrder(steps) {
const violations = [];
let prevOrder = null; // [{id,init}]
let prevStep = 0;
const orderAffecting = new Set(['addParticipant','addParticipants','removeParticipant',
'reorderParticipants','startEncounter','setup_encounter','setup_campaign']);
for (const s of steps) {
if (!s.post || !Array.isArray(s.post.participants)) continue;
const cur = s.post.participants.map(p => ({ id: p.id, init: p.initiative, name: p.name }));
if (prevOrder && prevOrder.length === cur.length) {
const sameIds = prevOrder.every((p, i) => p.id === cur[i].id);
if (sameIds) {
// same roster, same order — check initiative monotonic desc with stable ties
for (let i = 1; i < cur.length; i++) {
if (cur[i].initiative > cur[i - 1].initiative) {
// initiative ascended — only ok if a reorder happened
if (!orderAffecting.has(s.fn)) {
violations.push({ step: s.step, kind: 'slot_violation',
at: i, prev: nm(cur[i-1].id)+':'+cur[i-1].init,
cur: nm(cur[i].id)+':'+cur[i].init,
detail: `initiative ascended without reorder` });
}
}
}
}
}
prevOrder = cur;
prevStep = s.step;
}
return violations;
}
// ---------- reporting ----------
function reportOne(label, steps) {
learnNames(steps);
const { issues, rounds } = analyze(steps);
const slotViolations = checkSlotOrder(steps);
const all = [...issues, ...slotViolations].sort((a, b) => (a.step || 0) - (b.step || 0));
const byKind = {};
for (const it of all) byKind[it.kind] = (byKind[it.kind] || 0) + 1;
console.log(`=== ${label}${steps.length} steps, ${rounds.size} rounds ===`);
if (all.length === 0) {
console.log('CLEAN');
return 0;
}
console.log(`--- ${all.length} issues ---`);
for (const k of Object.keys(byKind)) console.log(` ${k}: ${byKind[k]}`);
for (const it of all.slice(0, 30)) {
const where = it.round != null ? `R${it.round} ` : '';
console.log(` step ${it.step} ${where}${it.kind}: ${it.detail || ''}`);
}
if (all.length > 30) console.log(` ... +${all.length - 30} more`);
return all.length;
}
const text = readInput();
const allSteps = loadSteps(text); // array of step-arrays (one per encounter)
let total = 0;
for (let i = 0; i < allSteps.length; i++) {
const label = allSteps.length > 1 ? `[encounter ${i + 1}/${allSteps.length}]` : 'trace';
if (i > 0) console.log('');
total += reportOne(label, allSteps[i]);
}
console.log(`\n=== ${allSteps.length} source(s), ${total} total issues ===`);
process.exit(total === 0 ? 0 : 1);
Executable
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# scripts/cap.sh — wrap a command with hard timeout. Hang = FAIL.
# Picks gtimeout (mac coreutils) or timeout (linux).
# Usage: cap.sh <seconds> <cmd> [args...]
set -euo pipefail
TIMEOUT_BIN=gtimeout
command -v gtimeout >/dev/null 2>&1 || TIMEOUT_BIN=timeout
if ! command -v "$TIMEOUT_BIN" >/dev/null 2>&1; then
echo "ERROR: need 'timeout' or 'gtimeout'" >&2
exit 2
fi
secs="$1"; shift
exec "$TIMEOUT_BIN" --signal=KILL "$secs" "$@"
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env node
// scripts/combat.js
// ONE tool. Replaces replay-combat.js + analyze-turns.js + replay-from-logs.js.
//
// Two modes (same binary):
//
// combat replay — drive a fresh combat through LIVE backend, write log
// combat verify — read any log file, check combat rotated correctly
//
// WHY ONE TOOL:
// Same data, same words. Old 3-tool split = format + naming nightmare.
// Replay writes the log, verify reads it. Same log shape both ends.
//
// LOG FORMAT:
// JSON array. Same as app download. One mental model. NOT jsonl.
// Each entry = canonical lean event (shared/logEvent.js shape):
// { id, ts, type, message, encounterId, encounterName, encounterPath,
// participantId, participantName, delta, undo, undone, snapshot }
// snapshot = { round, currentTurnParticipantId, turnOrderIds, activeIds }
//
// USAGE:
// combat replay [rounds] [delayMs] --out <log.json> [-v]
// combat verify <log.json>
// cat events.json | combat verify
//
// EXIT:
// replay: 0 success, 1 error, 2 bad args
// verify: 0 all checks pass, 1 bugs found, 2 bad args/no input
//
// WHAT VERIFY CHECKS (in DM words, not internals):
// - Round count go up correctly (no skip, no jump, no backward)
// - Turn pass to right person each step
// - Nobody act twice in same round
// - Nobody get skipped who was active whole round
// - Turn order stay stable (no random reshuffle)
// - Initiative slot order hold (replay logs only — need full roster)
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const shared = require('../shared');
const { normalizeEvent, serializeEvents } = shared.logEvent;
// =============================================================================
// ARGS
// =============================================================================
function parseArgs(argv) {
const mode = argv[0];
const rest = argv.slice(1);
if (mode !== 'replay' && mode !== 'verify') return { mode: null };
const out = { mode, verbose: false, out: null, positional: [] };
for (let i = 0; i < rest.length; i++) {
const a = rest[i];
if (a === '-v' || a === '--verbose') { out.verbose = true; continue; }
if (a === '--out' && rest[i + 1]) { out.out = rest[i + 1]; i++; continue; }
out.positional.push(a);
}
return out;
}
function usageReplay() {
console.error('Usage: combat replay [rounds] [delayMs] --out <log.json> [-v]');
console.error(' --out <log.json> REQUIRED. Path you control.');
console.error(' rounds default 20, delayMs default 200');
console.error(' -v / --verbose log every action per turn');
process.exit(2);
}
function usageVerify() {
console.error('Usage: combat verify <log.json>');
console.error(' cat events.json | combat verify');
process.exit(2);
}
const args = parseArgs(process.argv.slice(2));
if (!args.mode) {
console.error('combat — unified replay + verify for the TTRPG combat tracker');
console.error('');
console.error('Usage:');
console.error(' combat replay [rounds] [delayMs] --out <log.json> [-v]');
console.error(' combat verify <log.json>');
console.error(' cat events.json | combat verify');
process.exit(2);
}
// =============================================================================
// REPLAY MODE
// =============================================================================
// Drive fresh combat through live backend. Each mutation = one lean log entry
// written to --out path. DM-facing stdout: round headers + per-turn actions.
// Same log shape as app download. verify reads it back.
if (args.mode === 'replay') {
if (!args.out) usageReplay();
require('./combat/replay.js')(args).catch(err => {
console.error('replay failed:', err);
process.exit(1);
});
}
// =============================================================================
// VERIFY MODE
// =============================================================================
// Read a combat log (app download OR combat replay output). Check rotation
// correctness. DM-facing output: combat name, rounds, per-round turn list,
// checks pass/fail. No "mutation", "pointer", "invariant" in output.
if (args.mode === 'verify') {
const logPath = args.positional[0];
let text;
if (logPath) {
if (!fs.existsSync(logPath)) {
console.error(`combat verify: file not found: ${logPath}`);
process.exit(2);
}
text = fs.readFileSync(logPath, 'utf8');
} else if (!process.stdin.isTTY) {
text = fs.readFileSync(0, 'utf8');
}
if (!text || !text.trim()) usageVerify();
try {
const result = require('./combat/verify.js')(text, { verbose: args.verbose });
process.exit(result);
} catch (err) {
console.error('verify failed:', err);
process.exit(1);
}
}
+415
View File
@@ -0,0 +1,415 @@
// scripts/combat/replay.js
// Drive fresh combat through LIVE backend. Write lean log to --out path.
// Same log shape as app download (JSON array of canonical events).
//
// DM-facing stdout: round headers + per-turn actions. No internals leak.
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const shared = require('../../shared');
const { normalizeEvent } = shared.logEvent;
const {
buildCharacterParticipant, buildMonsterParticipant,
startEncounter, nextTurn, togglePause, endEncounter,
addParticipant, updateParticipant, removeParticipant,
toggleParticipantActive, applyHpChange, deathSave,
stabilizeParticipant, reviveParticipant,
toggleCondition, reorderParticipants,
} = shared;
const { createServerStorage } = require('../../src/storage/server');
const BACKEND = process.env.BACKEND_URL || 'http://127.0.0.1:4001';
const WS_URL = process.env.BACKEND_REALTIME_URL || BACKEND.replace(/^http/, 'ws') + '/ws';
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
const PUB = `artifacts/${APP_ID}/public/data`;
const getPath = {
campaigns: () => `${PUB}/campaigns`,
campaign: (id) => `${PUB}/campaigns/${id}`,
encounters: (cid) => `${PUB}/campaigns/${cid}/encounters`,
encounter: (cid, eid) => `${PUB}/campaigns/${cid}/encounters/${eid}`,
activeDisplay: () => `${PUB}/activeDisplay/status`,
logs: () => `${PUB}/logs`,
};
const storage = createServerStorage({ baseUrl: BACKEND, realtimeUrl: WS_URL });
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
function buildRoster() {
return [
{ id: 'char-fighter', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 },
{ id: 'char-cleric', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 },
{ id: 'char-rogue', name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 },
];
}
function buildMonsters() {
return [
{ name: 'Goblin1', maxHp: 30, initMod: 2 },
{ name: 'Goblin2', maxHp: 30, initMod: 2 },
{ name: 'OrcBoss', maxHp: 120, initMod: 1 },
{ name: 'Wolf', maxHp: 40, initMod: 3 },
{ name: 'Merchant', maxHp: 30, initMod: 0, asNpc: true },
];
}
const CONDITIONS = [
'alchemist_fire','bardic_inspiration','blinded','charmed','deafened',
'frightened','grappled','incapacitated','invisible','paralyzed',
'petrified','poisoned','prone','restrained','sapped','shield',
'slowed','stunned','unconscious','vexed',
];
const CUSTOM_CONDITIONS = ['hexed','rager','marked_for_death','shield_blessed'];
const ALL_CONDITIONS = [...CONDITIONS, ...CUSTOM_CONDITIONS];
let condIdx = 0;
// Lean snapshot — verify needs rotation fields only.
function snapshot(enc) {
if (!enc) return null;
return {
round: enc.round ?? 0,
currentTurnParticipantId: enc.currentTurnParticipantId ?? null,
turnOrderIds: [...(enc.turnOrderIds || [])],
activeIds: (enc.participants || []).filter(p => p.isActive).map(p => p.id),
};
}
function nameOf(enc, id) {
if (!id || !enc) return '(none)';
const p = (enc.participants || []).find(x => x.id === id);
return p ? p.name : '(missing)';
}
// resolve participantId + name from call args + encounter state
let _encCache = null;
function resolveId(a) {
if (!a) return null;
const name = a.target || a.participant || a.dragged || a.name;
if (!name || !_encCache) return null;
const p = (_encCache.participants || []).find(x => x.name === name);
return p ? p.id : null;
}
function resolveName(a) {
if (!a) return null;
return a.target || a.participant || a.dragged || a.name || null;
}
module.exports = async function replay(args) {
// In pipelines, Ctrl-C often kills downstream (e.g. timestamper) first.
// Then any later console.log hits EPIPE and process dies before cleanup.
// Ignore broken output pipe so SIGINT cleanup can still end combat + verify.
for (const s of [process.stdout, process.stderr]) {
s.on('error', (err) => {
if (err && err.code === 'EPIPE') return;
throw err;
});
}
const ROUNDS = parseInt(args.positional[0], 10);
const DELAY = parseInt(args.positional[1], 10);
const rounds = Number.isNaN(ROUNDS) ? 20 : ROUNDS;
const delay = Number.isNaN(DELAY) ? 200 : DELAY;
// Safety only. Roster grows via reinforcements, so fixed rounds*30 was too low
// and killed long replays around 6000 events. Keep generous hard stop.
const MAX_STEPS = Math.max(rounds * 250, 10000);
const VERBOSE = args.verbose;
const runStamp = new Date().toISOString().slice(0, 19).replace('T', '_').replace(/:/g, '-');
const campaignId = crypto.randomUUID();
const encounterId = crypto.randomUUID();
const encounterPath = getPath.encounter(campaignId, encounterId);
const activeDisplayPath = getPath.activeDisplay();
const ctx = { storage, encPath: encounterPath, logPath: getPath.logs(), displayPath: activeDisplayPath };
// ensure parent dir exists
const traceDir = path.dirname(args.out);
if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
const events = []; // collected lean events, flushed to --out at end
let stepN = 0;
let prevEnc = null;
let interrupted = false;
let finishing = false;
const onSigint = () => {
interrupted = true;
console.error('\ninterrupted — ending combat and verifying partial log...');
finishAndExit().catch(err => {
console.error('interrupt cleanup failed:', err);
process.exit(1);
});
};
process.once('SIGINT', onSigint);
// callStep: trust func return (post), no per-step getDoc. Build lean event
// shape = same as app download. Pre/post snapshot = ground truth.
async function callStep(fn, argsObj, runner) {
stepN++;
const encBefore = prevEnc !== null ? prevEnc : await storage.getDoc(encounterPath);
const preSnap = snapshot(encBefore);
let result, threw = null;
try { result = await runner(encBefore); }
catch (e) { threw = e.message; }
const encAfter = threw ? encBefore : (result !== undefined ? result : encBefore);
prevEnc = encAfter;
_encCache = encAfter;
const postSnap = snapshot(encAfter);
events.push({
id: crypto.randomUUID(),
ts: Date.now(),
type: fn,
message: describe(fn, argsObj, encAfter),
encounterId,
encounterName: `Replay ${runStamp}`,
encounterPath,
participantId: resolveId(argsObj),
participantName: resolveName(argsObj),
delta: null,
undo: null,
undone: false,
snapshot: postSnap,
_pre: preSnap,
_call: { fn, args: argsObj },
_error: threw,
});
return { enc: encAfter, result, threw };
}
async function finishAndExit() {
if (finishing) return;
finishing = true;
process.removeListener('SIGINT', onSigint);
try {
// end encounter, even on Ctrl-C, so live display/app is not left mid-combat
const latest = await storage.getDoc(encounterPath);
prevEnc = latest;
if (latest && latest.isStarted) {
await callStep('endEncounter', {}, (e) => endEncounter(e, ctx));
}
await storage.updateDoc(activeDisplayPath, { activeCampaignId: null, activeEncounterId: null });
} catch (err) {
console.error('cleanup warning:', err.message || err);
}
const log = interrupted ? console.error : console.log;
const clean = events.map(({ _pre, _call, _error, ...rest }) => rest);
fs.writeFileSync(args.out, JSON.stringify(clean, null, 2));
log(`log written: ${events.length} events -> ${args.out}`);
log('');
log('--- verifying ---');
const text = fs.readFileSync(args.out, 'utf8');
const verify = require('./verify.js');
const exit = verify(text, { verbose: VERBOSE, log });
process.exit(exit);
}
// human-readable message per action
function describe(fn, a, enc) {
switch (fn) {
case 'setup_campaign': return 'Campaign created';
case 'setup_encounter': return 'Encounter created';
case 'addParticipant': return `${a.name} joined`;
case 'startEncounter': return `Combat started — round 1, ${nameOf(enc, enc.currentTurnParticipantId)} first`;
case 'nextTurn': return `Turn passed to ${nameOf(enc, enc.currentTurnParticipantId)}`;
case 'applyHpChange':
return a.changeType === 'heal'
? `${a.target} healed +${a.amount}`
: `${a.target} took ${a.amount} damage`;
case 'toggleCondition': return `${a.participant} ${a.condition} toggled`;
case 'updateParticipant': return `${a.participant} edited`;
case 'deathSave': return `${a.participant} death save (${a.outcome})`;
case 'toggleParticipantActive':
return a.revive ? `${a.participant} reactivated` : `${a.participant} toggled`;
case 'togglePause': return a.to === 'paused' ? 'Combat paused' : 'Combat resumed';
case 'removeParticipant': return `${a.participant} removed`;
case 'reorderParticipants': return `${a.dragged} moved before ${a.target}`;
case 'endEncounter': return 'Combat ended';
default: return fn;
}
}
console.log(`replay: ${rounds} rounds, ${delay}ms/step, backend=${BACKEND}${VERBOSE ? ' [verbose]' : ''}`);
// --- setup ---
await callStep('setup_campaign', { campaignId }, async () =>
storage.setDoc(getPath.campaign(campaignId), {
id: campaignId, name: `Replay Campaign ${runStamp}`, createdAt: Date.now(),
players: buildRoster(),
})
);
await callStep('setup_encounter', { encounterId }, async () =>
storage.setDoc(encounterPath, {
id: encounterId, name: `Replay ${runStamp}`, campaignId,
participants: [], isStarted: false, isPaused: false,
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
createdAt: Date.now(),
})
);
for (const ch of buildRoster()) {
const { participant } = buildCharacterParticipant(ch);
await callStep('addParticipant', { name: participant.name }, (enc) =>
addParticipant(enc, participant, ctx));
}
for (const m of buildMonsters()) {
const { participant } = buildMonsterParticipant(m);
await callStep('addParticipant', { name: participant.name }, (enc) =>
addParticipant(enc, participant, ctx));
}
// --- start combat ---
let enc = await storage.getDoc(encounterPath);
const startRes = await callStep('startEncounter', {}, (e) => startEncounter(e, ctx));
enc = startRes.enc;
await storage.updateDoc(activeDisplayPath, { activeCampaignId: campaignId, activeEncounterId: encounterId });
console.log(`--- round ${enc.round} ---`);
let totalTurns = 0;
let lastRound = enc.round;
// --- main loop: drive by real enc.round ---
while (!interrupted && enc.isStarted && enc.round <= rounds && stepN < MAX_STEPS) {
const actor = (enc.participants || []).find(p => p.id === enc.currentTurnParticipantId);
totalTurns++;
if (VERBOSE && actor) console.log(` [r${enc.round}] ${actor.name}'s turn`);
if (actor) {
const living = enc.participants.filter(p => p.currentHp > 0 && p.id !== actor.id);
if (living.length > 0) {
const tgt = pick(living);
const dmg = 1 + Math.floor(Math.random() * 5);
if (VERBOSE) console.log(` damage ${tgt.name} -${dmg}`);
enc = (await callStep('applyHpChange',
{ target: tgt.name, changeType: 'damage', amount: dmg },
(e) => applyHpChange(e, tgt.id, 'damage', dmg, ctx))).enc;
}
if (totalTurns % 5 === 0) {
const cond = ALL_CONDITIONS[condIdx++ % ALL_CONDITIONS.length];
if (VERBOSE) console.log(` condition ${actor.name} +${cond}`);
enc = (await callStep('toggleCondition',
{ participant: actor.name, condition: cond },
(e) => toggleCondition(e, actor.id, cond, ctx))).enc;
}
if (totalTurns % 11 === 0) {
if (VERBOSE) console.log(` update ${actor.name} notes`);
enc = (await callStep('updateParticipant',
{ participant: actor.name, fields: ['notes'] },
(e) => updateParticipant(e, actor.id, { notes: `edited r${enc.round}` }, ctx))).enc;
}
if (actor.status === 'dying' && (actor.type === 'character' || actor.type === 'npc')) {
const outcomes = ['success', 'fail', 'nat1', 'nat20'];
const outcome = outcomes[totalTurns % outcomes.length];
if (VERBOSE) console.log(` deathSave ${actor.name} ${outcome}`);
const dsRes = await callStep('deathSave',
{ participant: actor.name, outcome },
async (e) => (await deathSave(e, actor.id, outcome, ctx)).enc);
enc = dsRes.enc;
}
if (totalTurns % 9 === 0) {
const living = enc.participants.filter(p => p.currentHp > 0);
if (living.length > 0) {
const tgt = pick(living);
if (VERBOSE) console.log(` toggleActive ${tgt.name}`);
enc = (await callStep('toggleParticipantActive',
{ participant: tgt.name },
(e) => toggleParticipantActive(e, tgt.id, ctx))).enc;
}
}
if (totalTurns % 20 === 0 && enc.isPaused === false) {
if (VERBOSE) console.log(` reinforcements: pause`);
enc = (await callStep('togglePause', { to: 'paused' }, (e) => togglePause(e, ctx))).enc;
const r = buildMonsterParticipant({ name: `Reinforce${totalTurns}`, maxHp: 20, initMod: 1 });
if (VERBOSE) console.log(` add ${r.participant.name}`);
enc = (await callStep('addParticipant',
{ name: r.participant.name, reinforcement: true },
(e) => addParticipant(e, r.participant, ctx))).enc;
if (VERBOSE) console.log(` resume`);
enc = (await callStep('togglePause', { to: 'resumed' }, (e) => togglePause(e, ctx))).enc;
}
if (totalTurns % 13 === 0) {
const dead = enc.participants.find(p => p.currentHp <= 0 && p.type === 'monster');
if (dead) {
if (VERBOSE) console.log(` remove ${dead.name}`);
enc = (await callStep('removeParticipant',
{ participant: dead.name, dead: true },
(e) => removeParticipant(e, dead.id, ctx))).enc;
}
}
if (totalTurns % 17 === 0) {
const order = enc.participants || [];
if (order.length >= 2) {
const a = order[0], b = order.find(p => p.initiative === a.initiative && p.id !== a.id);
if (b) {
if (VERBOSE) console.log(` reorder ${b.name} -> ${a.name}`);
enc = (await callStep('reorderParticipants',
{ dragged: b.name, target: a.name },
(e) => reorderParticipants(e, b.id, a.id, ctx))).enc;
}
}
}
}
if (interrupted) break;
if (delay > 0) await sleep(delay);
if (interrupted) break;
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
if (VERBOSE) console.log(` -> nextTurn`);
const advRes = await callStep('nextTurn', {}, (e) => nextTurn(e, ctx));
enc = advRes.enc;
if (advRes.threw) { console.log(` nextTurn err: ${advRes.threw}`); break; }
// round wrap
if (enc.isStarted && enc.round > lastRound) {
if (enc.round > rounds) {
// cap hit: the nextTurn that wrapped us here is a phantom — remove it
// so verify doesn't show an incomplete round.
events.pop();
stepN--;
break;
}
console.log(`--- round ${enc.round} ---`);
lastRound = enc.round;
const down = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
for (const d of down) {
if (interrupted) break;
if (d.status === 'dead') {
if (VERBOSE) console.log(` revive dead ${d.name}`);
enc = (await callStep('reviveParticipant',
{ participant: d.name, revive: true },
(e) => reviveParticipant(e, d.id, ctx))).enc;
}
if (d.status === 'stable') {
if (VERBOSE) console.log(` heal stable ${d.name} +${d.maxHp}`);
enc = (await callStep('applyHpChange',
{ target: d.name, changeType: 'heal', amount: d.maxHp, revive: true },
(e) => applyHpChange(e, d.id, 'heal', d.maxHp, ctx))).enc;
}
const latest = (enc.participants || []).find(p => p.id === d.id);
if (latest && latest.isActive === false) {
if (VERBOSE) console.log(` reactivate ${latest.name}`);
enc = (await callStep('toggleParticipantActive',
{ participant: latest.name, revive: true },
(e) => toggleParticipantActive(e, latest.id, ctx))).enc;
}
}
}
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
}
console.log(`replay: ${totalTurns} turns, reached round ${enc.round} (cap ${rounds})`);
await finishAndExit();
};
+353
View File
@@ -0,0 +1,353 @@
// scripts/combat/verify.js
// Read combat log (JSON array of lean events). Check rotation correctness.
// DM-facing output: combat name, rounds, per-round turn list, checks pass/fail.
//
// No internal jargon in output. "Someone acted twice", "someone skipped",
// "turn passed wrong way" — not "double_act", "real_skip", "wrong_advance".
//
// CHECKS (rules combat must obey):
// 1. Round count go up correctly (no skip/jump/backward)
// 2. Turn pass to right person each step
// 3. Nobody act twice in same round
// 4. Nobody get skipped who was active whole round
// 5. Turn order stay stable (no random reshuffle)
// 6. Initiative slot order hold (replay logs only — needs full roster)
//
// Return: 0 clean, 1 bugs found.
'use strict';
const { normalizeEvent } = require('../../shared/logEvent');
// snake_case log type → camelCase fn (checks match both shapes)
function normalizeFn(fn) {
if (!fn) return fn;
if (!fn.includes('_')) return fn;
return fn.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
}
// Parse text → array of step-arrays (one per combat run).
// Input: JSON array of lean events (app download OR combat replay output).
// Split: by encounterId, then sub-split on start_encounter (restart boundary).
// First start after setup stays with setup (not bogus "encounter 1").
function loadSteps(text) {
const trimmed = text.trim();
if (!trimmed) return [];
const raw = JSON.parse(trimmed);
const arr = Array.isArray(raw) ? raw : [raw];
const groups = new Map();
for (const e of arr) {
const key = e.encounterId || '_none_';
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(e);
}
const toSteps = (evs) => evs.map((e, i) => ({
step: i + 1, ts: e.ts || 0, type: e.type,
fn: normalizeFn(e.type),
args: null,
pre: null,
post: e.snapshot ? {
round: e.snapshot.round,
currentTurnParticipantId: e.snapshot.currentTurnParticipantId,
isStarted: true, isPaused: false,
turnOrderIds: e.snapshot.turnOrderIds || [],
activeIds: e.snapshot.activeIds || [],
participants: null,
} : null,
message: e.message || '',
error: null,
}));
const out = [];
for (const evs of groups.values()) {
let cur = [];
let started = false;
const flush = () => { if (cur.length) { out.push(toSteps(cur)); cur = []; } started = false; };
for (const e of evs) {
if (e.type === 'start_encounter' && started) flush();
if (e.type === 'start_encounter') started = true;
cur.push(e);
}
flush();
}
return out;
}
// ---------- name map ----------
const nameMap = new Map();
function learnNames(steps) {
for (const s of steps) {
// snapshot has no roster; use participantName field on events + messages
if (s.message) {
// no-op; names come from post.currentTurnParticipantId via message ctx
}
}
}
// fallback: ids unknown → short. (replay logs carry encounterName; app logs
// carry participantName. We use message text + the turnOrderIds as-is.)
function nm(id) { return id ? (nameMap.get(id) || id.slice(0, 8)) : '(none)'; }
// rebuild name map from raw events (participantName field)
function learnNamesFromEvents(evs) {
for (const e of evs) {
if (e.participantId && e.participantName) nameMap.set(e.participantId, e.participantName);
}
}
// ---------- expected advance ----------
function expectedAdvance(order, fromPos, isActive) {
const n = order.length;
if (n === 0) return { nextId: null, wrapped: false };
for (let step = 1; step < n; step++) {
const idx = (fromPos + step) % n;
const id = order[idx];
if (isActive(id)) return { nextId: id, wrapped: idx <= fromPos };
}
return { nextId: null, wrapped: false };
}
// ---------- checks ----------
function analyze(steps) {
const issues = [];
const rounds = new Map();
function ensureRound(r) {
if (!rounds.has(r)) rounds.set(r, { turnCount: 0, actors: [] });
return rounds.get(r);
}
for (let i = 0; i < steps.length; i++) {
const s = steps[i];
const pre = s.pre || (i > 0 ? steps[i - 1].post : null);
const post = s.post;
if (!post) continue;
const isNextTurn = s.fn === 'nextTurn' || s.type === 'next_turn';
const isStart = s.fn === 'startEncounter' || s.type === 'start_encounter';
const isEnd = s.fn === 'endEncounter' || s.type === 'end_encounter';
if (isStart) {
ensureRound(post.round || 1).actors.push(post.currentTurnParticipantId);
continue;
}
if (isEnd) continue;
if (isNextTurn) {
const r = ensureRound(post.round || 0);
r.turnCount++;
r.actors.push(post.currentTurnParticipantId);
if (!pre) continue;
const order = pre.turnOrderIds || [];
const fromPos = order.indexOf(pre.currentTurnParticipantId);
const isActive = id => (pre.activeIds || []).includes(id);
const exp = expectedAdvance(order, fromPos, isActive);
const actual = post.currentTurnParticipantId;
if (exp.nextId && actual && actual !== exp.nextId) {
issues.push({ step: s.step, round: post.round, kind: 'wrong_turn',
detail: `Turn passed to ${nm(actual)}, should have been ${nm(exp.nextId)}` });
}
if (pre.round !== undefined && post.round !== undefined) {
if (post.round < pre.round)
issues.push({ step: s.step, kind: 'round_backward',
detail: `Round went ${pre.round}${post.round}` });
if (post.round > pre.round + 1)
issues.push({ step: s.step, kind: 'round_jump',
detail: `Round jumped ${pre.round}${post.round}` });
if (post.round === pre.round + 1 && !exp.wrapped)
issues.push({ step: s.step, kind: 'round_phantom',
detail: `Round went ${pre.round}${post.round} but turn didn't wrap` });
}
continue;
}
if (pre && post && !orderChangedByRosterOrReorder(s.fn)) {
const before = JSON.stringify(pre.turnOrderIds || []);
const after = JSON.stringify(post.turnOrderIds || []);
if (before !== after && pre.turnOrderIds && pre.turnOrderIds.length) {
issues.push({ step: s.step, kind: 'order_shift',
detail: `Turn order changed during ${s.fn}` });
}
}
}
issues.push(...checkCycles(steps, rounds));
return { issues, rounds };
}
function checkCycles(steps, rounds) {
const out = [];
let cycleActive = new Set();
let cycleActed = new Set();
let cycleStarter = null;
let cycleRound = null;
let started = false;
function finalize(endStep) {
if (!started) return;
const skipped = [...cycleActive].filter(id => !cycleActed.has(id));
if (skipped.length) {
out.push({ step: endStep, round: cycleRound, kind: 'skipped',
detail: `Never acted in round ${cycleRound}: ${skipped.map(nm).join(', ')}` });
}
}
for (let i = 0; i < steps.length; i++) {
const s = steps[i];
const pre = s.pre || (i > 0 ? steps[i - 1].post : null);
const post = s.post;
if (!post) continue;
const isNextTurn = s.fn === 'nextTurn' || s.type === 'next_turn';
const isStart = s.fn === 'startEncounter' || s.type === 'start_encounter';
const isEnd = s.fn === 'endEncounter' || s.type === 'end_encounter';
if (isStart) {
finalize(s.step);
cycleRound = post.round || 1;
cycleActive = new Set(post.activeIds || []);
cycleActed = new Set(post.currentTurnParticipantId ? [post.currentTurnParticipantId] : []);
cycleStarter = post.currentTurnParticipantId;
started = true;
continue;
}
if (isEnd) { started = false; continue; }
if (started && pre && post.currentTurnParticipantId &&
pre.currentTurnParticipantId !== post.currentTurnParticipantId) {
const wrapped = pre.round !== undefined && post.round !== undefined && post.round !== pre.round;
if (wrapped) {
// drain removed/inactive before finalizing so removed actors
// aren't flagged as skipped.
if (pre && pre.activeIds && post.activeIds) {
const postSet = new Set(post.activeIds);
for (const id of [...cycleActive]) {
if (!postSet.has(id)) {
cycleActive.delete(id);
cycleActed.delete(id);
}
}
}
finalize(s.step);
cycleRound = post.round;
cycleActive = new Set(post.activeIds || []);
cycleActed = new Set(post.currentTurnParticipantId ? [post.currentTurnParticipantId] : []);
cycleStarter = post.currentTurnParticipantId;
} else {
const c = post.currentTurnParticipantId;
if (cycleActed.has(c) && c !== cycleStarter) {
out.push({ step: s.step, round: post.round, kind: 'acted_twice',
detail: `${nm(c)} acted twice in round ${post.round}` });
}
cycleActed.add(c);
}
}
if (isNextTurn) continue;
if (pre && post && pre.activeIds && post.activeIds) {
const postSet = new Set(post.activeIds);
for (const id of [...cycleActive]) {
if (!postSet.has(id)) {
cycleActive.delete(id);
cycleActed.delete(id);
}
}
}
}
return out;
}
function orderChangedByRosterOrReorder(fn) {
return [
'addParticipant','addParticipants','removeParticipant','reorderParticipants',
'startEncounter','endEncounter','setup_encounter','setup_campaign',
'add_participant','add_participants','remove_participant','reorder',
'start_encounter','end_encounter',
].includes(fn);
}
// ---------- reporting (DM-facing) ----------
const KIND_LABEL = {
wrong_turn: 'Turn passed to wrong person',
round_backward: 'Round count went backward',
round_jump: 'Round count jumped',
round_phantom: 'Round count changed without turn wrapping',
order_shift: 'Turn order changed unexpectedly',
skipped: 'Someone got skipped',
acted_twice: 'Someone acted twice',
slot_violation: 'Initiative order violated',
};
function reportOne(label, steps, rawEventsForNames, opts = {}) {
const log = opts.log || console.log;
if (rawEventsForNames) learnNamesFromEvents(rawEventsForNames);
const { issues, rounds } = analyze(steps);
// combat name
const name = (rawEventsForNames && rawEventsForNames[0] && rawEventsForNames[0].encounterName) || 'Combat';
log(`=== ${label}: ${name} ===`);
// Per-round turn list only in verbose mode. Long replays can print thousands
// of names and swamp useful result output.
const sortedRounds = [...rounds.keys()].sort((a, b) => a - b);
if (opts.verbose) {
for (const r of sortedRounds) {
const info = rounds.get(r);
log(` Round ${r}: ${info.actors.map(nm).join(' → ')}`);
}
}
log(` (${steps.length} events, ${rounds.size} rounds${opts.verbose ? '' : ', use -v for per-round turns'})`);
if (issues.length === 0) {
log(' ✓ PASS — combat rotated correctly');
return 0;
}
log(` ✗ FAIL — ${issues.length} problem(s):`);
const byKind = {};
for (const it of issues) byKind[it.kind] = (byKind[it.kind] || 0) + 1;
for (const [k, n] of Object.entries(byKind)) {
log(` ${KIND_LABEL[k] || k}: ${n}`);
}
for (const it of issues.slice(0, 15)) {
const where = it.round != null ? `R${it.round} ` : '';
log(` [${where}event ${it.step}] ${it.detail}`);
}
if (issues.length > 15) log(` ... +${issues.length - 15} more`);
return issues.length;
}
module.exports = function verify(text, opts = {}) {
const log = opts.log || console.log;
const allSteps = loadSteps(text);
if (!allSteps.length) {
log('No combat events found.');
return 0;
}
// raw events grouped same way for name learning
const raw = JSON.parse(text.trim());
const arr = Array.isArray(raw) ? raw : [raw];
const rawGroups = new Map();
for (const e of arr) {
const key = e.encounterId || '_none_';
if (!rawGroups.has(key)) rawGroups.set(key, []);
rawGroups.get(key).push(e);
}
const rawList = [...rawGroups.values()];
let total = 0;
for (let i = 0; i < allSteps.length; i++) {
const label = allSteps.length > 1 ? `[combat ${i + 1}/${allSteps.length}]` : 'Combat';
if (i > 0) log('');
total += reportOne(label, allSteps[i], rawList[i], opts);
}
log('');
log(total === 0 ? 'CLEAN' : `${total} problem(s) found`);
return total === 0 ? 0 : 1;
};
+5 -5
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Start local dev stack: node backend (sqlite) + react frontend, ws storage mode. # Start local dev stack: node backend (sqlite) + react frontend, server storage mode.
# Usage: ./scripts/dev-start.sh # Usage: ./scripts/dev-start.sh
# Stop: ./scripts/dev-stop.sh # Stop: ./scripts/dev-stop.sh
set -euo pipefail set -euo pipefail
@@ -18,19 +18,19 @@ done
# backend: better-sqlite3, :4001 # backend: better-sqlite3, :4001
if ! lsof -ti :4001 >/dev/null 2>&1; then if ! lsof -ti :4001 >/dev/null 2>&1; then
echo "starting backend :4001..." echo "starting backend :4001..."
DB_PATH=$(pwd)/data/tracker.sqlite PORT=4001 \ DB_PATH=$(pwd)/data/tracker.sqlite PORT=4001 ALLOW_DEV_ENDPOINTS=1 \
nohup npm run server:dev > tmp/server.log 2>&1 & nohup npm run server:dev > tmp/server.log 2>&1 &
echo $! > tmp/server.pid echo $! > tmp/server.pid
else else
echo "backend already on :4001" echo "backend already on :4001"
fi fi
# frontend: ws storage, :3999 # frontend: server storage, :3999
if ! lsof -ti :3999 >/dev/null 2>&1; then if ! lsof -ti :3999 >/dev/null 2>&1; then
echo "starting frontend :3999..." echo "starting frontend :3999..."
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 \
BROWSER=none PORT=3999 \ BROWSER=none PORT=3999 \
nohup npm start > tmp/fe.log 2>&1 & nohup npm start > tmp/fe.log 2>&1 &
echo $! > tmp/fe.pid echo $! > tmp/fe.pid
+294 -298
View File
@@ -1,376 +1,372 @@
// scripts/replay-combat.js // scripts/replay-combat.js
// Drive a full combat through the LIVE backend via the ws storage adapter // Drive a full combat through the LIVE backend via the server storage adapter
// (same contract boundary as the App), so the player display window // (same contract boundary as the App), so the player display window
// (subscribed via WS) live-updates as combat progresses. // (subscribed via WS) live-updates as combat progresses.
// Uses shared/turn.js for all turn logic (same model as the UI). // Uses shared/turn.js for all turn logic (same model as the UI).
// //
// Coverage goals (rotate across rounds): // BLIND CALLER: this tool does NOT know the correct turn order. It hammers
// - nextTurn (every turn) // nextTurn + mutations and trusts turn.js. An SEPARATE analyze pass inspects
// - applyHpChange damage + heal (varying magnitude) // what actually happened (invariants on backend read-back). Do not encode
// - toggleCondition (all CONDITIONS at least once) // expected-order logic here — that's circular.
// - toggleParticipantActive (mark inactive, later reactivate)
// - deathSave (when a PC reaches 0 HP)
// - addParticipant (reinforcements drop in)
// - removeParticipant (dead monsters hauled off)
// - updateParticipant (edit fields mid-combat)
// - togglePause / resume
// - reorderParticipants (initiative reorder)
// - endEncounter (cleanup)
// //
// Run: node scripts/replay-combat.js [rounds] [delayMs] // DRIVEN BY REAL enc.round (backend state), not a fake loop counter. Round
// rounds default 100, delayMs default 200 // wraps when nextTurn's pointer advances last→first active.
//
'use strict'; // TRACE: writes JSONL to tmp/replay-{stamp}.jsonl — one record per call:
// { step, ts, type, call:{fn,args}, pre, post }
// pre/post = backend getDoc snapshots (independent of func return value).
// This is ground truth. analyze-turns.js consumes it. The func return value
// is "what turn.js claims"; read-back is "what the backend stored". Mismatch
// = write bug.
//
// Coverage (rotates by step counter):
// applyHpChange damage, toggleCondition, updateParticipant, deathSave,
// toggleParticipantActive, pause/add/resume reinforcements,
// removeParticipant dead, reorderParticipants same-init, nextTurn.
//
// Run: node scripts/replay-combat.js [rounds] [delayMs] --out <path> [-v|--verbose]
// rounds default 20, delayMs default 200
// --out <path> = REQUIRED. Trace path you control.
// -v / --verbose = log every action per turn
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const shared = require('../shared'); const shared = require('../shared');
const { const {
buildCharacterParticipant, buildMonsterParticipant, buildCharacterParticipant, buildMonsterParticipant,
startEncounter, nextTurn, togglePause, startEncounter, nextTurn, togglePause, endEncounter,
addParticipant, updateParticipant, removeParticipant, addParticipant, updateParticipant, removeParticipant,
toggleParticipantActive, applyHpChange, deathSave, toggleParticipantActive, applyHpChange, deathSave,
toggleCondition, reorderParticipants, endEncounter, toggleCondition, reorderParticipants,
} = shared; } = shared;
const { createWsStorage } = require('../src/storage/ws'); const { createServerStorage } = require('../src/storage/server');
const BACKEND = process.env.BACKEND_URL || 'http://127.0.0.1:4001'; const BACKEND = process.env.BACKEND_URL || 'http://127.0.0.1:4001';
const WS_URL = process.env.BACKEND_WS || BACKEND.replace(/^http/, 'ws') + '/ws'; const WS_URL = process.env.BACKEND_REALTIME_URL || BACKEND.replace(/^http/, 'ws') + '/ws';
const ROUNDS = parseInt(process.argv[2], 10) || 100; // args: [rounds] [delayMs] --out <path> [-v|--verbose]
const DELAY = parseInt(process.argv[3], 10) || 200; // --out <path> REQUIRED — trace path. You control it, not the tool.
const args = process.argv.slice(2);
const VERBOSE = args.some(a => a === '-v' || a === '--verbose');
let OUT_PATH = null;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--out' && args[i + 1]) {
OUT_PATH = args[i + 1];
args.splice(i, 2);
break;
}
}
if (!OUT_PATH) {
console.error('Usage: node scripts/replay-combat.js [rounds] [delayMs] --out <path> [-v]');
console.error(' --out <path> REQUIRED.');
process.exit(2);
}
const positional = args.filter(a => !a.startsWith('-'));
const ROUNDS = parseInt(positional[0], 10) || 20;
const DELAY = parseInt(positional[1], 10) || 200;
const MAX_STEPS = ROUNDS * 30; // safety cap on total calls
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default'; const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
const PUB = `artifacts/${APP_ID}/public/data`; const PUB = `artifacts/${APP_ID}/public/data`;
// Mirror App.js getPath. Adapter takes these; norm() strips prefix.
const getPath = { const getPath = {
campaigns: () => `${PUB}/campaigns`, campaigns: () => `${PUB}/campaigns`,
campaign: (id) => `${PUB}/campaigns/${id}`, campaign: (id) => `${PUB}/campaigns/${id}`,
encounters: (cid) => `${PUB}/campaigns/${cid}/encounters`, encounters: (cid) => `${PUB}/campaigns/${cid}/encounters`,
encounter: (cid, eid) => `${PUB}/campaigns/${cid}/encounters/${eid}`, encounter: (cid, eid) => `${PUB}/campaigns/${cid}/encounters/${eid}`,
activeDisplay: () => `${PUB}/activeDisplay/status`, activeDisplay: () => `${PUB}/activeDisplay/status`,
logs: () => `${PUB}/logs`,
}; };
const storage = createServerStorage({ baseUrl: BACKEND, realtimeUrl: WS_URL });
const sleep = (ms) => new Promise(r => setTimeout(r, ms)); const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
// Use the ADAPTER as the contract boundary (same as App). No raw REST. function buildRoster() {
const storage = createWsStorage({ baseUrl: BACKEND, wsUrl: WS_URL }); return [
{ name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 },
// Mirror App.js CONDITIONS so we exercise all of them. { name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 },
const CONDITIONS = [ { name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 },
'alchemist_fire', 'bardic_inspiration', 'blinded', 'charmed', 'deafened', ];
'exhaustion', 'frightened', 'grappled', 'grazed', 'incapacitated', }
'invisible', 'paralyzed', 'petrified', 'poisoned', 'prone', 'restrained', function buildMonsters() {
'sapped', 'shield', 'slowed', 'stunned', 'unconscious', 'vexed', return [
]; { name: 'Goblin1', maxHp: 30, initMod: 2 },
{ name: 'Goblin2', maxHp: 30, initMod: 2 },
async function patch(encounterPath, enc, result, label) { { name: 'OrcBoss', maxHp: 120, initMod: 1 },
if (!result || !result.patch) { if (label) console.log(` (${label}: no-op)`); return enc; } { name: 'Wolf', maxHp: 40, initMod: 3 },
await storage.updateDoc(encounterPath, result.patch); { name: 'Merchant', maxHp: 30, initMod: 0, isNpc: true },
if (label) console.log(` [${label}]`); ];
// emit pointer-advance line when a MUTATION changes currentTurnParticipantId.
// nextTurn passes label=null — it's a normal advance, already logged via
// the turn line. Emitting pointer for it double-counts.
const oldCur = enc.currentTurnParticipantId;
const oldRound = enc.round;
const newEnc = { ...enc, ...result.patch };
const newCur = newEnc.currentTurnParticipantId;
const newRound = newEnc.round;
if (label && oldCur && newCur && oldCur !== newCur) {
const oldName = enc.participants.find(p => p.id === oldCur)?.name || oldCur;
const newName = newEnc.participants.find(p => p.id === newCur)?.name || newCur;
const wrap = oldRound !== newRound ? ' wrap' : '';
console.log(` [pointer ${oldName}${newName}${wrap}]`);
}
return newEnc;
} }
function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; } const CONDITIONS = [
'alchemist_fire','bardic_inspiration','blinded','charmed','deafened',
'frightened','grappled','incapacitated','invisible','paralyzed',
'petrified','poisoned','prone','restrained','sapped','shield',
'slowed','stunned','unconscious','vexed',
];
const CUSTOM_CONDITIONS = ['hexed','rager','marked_for_death','shield_blessed'];
const ALL_CONDITIONS = [...CONDITIONS, ...CUSTOM_CONDITIONS];
let condIdx = 0;
// ---------- JSONL trace ----------
// Lean snapshot — analyzer needs rotation fields only, not roster.
// participants dropped (was 80% of trace bloat). turnOrderIds + activeIds
// carry id-level info; hp/conditions stay on encounter doc, not trace.
function snapshot(enc) {
if (!enc) return null;
return {
round: enc.round ?? 0,
currentTurnParticipantId: enc.currentTurnParticipantId ?? null,
isStarted: !!enc.isStarted,
isPaused: !!enc.isPaused,
turnOrderIds: [...(enc.turnOrderIds || [])],
activeIds: (enc.participants || []).filter(p => p.isActive).map(p => p.id),
};
}
function nameOf(enc, id) {
if (!id || !enc) return '(none)';
const p = (enc.participants || []).find(x => x.id === id);
return p ? p.name : '(missing)';
}
async function main() { async function main() {
console.log(`replay-combat: ${ROUNDS} rounds, ${DELAY}ms/step, backend=${BACKEND}`); const runStamp = new Date().toISOString().slice(0,19).replace('T', '_').replace(/:/g, '-');
const campaignId = crypto.randomUUID(); const campaignId = crypto.randomUUID();
const encounterId = crypto.randomUUID(); const encounterId = crypto.randomUUID();
await storage.setDoc(getPath.campaign(campaignId), {
name: `Replay Campaign (${new Date().toLocaleString('en-US', { hour12: false })})`,
playerDisplayBackgroundUrl: '',
ownerId: 'replay',
createdAt: new Date().toISOString(),
players: [
{ id: 'c1', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 },
{ id: 'c2', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 },
{ id: 'c3', name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 },
],
});
const charSpecs = [
{ id: 'c1', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 },
{ id: 'c2', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 },
{ id: 'c3', name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 },
];
const monsterSpecs = [
{ name: 'Goblin1', maxHp: 100, initMod: 2 },
{ name: 'Goblin2', maxHp: 100, initMod: 2 },
{ name: 'OrcBoss', maxHp: 500, initMod: 1 },
{ name: 'Wolf', maxHp: 120, initMod: 3 },
{ name: 'Merchant', maxHp: 150, initMod: 0, isNpc: true },
];
const participants = [
...charSpecs.map(c => buildCharacterParticipant(c).participant),
...monsterSpecs.map(m => buildMonsterParticipant(m).participant),
];
await storage.setDoc(getPath.encounter(campaignId, encounterId), {
name: `Big Boss Replay (${new Date().toLocaleString('en-US', { hour12: false })})`,
campaignId,
createdAt: new Date().toISOString(),
participants,
round: 0,
currentTurnParticipantId: null,
isStarted: false,
isPaused: false,
turnOrderIds: [],
});
console.log(`created: campaign=${campaignId} encounter=${encounterId} participants=${participants.length}`);
await storage.setDoc(getPath.activeDisplay(), {
activeCampaignId: campaignId,
activeEncounterId: encounterId,
hidePlayerHp: false,
});
await sleep(800);
const encounterPath = getPath.encounter(campaignId, encounterId); const encounterPath = getPath.encounter(campaignId, encounterId);
const activeDisplayPath = getPath.activeDisplay(); const activeDisplayPath = getPath.activeDisplay();
const ctx = { storage, encPath: encounterPath, logPath: getPath.logs(), displayPath: activeDisplayPath };
// start let tracePath = OUT_PATH;
let enc = await storage.getDoc(encounterPath); // ensure parent dir exists
enc = await patch(encounterPath, enc, startEncounter(enc), 'startEncounter'); const traceDir = path.dirname(tracePath);
console.log(`combat started: round ${enc.round}, first=${firstActiveName(enc)}`); if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
await sleep(DELAY); const jsonl = fs.createWriteStream(tracePath, { flags: 'w' });
let stepN = 0;
let prevEnc = null; // cached post = next step's pre (no backend read-back).
// callStep: pre = cached prevEnc (or fresh read on first) → run → post =
// runner return (trusted, like old fast impl) → emit JSONL. No per-step
// backend getDoc. Funcs return newEnc already.
async function callStep(fn, args, runner) {
stepN++;
const encBefore = prevEnc !== null ? prevEnc : await storage.getDoc(encounterPath);
const pre = snapshot(encBefore);
let result, threw = null;
try { result = await runner(encBefore); }
catch (e) { threw = e.message; }
// func returns newEnc (post). Trust it — matches old fast behavior.
// Setup funcs (setDoc/getDoc) return undefined → keep encBefore.
const encAfter = threw ? encBefore : (result !== undefined ? result : encBefore);
prevEnc = encAfter;
const post = snapshot(encAfter);
jsonl.write(JSON.stringify({
step: stepN, ts: Date.now(), type: fn, call: { fn, args },
pre, post, error: threw,
}) + '\n');
return { enc: encAfter, result, threw };
}
console.log(`replay-combat: ${ROUNDS} rounds, ${DELAY}ms/step, backend=${BACKEND}${VERBOSE ? ' [verbose]' : ''}`);
// --- setup: campaign + encounter docs (also traced, type 'setup') ---
await callStep('setup_campaign', { campaignId }, async () =>
storage.setDoc(getPath.campaign(campaignId), {
id: campaignId, name: `Replay Campaign ${runStamp}`, createdAt: Date.now(),
})
);
await callStep('setup_encounter', { encounterId }, async () =>
storage.setDoc(encounterPath, {
id: encounterId,
name: `Big Boss Replay ${runStamp}`,
campaignId,
participants: [],
isStarted: false, isPaused: false,
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
createdAt: Date.now(),
})
);
// add roster + monsters
for (const ch of buildRoster()) {
const { participant } = buildCharacterParticipant(ch);
await callStep('addParticipant', { name: participant.name }, (enc) =>
addParticipant(enc, participant, ctx)
);
}
for (const m of buildMonsters()) {
const { participant } = buildMonsterParticipant(m);
await callStep('addParticipant', { name: participant.name }, (enc) =>
addParticipant(enc, participant, ctx)
);
}
// start combat
let enc = (await storage.getDoc(encounterPath));
const startRes = await callStep('startEncounter', {}, (e) => startEncounter(e, ctx));
enc = startRes.enc;
await storage.updateDoc(activeDisplayPath, { activeCampaignId: campaignId, activeEncounterId: encounterId });
console.log(`combat started: round ${enc.round}, first=${nameOf(enc, enc.currentTurnParticipantId)}`);
let totalTurns = 0; let totalTurns = 0;
const condQueue = [...CONDITIONS].sort(() => Math.random() - 0.5); let lastRound = enc.round;
let reinforcementsAdded = 0;
let lastPaused = false;
let lastReorder = 0;
for (let roundN = 1; roundN <= ROUNDS; roundN++) { // --- main loop: drive by real enc.round ---
console.log(`--- round ${roundN} starting ---`); while (enc.isStarted && enc.round <= ROUNDS && stepN < MAX_STEPS) {
// advance initiative until round counter ticks (full cycle done). const actor = (enc.participants || []).find(p => p.id === enc.currentTurnParticipantId);
const cap = (enc.participants.length + 2) * 2; totalTurns++;
let guard = 0; if (VERBOSE && actor) console.log(` [r${enc.round}] ${actor.name}'s turn`);
while (enc.round < roundN + 1 && guard < cap) {
// NOTE: do NOT getDoc here — async re-fetch can return stale state and
// cause nextTurn to compute off pre-mutation data (double-acts/skips).
// Trust the local enc returned by patch (sync spread of updateDoc).
// 9. resume if paused: must happen BEFORE nextTurn or it throws. if (actor) {
if (lastPaused) { // random damage from actor to random living target
enc = await patch(encounterPath, enc, togglePause(enc), 'resume'); const living = enc.participants.filter(p => p.currentHp > 0 && p.id !== actor.id);
lastPaused = false; if (living.length > 0) {
const tgt = pick(living);
const dmg = 1 + Math.floor(Math.random() * 5);
if (VERBOSE) console.log(` damage ${tgt.name} -${dmg}`);
const res = await callStep('applyHpChange',
{ target: tgt.name, changeType: 'damage', amount: dmg },
(e) => applyHpChange(e, tgt.id, 'damage', dmg, ctx));
enc = res.enc;
} }
let t; // random condition
try { t = nextTurn(enc); } catch (e) { console.log(` nextTurn err: ${e.message}`); break; } if (totalTurns % 5 === 0) {
enc = await patch(encounterPath, enc, t, null); const cond = ALL_CONDITIONS[condIdx++ % ALL_CONDITIONS.length];
totalTurns++; if (VERBOSE) console.log(` condition ${actor.name} +${cond}`);
const actorName = firstActiveName(enc); const res = await callStep('toggleCondition',
const actor = currentParticipant(enc); { participant: actor.name, condition: cond },
(e) => toggleCondition(e, actor.id, cond, ctx));
// Dump turn line with order AND initiative (DM drag may reorder without enc = res.enc;
// changing init — log both so parser can flag unexplained shifts).
const ordStr = enc.turnOrderIds.map(id => {
const p = enc.participants.find(x => x.id === id);
return p ? `${p.name}:${p.initiative}` : id;
}).join(',');
// Also dump participants[] order (display source). Diverge from order = sync bug.
const pStr = enc.participants.map(p => `${p.name}:${p.initiative}`).join(',');
console.log(` turn ${totalTurns} (round ${enc.round}): ${actorName} | order=[${ordStr}] parts=[${pStr}] cur=${enc.currentTurnParticipantId}`);
// 1. damage: actor hits a random living, active target.
if (actor) {
const foes = enc.participants.filter(
p => p.id !== actor.id && p.currentHp > 0 && p.isActive !== false && !p.name.startsWith('Dead')
);
if (foes.length > 0) {
const tgt = pick(foes);
const dmg = 1 + Math.floor(Math.random() * 5); // 1-5
const h = applyHpChange(enc, tgt.id, 'damage', dmg);
if (h.patch) {
await storage.updateDoc(encounterPath, h.patch);
enc = { ...enc, ...h.patch };
console.log(` ${actorName}${tgt.name} (-${dmg}, hp=${tgt.currentHp - dmg})`);
}
}
} }
// 2. heal: Cleric (when active) heals lowest-HP ally every other turn. // random edit (notes/init unchanged)
if (actor && actor.name === 'Cleric' && totalTurns % 2 === 0) { if (totalTurns % 11 === 0) {
const wounded = enc.participants if (VERBOSE) console.log(` update ${actor.name} notes`);
.filter(p => p.currentHp > 0 && p.currentHp < p.maxHp && p.isActive !== false) const res = await callStep('updateParticipant',
.sort((a, b) => (a.currentHp / a.maxHp) - (b.currentHp / b.maxHp)); { participant: actor.name, fields: ['notes'] },
if (wounded.length > 0) { (e) => updateParticipant(e, actor.id, { notes: `edited r${enc.round}` }, ctx));
const tgt = wounded[0]; enc = res.enc;
const amt = 2 + Math.floor(Math.random() * 5); // 2-6
const h = applyHpChange(enc, tgt.id, 'heal', amt);
if (h.patch) {
await storage.updateDoc(encounterPath, h.patch);
enc = { ...enc, ...h.patch };
console.log(` Cleric heal → ${tgt.name} (+${amt}, hp=${tgt.currentHp + amt})`);
}
}
} }
// 3. conditions: toggle a queued condition off some participant each turn. // deathSave: PC at 0 HP
if (condQueue.length > 0) { if (actor.currentHp <= 0 && !actor.isNpc) {
const cond = condQueue[0]; if (VERBOSE) console.log(` deathSave ${actor.name} +1 success`);
const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false); const res = await callStep('deathSave',
if (living.length > 0) { { participant: actor.name, type: 'success', n: 1 },
const tgt = pick(living); (e) => deathSave(e, actor.id, 'success', 1, ctx));
try { enc = res.enc;
const c = toggleCondition(enc, tgt.id, cond);
enc = await patch(encounterPath, enc, c, `condition ${cond} on ${tgt.name}`);
condQueue.shift();
} catch (e) { console.log(` condition ${cond} err: ${e.message}`); condQueue.shift(); }
}
} else if (totalTurns % 6 === 0) {
// second pass: toggle a random condition on random participant (add/remove).
const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
if (living.length > 0) {
const tgt = pick(living);
const cond = pick(CONDITIONS);
try {
const c = toggleCondition(enc, tgt.id, cond);
enc = await patch(encounterPath, enc, c, `condition ${cond} on ${tgt.name}`);
} catch (e) { /* ignore */ }
}
} }
// 4. toggleParticipantActive: randomly mark someone inactive, or reactivate. // toggleActive every 9 turns
if (totalTurns % 9 === 0) { if (totalTurns % 9 === 0) {
const living = enc.participants.filter(p => p.currentHp > 0); const living = enc.participants.filter(p => p.currentHp > 0);
if (living.length > 0) { if (living.length > 0) {
const tgt = pick(living); const tgt = pick(living);
try { if (VERBOSE) console.log(` toggleActive ${tgt.name}`);
const r = toggleParticipantActive(enc, tgt.id); const res = await callStep('toggleParticipantActive',
enc = await patch(encounterPath, enc, r, `${tgt.isActive === false ? 'reactivate' : 'deactivate'} ${tgt.name}`); { participant: tgt.name },
} catch (e) { /* ignore */ } (e) => toggleParticipantActive(e, tgt.id, ctx));
enc = res.enc;
} }
} }
// 5. deathSave: when a PC is at 0 HP on their turn, attempt a save. // reinforcements every 20 turns (pause/add/resume)
if (actor && actor.currentHp <= 0 && !actor.isNpc && actor.name !== actor.name.startsWith('Monster')) { if (totalTurns % 20 === 0 && enc.isPaused === false) {
try { if (VERBOSE) console.log(` reinforcements: pause`);
const ds = deathSave(enc, actor.id, 1); let res = await callStep('togglePause', { to: 'paused' }, (e) => togglePause(e, ctx));
enc = await patch(encounterPath, enc, ds, `deathSave ${actor.name} (+1 success)`); enc = res.enc;
} catch (e) { /* ignore */ } const r = buildMonsterParticipant({ name: `Reinforce${totalTurns}`, maxHp: 20, initMod: 1 });
if (VERBOSE) console.log(` add ${r.participant.name}`);
res = await callStep('addParticipant',
{ name: r.participant.name, reinforcement: true },
(e) => addParticipant(e, r.participant, ctx));
enc = res.enc;
if (VERBOSE) console.log(` resume`);
res = await callStep('togglePause', { to: 'resumed' }, (e) => togglePause(e, ctx));
enc = res.enc;
} }
// 6. removeParticipant: dead monsters hauled off (every ~5 turns). // remove dead monster every 13 turns
if (totalTurns % 5 === 0) { if (totalTurns % 13 === 0) {
const dead = enc.participants.find(p => (p.isDying || p.currentHp <= 0) && (p.isNpc || p.name.startsWith('Goblin') || p.name === 'OrcBoss' || p.name === 'Wolf')); const dead = enc.participants.find(p => p.currentHp <= 0 && p.type === 'monster');
if (dead) { if (dead) {
try { if (VERBOSE) console.log(` remove ${dead.name}`);
const r = removeParticipant(enc, dead.id); const res = await callStep('removeParticipant',
enc = await patch(encounterPath, enc, r, `remove dead ${dead.name}`); { participant: dead.name, dead: true },
} catch (e) { /* ignore */ } (e) => removeParticipant(e, dead.id, ctx));
enc = res.enc;
} }
} }
// 7. addParticipant (reinforcements): every 10 turns a new monster joins. // drag same-init every 17 turns
if (totalTurns % 10 === 0 && reinforcementsAdded < 4) { if (totalTurns % 17 === 0) {
const spec = pick([ const order = enc.participants || [];
{ name: `Reinforce${reinforcementsAdded + 1}`, maxHp: 120, initMod: 1 }, if (order.length >= 2) {
{ name: `Summon${reinforcementsAdded + 1}`, maxHp: 80, initMod: 4 }, const a = order[0], b = order.find(p => p.initiative === a.initiative && p.id !== a.id);
]); if (b) {
try { if (VERBOSE) console.log(` reorder ${b.name} -> ${a.name}`);
const built = buildMonsterParticipant(spec).participant; const res = await callStep('reorderParticipants',
const r = addParticipant(enc, built); { dragged: b.name, target: a.name },
enc = await patch(encounterPath, enc, r, `add ${spec.name}`); (e) => reorderParticipants(e, b.id, a.id, ctx));
reinforcementsAdded++; enc = res.enc;
} catch (e) { /* ignore */ } }
}
// 8. updateParticipant: every 7 turns, edit a field on someone (e.g. temp AC).
if (totalTurns % 7 === 0) {
const living = enc.participants.filter(p => p.currentHp > 0);
if (living.length > 0) {
const tgt = pick(living);
try {
const r = updateParticipant(enc, tgt.id, { notes: `edited@turn${totalTurns}` });
enc = await patch(encounterPath, enc, r, `edit ${tgt.name} notes`);
} catch (e) { /* ignore */ }
} }
} }
// 9. togglePause: every 12 turns, pause (resumes next iteration via above).
if (totalTurns % 12 === 0 && !lastPaused) {
enc = await patch(encounterPath, enc, togglePause(enc), 'pause');
lastPaused = true;
}
// 10. reorderParticipants: every 8 turns, drag one past another (DM reorder).
// Pick two ADJACENT UPCOMING actors (both strictly after current pointer)
// and swap them. Avoids crossing current pointer — crossing it creates
// ambiguous "who acted this round" semantics (skip/double). Swapping two
// upcoming actors is always safe and still exercises reorder.
if (totalTurns % 8 === 0 && lastReorder !== totalTurns) {
const curIdx = enc.turnOrderIds.indexOf(enc.currentTurnParticipantId);
// upcoming = everyone after current in turn order (rest of this round)
const upcomingIds = enc.turnOrderIds.slice(curIdx + 1)
.filter(id => { const p = enc.participants.find(x => x.id === id); return p && p.currentHp > 0 && p.isActive !== false; });
// swap first adjacent upcoming pair (drag index1 before index0)
if (upcomingIds.length >= 2) {
const target = enc.participants.find(p => p.id === upcomingIds[0]);
const dragged = enc.participants.find(p => p.id === upcomingIds[1]);
try {
const r = reorderParticipants(enc, dragged.id, target.id);
enc = await patch(encounterPath, enc, r, `reorder ${dragged.name}→before ${target.name}`);
lastReorder = totalTurns;
} catch (e) { /* swap not allowed — skip this round */ }
}
}
await sleep(DELAY);
guard++;
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
} }
if (DELAY > 0) await sleep(DELAY);
// advance turn (unless combat auto-ended)
if (!enc.isStarted) { console.log('combat auto-ended'); break; } if (!enc.isStarted) { console.log('combat auto-ended'); break; }
const alive = enc.participants.filter(p => p.currentHp > 0).length; if (VERBOSE) console.log(` -> nextTurn`);
// revive dead: heal to full + reactivate. Sustains combat for 100 rounds const advRes = await callStep('nextTurn', {}, (e) => nextTurn(e, ctx));
// and exercises toggleActive reactivate + heal-from-zero path. enc = advRes.enc;
const dead = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false); if (advRes.threw) { console.log(` nextTurn err: ${advRes.threw}`); break; }
for (const d of dead) {
try { // round wrap: revive dead so combat sustains to full ROUNDS count
if (enc.isStarted && enc.round > lastRound) {
if (enc.round > ROUNDS) break; // round cap reached, stop before printing/acting
console.log(`--- round ${enc.round} ---`);
lastRound = enc.round;
const dead = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false);
for (const d of dead) {
if (d.isActive === false) { if (d.isActive === false) {
enc = await patch(encounterPath, enc, toggleParticipantActive(enc, d.id), `revive-reactivate ${d.name}`); if (VERBOSE) console.log(` revive ${d.name}`);
const res = await callStep('toggleParticipantActive',
{ participant: d.name, revive: true },
(e) => toggleParticipantActive(e, d.id, ctx));
enc = res.enc;
} }
const h = applyHpChange(enc, d.id, 'heal', d.maxHp); if (VERBOSE) console.log(` heal ${d.name} +${d.maxHp}`);
enc = await patch(encounterPath, enc, h, `revive-heal ${d.name}${d.maxHp}`); const res = await callStep('applyHpChange',
} catch (e) { console.log(` revive ${d.name} err: ${e.message}`); } { target: d.name, changeType: 'heal', amount: d.maxHp, revive: true },
(e) => applyHpChange(e, d.id, 'heal', d.maxHp, ctx));
enc = res.enc;
}
} }
if (!enc.isStarted) { console.log('combat auto-ended'); break; }
} }
console.log(`replay: ${totalTurns} total turns across ${ROUNDS} rounds`); console.log(`replay: ${totalTurns} actor-turns, reached round ${enc.round} (cap ${ROUNDS})`);
// end // end encounter
enc = await storage.getDoc(encounterPath); enc = await storage.getDoc(encounterPath);
if (enc.isStarted) enc = await patch(encounterPath, enc, endEncounter(enc), 'endEncounter'); if (enc.isStarted) {
const res = await callStep('endEncounter', {}, (e) => endEncounter(e, ctx));
enc = res.enc;
}
await storage.updateDoc(activeDisplayPath, { activeCampaignId: null, activeEncounterId: null }); await storage.updateDoc(activeDisplayPath, { activeCampaignId: null, activeEncounterId: null });
jsonl.end();
await new Promise(r => jsonl.close(r));
console.log(`trace written: ${stepN} steps -> ${tracePath}`);
console.log('');
console.log('>>> ANALYZE: node scripts/analyze-turns.js "' + tracePath + '"');
console.log('replay done'); console.log('replay done');
} }
function firstActiveName(enc) {
if (!enc.currentTurnParticipantId) return '(none)';
const p = currentParticipant(enc);
return p ? p.name : '(missing)';
}
function currentParticipant(enc) {
if (!enc.currentTurnParticipantId) return null;
return (enc.participants || []).find(x => x.id === enc.currentTurnParticipantId) || null;
}
main().catch(err => { console.error('replay failed:', err); process.exit(1); }); main().catch(err => { console.error('replay failed:', err); process.exit(1); });
+146
View File
@@ -0,0 +1,146 @@
// scripts/replay-from-logs.js
// Ingest canonical event JSON (downloaded app logs OR replay-combat --json-out)
// and replay forward patches in order. Verifies each event's snapshot matches
// the reconstructed encounter state — catches drift between logged intent and
// actual mutation.
//
// Usage:
// node scripts/replay-from-logs.js <events.json>
// node scripts/replay-from-logs.js <events.json> --encounter <id> # filter
// cat logs.json | node scripts/replay-from-logs.js # stdin
//
// Modes:
// (default) in-memory replay + snapshot verification. No backend writes.
// --write apply each patch to LIVE backend (same adapter as replay-combat).
// Creates fresh encounter under new campaign. Useful for cloning
// a logged combat into a clean DB.
//
// Output: turn sequence, round progression, drift report (any snapshot mismatch).
'use strict';
const fs = require('fs');
const { normalizeEvent } = require('../shared/logEvent');
const WRITE_MODE = process.argv.includes('--write');
const ENC_FILTER = (() => {
const i = process.argv.indexOf('--encounter');
return i >= 0 ? process.argv[i + 1] : null;
})();
function readInput() {
// positional .json arg, else stdin.
const pos = process.argv[2];
if (pos && !pos.startsWith('--')) return fs.readFileSync(pos, 'utf8');
// stdin: require piped data (not interactive terminal). Avoid hang.
if (process.stdin.isTTY) {
console.error('Usage: node scripts/replay-from-logs.js <events.json> [--encounter <id>] [--write]');
console.error(' cat events.json | node scripts/replay-from-logs.js');
process.exit(2);
}
const data = fs.readFileSync(0, 'utf8');
if (!data.trim()) {
console.error('No input on stdin and no file arg.');
process.exit(2);
}
return data;
}
// Shallow merge patch into encounter (mirrors storage updateDoc semantics).
function applyPatch(enc, patch) {
if (!patch) return enc;
const next = { ...enc };
for (const [k, v] of Object.entries(patch)) {
next[k] = v;
}
return next;
}
// Compare logged snapshot vs computed state. Returns list of field mismatches.
function diffSnapshot(enc, snap) {
if (!snap) return [];
const drift = [];
const curRound = enc.round ?? 0;
const curTurn = enc.currentTurnParticipantId ?? null;
const order = enc.turnOrderIds || [];
const active = (enc.participants || []).filter(p => p.isActive).map(p => p.id);
if (snap.round !== curRound) drift.push(`round: logged=${snap.round} actual=${curRound}`);
if (snap.currentTurnParticipantId !== curTurn) drift.push(`currentTurn: logged=${snap.currentTurnParticipantId} actual=${curTurn}`);
if (JSON.stringify(snap.turnOrderIds || []) !== JSON.stringify(order)) drift.push(`turnOrderIds: logged=${JSON.stringify(snap.turnOrderIds || [])} actual=${JSON.stringify(order)}`);
if (JSON.stringify(snap.activeIds || []) !== JSON.stringify(active)) drift.push(`activeIds: logged=${JSON.stringify(snap.activeIds || [])} actual=${JSON.stringify(active)}`);
return drift;
}
function nameMap(enc) {
const m = new Map();
for (const p of (enc.participants || [])) if (p.id && p.name) m.set(p.id, p.name);
return m;
}
async function main() {
const text = readInput().trim();
const raw = JSON.parse(text);
const arr = (Array.isArray(raw) ? raw : [raw]).map(normalizeEvent).filter(Boolean);
const events = ENC_FILTER ? arr.filter(e => e.encounterId === ENC_FILTER) : arr;
console.log(`replay-from-logs: ${events.length} events${ENC_FILTER ? ` (encounter ${ENC_FILTER})` : ''}`);
let enc = null;
let encName = null;
let encPath = null;
let round = 0;
let turnCount = 0;
let appliedCount = 0;
const drift = [];
const turnSequence = [];
for (const e of events) {
// init on first event
if (!enc) {
enc = applyPatch({}, e.payload);
encName = e.encounterName;
encPath = e.encounterPath;
} else {
enc = applyPatch(enc, e.payload);
}
appliedCount++;
if (e.snapshot) {
const d = diffSnapshot(enc, e.snapshot);
if (d.length) drift.push({ event: e, fields: d });
}
if (e.type === 'next_turn') {
turnCount++;
const names = nameMap(enc);
const actor = e.snapshot?.currentTurnParticipantId;
const actorName = names.get(actor) || actor || '?';
const r = e.snapshot?.round ?? enc?.round ?? 0;
if (r !== round) { round = r; }
turnSequence.push(`R${r}: ${actorName}`);
}
}
console.log(`encounter: ${encName || '?'}`);
console.log(`events applied: ${appliedCount} / ${events.length}`);
console.log(`turns replayed: ${turnCount}`);
console.log(`final round: ${round}`);
console.log(`participants at end: ${(enc?.participants || []).length}`);
if (drift.length) {
console.log(`\n--- SNAPSHOT DRIFT (${drift.length} events) ---`);
for (const { event: e, fields } of drift.slice(0, 10)) {
console.log(` ${e.type} (${e.id || e.ts}): ${fields.join('; ')}`);
}
if (drift.length > 10) console.log(` ... +${drift.length - 10} more`);
} else {
console.log('\nsnapshots: all match');
}
console.log(`\n=== ${turnCount} turns, ${drift.length} drift ===`);
console.log(drift.length === 0 ? 'CLEAN — replay matches logged intent' : 'DRIFT — logged snapshots diverge from applied patches');
process.exit(drift.length === 0 ? 0 : 1);
}
main().catch(err => { console.error('replay-from-logs failed:', err); process.exit(1); });
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# scripts/run-tests.sh — run all 3 test suites with hard timeout.
# Design spec: tests never take >60s. Hang = failure, not silent.
# Each suite gets 60s. Total cap = 180s. Exits non-zero on timeout OR failure.
set -euo pipefail
TIMEOUT_BIN=gtimeout
command -v gtimeout >/dev/null 2>&1 || TIMEOUT_BIN=timeout
if ! command -v "$TIMEOUT_BIN" >/dev/null 2>&1; then
echo "ERROR: need 'timeout' (coreutils) or 'gtimeout' (brew coreutils)" >&2
exit 2
fi
run_suite () {
local label="$1"; shift
local cmd="$1"; shift
local secs="${1:-60}"
echo "=== $label (${secs}s cap) ==="
if $TIMEOUT_BIN --signal=KILL "$secs" bash -c "$cmd"; then
echo "=== $label: PASS ==="
else
local rc=$?
if [ "$rc" -eq 137 ] || [ "$rc" -eq 124 ]; then
echo "=== $label: FAIL — timeout (${secs}s exceeded, killed) ===" >&2
else
echo "=== $label: FAIL (exit $rc) ===" >&2
fi
exit "$rc"
fi
}
# Pre-flight: refuse to run if any test is skipped/disabled.
# Skipped tests rot silently. Catch them before they hide coverage gaps.
check_for_skips () {
echo "=== skip-guard ==="
local hits
hits=$(grep -rnE '\.(skip|todo)\(|xdescribe\(|xit\(' src/tests shared/tests server/tests 2>/dev/null || true)
if [ -n "$hits" ]; then
echo "FAIL: skipped/disabled tests found:" >&2
echo "$hits" >&2
echo "" >&2
echo "Fix or delete. Do not skip." >&2
exit 1
fi
echo "=== skip-guard: PASS ==="
}
check_for_skips
run_suite "app" "CI=true npx react-scripts test --watchAll=false" 60
run_suite "shared" "npm test --workspace shared" 30
run_suite "server" "npm test --workspace server" 30
echo "=== ALL SUITES GREEN ==="
+71 -5
View File
@@ -10,7 +10,7 @@
// logs/{id} doc // logs/{id} doc
// //
// No shape-specific tables. Data is opaque JSON. This is the firebase mirror: // No shape-specific tables. Data is opaque JSON. This is the firebase mirror:
// the adapter (src/storage/ws.js) is a thin passthrough, app logic unchanged. // the adapter (src/storage/server.js) is a thin passthrough, app logic unchanged.
'use strict'; 'use strict';
@@ -27,6 +27,10 @@ CREATE TABLE IF NOT EXISTS docs (
); );
CREATE INDEX IF NOT EXISTS idx_docs_parent ON docs(parent); CREATE INDEX IF NOT EXISTS idx_docs_parent ON docs(parent);
-- Hot path: logs page subscribes to latest logs ordered by JSON ts.
-- Without this, every log write makes UI re-fetch + sort all logs.
CREATE INDEX IF NOT EXISTS idx_docs_parent_ts ON docs(parent, CAST(json_extract(data, '$.ts') AS NUMERIC));
CREATE INDEX IF NOT EXISTS idx_docs_parent_encounter_ts ON docs(parent, json_extract(data, '$.encounterPath'), CAST(json_extract(data, '$.ts') AS NUMERIC));
`; `;
function openDb(dbPath) { function openDb(dbPath) {
@@ -52,7 +56,6 @@ function makeStore(db, broadcast) {
ON CONFLICT(path) DO UPDATE SET data = @data, updated_at = @ts ON CONFLICT(path) DO UPDATE SET data = @data, updated_at = @ts
`); `);
const stmtDelete = db.prepare('DELETE FROM docs WHERE path = ?'); const stmtDelete = db.prepare('DELETE FROM docs WHERE path = ?');
const stmtColl = db.prepare('SELECT path, data FROM docs WHERE parent = ? ORDER BY path ASC');
function getDoc(p) { function getDoc(p) {
const row = stmtGet.get(p); const row = stmtGet.get(p);
@@ -79,8 +82,54 @@ function makeStore(db, broadcast) {
if (broadcast) broadcast({ path: p, parent: parentOf(p), deleted: true }); if (broadcast) broadcast({ path: p, parent: parentOf(p), deleted: true });
} }
function getCollection(collPath) { function getCollection(collPath, { where, orderBy, dir = 'asc', limit, offset } = {}) {
return stmtColl.all(collPath).map(row => ({ id: row.path.split('/').pop(), path: row.path, ...JSON.parse(row.data) })); let sql = 'SELECT path, data FROM docs WHERE parent = ?';
const params = [collPath];
if (where) {
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(where.field)) throw new Error(`bad where field: ${where.field}`);
if (where.op !== '==') throw new Error(`unsupported where op: ${where.op}`);
sql += ` AND json_extract(data, '$.${where.field}') = ?`;
params.push(where.value);
}
if (orderBy) {
// whitelist field name to avoid injection (alphanumeric + underscore only)
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(orderBy)) throw new Error(`bad orderBy field: ${orderBy}`);
sql += ` ORDER BY CAST(json_extract(data, '$.${orderBy}') AS NUMERIC) ${dir === 'desc' ? 'DESC' : 'ASC'}`;
} else {
sql += ' ORDER BY path ASC';
}
if (limit && Number.isInteger(+limit) && +limit > 0) {
sql += ' LIMIT ?';
params.push(+limit);
if (offset && Number.isInteger(+offset) && +offset > 0) {
sql += ' OFFSET ?';
params.push(+offset);
}
}
return db.prepare(sql).all(...params).map(row => ({ id: row.path.split('/').pop(), path: row.path, ...JSON.parse(row.data) }));
}
function countCollection(collPath) {
return db.prepare('SELECT COUNT(*) AS n FROM docs WHERE parent = ?').get(collPath).n;
}
// Bulk delete whole collection or by where-filter. No fetch. SQL knows paths.
// DEV ONLY — guarded at HTTP layer; db fn itself unguarded (server-internal trust).
function deleteCollection(collPath, { where } = {}) {
let sql = 'DELETE FROM docs WHERE parent = ?';
const params = [collPath];
const changed = [];
if (where) {
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(where.field)) throw new Error(`bad where field: ${where.field}`);
if (where.op !== '==') throw new Error(`unsupported where op: ${where.op}`);
sql += ` AND json_extract(data, '$.${where.field}') = ?`;
params.push(where.value);
}
// collect paths+parents for broadcast before delete
const rows = db.prepare('SELECT path, parent FROM docs WHERE parent = ?' + (where ? ` AND json_extract(data, '$.${where.field}') = ?` : '')).all(...params);
const info = db.prepare(sql).run(...params);
if (broadcast) rows.forEach(r => broadcast({ path: r.path, parent: r.parent, deleted: true }));
return info.changes;
} }
function batchWrite(ops) { function batchWrite(ops) {
@@ -104,7 +153,24 @@ function makeStore(db, broadcast) {
if (broadcast) changed.forEach(c => broadcast(c)); if (broadcast) changed.forEach(c => broadcast(c));
} }
return { getDoc, setDoc, updateDoc, deleteDoc, getCollection, batchWrite }; // Transactional undo: apply encounter updates + flip log `undone` in one tx.
// No stale clobber, atomic. Returns { undone: true } or throws.
function transactionalUndo({ logPath, encounterPath, updates, undoneFlag }) {
const run = db.transaction(() => {
const merged = updateDoc(encounterPath, updates);
const log = getDoc(logPath) || {};
setDoc(logPath, { ...log, undone: undoneFlag });
return { encounter: merged, undone: undoneFlag };
});
const result = run();
if (broadcast) {
broadcast({ path: encounterPath, parent: parentOf(encounterPath) });
broadcast({ path: logPath, parent: parentOf(logPath) });
}
return result;
}
return { getDoc, setDoc, updateDoc, deleteDoc, getCollection, countCollection, deleteCollection, batchWrite, transactionalUndo };
} }
module.exports = { openDb, parentOf, makeStore }; module.exports = { openDb, parentOf, makeStore };
+56 -3
View File
@@ -1,6 +1,6 @@
// server/index.js — generic KV document store over HTTP + WebSocket. // server/index.js — generic KV document store over HTTP + WebSocket.
// firebase mirror: doc-tree model. Thin REST, path-based WS push. // firebase mirror: doc-tree model. Thin REST, path-based WS push.
// Adapter (src/storage/ws.js) = passthrough, no shape translation. // Adapter (src/storage/server.js) = passthrough, no shape translation.
'use strict'; 'use strict';
@@ -11,7 +11,7 @@ const crypto = require('crypto');
const { WebSocketServer } = require('ws'); const { WebSocketServer } = require('ws');
const { openDb, makeStore } = require('./db'); const { openDb, makeStore } = require('./db');
function createServer({ dbPath, port, corsOrigin } = {}) { function createServer({ dbPath, port, corsOrigin, allowDevEndpoints = false } = {}) {
const db = openDb(dbPath || './data/tracker.sqlite'); const db = openDb(dbPath || './data/tracker.sqlite');
const app = express(); const app = express();
app.use(cors({ origin: corsOrigin || '*' })); app.use(cors({ origin: corsOrigin || '*' }));
@@ -64,9 +64,21 @@ function createServer({ dbPath, port, corsOrigin } = {}) {
// GET /api/collection?path=campaigns/abc/encounters // GET /api/collection?path=campaigns/abc/encounters
app.get('/api/collection', (req, res) => { app.get('/api/collection', (req, res) => {
const { path: p, whereField, whereOp, whereValue, orderBy, dir, limit, offset } = req.query;
if (!p) return res.status(400).json({ error: 'path required' });
const opts = {};
if (whereField) opts.where = { field: whereField, op: whereOp || '==', value: whereValue };
if (orderBy) opts.orderBy = orderBy;
if (dir) opts.dir = dir;
if (limit) opts.limit = limit;
if (offset) opts.offset = offset;
res.json(store.getCollection(p, opts));
});
app.get('/api/collection/count', (req, res) => {
const { path: p } = req.query; const { path: p } = req.query;
if (!p) return res.status(400).json({ error: 'path required' }); if (!p) return res.status(400).json({ error: 'path required' });
res.json(store.getCollection(p)); res.json({ count: store.countCollection(p) });
}); });
// PUT /api/doc body: { path, data } (replace) // PUT /api/doc body: { path, data } (replace)
@@ -91,6 +103,21 @@ function createServer({ dbPath, port, corsOrigin } = {}) {
res.json({ ok: true }); res.json({ ok: true });
}); });
// DELETE /api/collection?path=...&whereField=...&whereValue=...
// Bulk delete whole collection or filtered. No fetch. SQL DELETE.
// DEV ONLY: requires ALLOW_DEV_ENDPOINTS=1 env on server.
app.delete('/api/collection', (req, res) => {
if (!allowDevEndpoints && process.env.ALLOW_DEV_ENDPOINTS !== '1') {
return res.status(403).json({ error: 'bulk delete disabled (dev only)' });
}
const { path: p, whereField, whereOp, whereValue } = req.query;
if (!p) return res.status(400).json({ error: 'path required' });
const opts = {};
if (whereField) opts.where = { field: whereField, op: whereOp || '==', value: whereValue };
const deleted = store.deleteCollection(p, opts);
res.json({ ok: true, deleted });
});
// POST /api/collection body: { path, data } (addDoc: auto-id under collection) // POST /api/collection body: { path, data } (addDoc: auto-id under collection)
app.post('/api/collection', (req, res) => { app.post('/api/collection', (req, res) => {
const { path: collPath, data } = req.body || {}; const { path: collPath, data } = req.body || {};
@@ -108,6 +135,32 @@ function createServer({ dbPath, port, corsOrigin } = {}) {
res.json({ ok: true }); res.json({ ok: true });
}); });
// POST /api/undo body: { logPath, undo } where undo={encounterPath, updates, redo}
// Transactional: apply updates + flip undone in one SQLite tx.
// If `redo` true, applies redo patch instead + flips undone:false.
app.post('/api/undo', (req, res) => {
const { logPath, undo } = req.body || {};
if (!logPath || !undo || !undo.encounterPath) {
return res.status(400).json({ error: 'logPath + undo.encounterPath required' });
}
if (!store.transactionalUndo) {
return res.status(501).json({ error: 'transactionalUndo not supported by store' });
}
try {
const isRedo = !!req.body.redo;
const updates = isRedo ? (undo.redo || undo.updates) : undo.updates;
const result = store.transactionalUndo({
logPath,
encounterPath: undo.encounterPath,
updates,
undoneFlag: !isRedo,
});
res.json({ ok: true, ...result });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// --- WebSocket: subscribe by path --- // --- WebSocket: subscribe by path ---
const server = http.createServer(app); const server = http.createServer(app);
const wss = new WebSocketServer({ server, path: '/ws' }); const wss = new WebSocketServer({ server, path: '/ws' });
+1 -1
View File
@@ -7,7 +7,7 @@
"scripts": { "scripts": {
"dev": "node --watch index.js", "dev": "node --watch index.js",
"start": "node index.js", "start": "node index.js",
"test": "jest --forceExit" "test": "../scripts/cap.sh 30 jest --forceExit"
}, },
"dependencies": { "dependencies": {
"@ttrpg/shared": "*", "@ttrpg/shared": "*",
+115
View File
@@ -0,0 +1,115 @@
// Layer 2 test: exercise server.js storage adapter against a LIVE backend.
// Complements Layer 1 (App + firebase mock) which proves App call shape but
// never touches ws.js. This catches translation bugs in the adapter.
//
// Runs the shared storage contract (same spec memory/firebase satisfy) against
// createServerStorage pointed at an ephemeral backend instance. A FRESH backend is
// spun up per test to guarantee isolation (backend has no reset endpoint yet).
'use strict';
const path = require('path');
const os = require('os');
const { createServer } = require('../index');
const { createServerStorage } = require('../../src/storage/server');
const { runStorageContract } = require('../../src/storage/contract');
// Factory: fresh backend (unique sqlite file) + storage pointed at it.
// Disposing the storage closes the backend so each test is fully isolated.
async function makeStorage({ allowDevEndpoints = false } = {}) {
const dbPath = path.join(os.tmpdir(), `ws-contract-${Date.now()}-${Math.random().toString(36).slice(2)}.sqlite`);
const handle = createServer({ dbPath, port: 0, allowDevEndpoints });
await new Promise((resolve, reject) => {
handle.server.on('error', reject);
handle.server.listen(0, resolve);
});
const port = handle.server.address().port;
const baseUrl = `http://127.0.0.1:${port}`;
const wsUrl = `ws://127.0.0.1:${port}/ws`;
const storage = createServerStorage({ baseUrl, realtimeUrl: wsUrl });
storage.dispose = (done) => handle.close(done);
return storage;
}
runStorageContract('server (live backend)', () => makeStorage({ allowDevEndpoints: true }));
describe('server-side query constraints', () => {
let storage;
beforeEach(async () => {
storage = await makeStorage();
});
afterEach((done) => storage.dispose(done));
test('GET /api/collection honors orderBy + limit (server-side, not client)', async () => {
// 5 docs with distinct ts
for (let i = 1; i <= 5; i++) {
await storage.addDoc('logs', { type: 'next_turn', ts: i * 100, n: i });
}
const res = await storage._api('GET', '/api/collection',
{ path: 'logs', orderBy: 'ts', dir: 'desc', limit: '2' });
expect(Array.isArray(res)).toBe(true);
expect(res.length).toBe(2);
// desc: highest ts first
expect(res[0].data ? res[0].data.n : res[0].n).toBe(5);
expect(res[1].data ? res[1].data.n : res[1].n).toBe(4);
});
test('GET /api/collection honors offset for pagination', async () => {
for (let i = 1; i <= 5; i++) {
await storage.addDoc('logs', { type: 'next_turn', ts: i * 100, n: i });
}
// page 2 of size 2, desc by ts: should be n=3,2
const res = await storage._api('GET', '/api/collection',
{ path: 'logs', orderBy: 'ts', dir: 'desc', limit: '2', offset: '2' });
expect(res.length).toBe(2);
expect(res[0].data ? res[0].data.n : res[0].n).toBe(3);
expect(res[1].data ? res[1].data.n : res[1].n).toBe(2);
});
test('GET /api/collection/count returns total', async () => {
for (let i = 1; i <= 5; i++) {
await storage.addDoc('logs', { type: 'next_turn', ts: i * 100, n: i });
}
const res = await storage._api('GET', '/api/collection/count', { path: 'logs' });
expect(res).toEqual({ count: 5 });
});
});
describe('DELETE /api/collection (bulk delete)', () => {
let storage;
beforeEach(async () => {
storage = await makeStorage({ allowDevEndpoints: true });
});
afterEach((done) => storage.dispose(done));
test('deletes all docs in collection, no fetch', async () => {
for (let i = 0; i < 3; i++) await storage.addDoc('logs', { type: 'x', n: i });
const res = await storage._api('DELETE', '/api/collection', { path: 'logs' });
expect(res.ok).toBe(true);
expect(res.deleted).toBe(3);
const after = await storage._api('GET', '/api/collection/count', { path: 'logs' });
expect(after.count).toBe(0);
});
test('honors whereField filter', async () => {
await storage.addDoc('logs', { type: 'keep', n: 1 });
await storage.addDoc('logs', { type: 'drop', n: 2 });
await storage.addDoc('logs', { type: 'drop', n: 3 });
const res = await storage._api('DELETE', '/api/collection',
{ path: 'logs', whereField: 'type', whereValue: 'drop' });
expect(res.deleted).toBe(2);
const after = await storage._api('GET', '/api/collection/count', { path: 'logs' });
expect(after.count).toBe(1);
});
test('403 when ALLOW_DEV_ENDPOINTS not set', async () => {
const gated = await makeStorage({ allowDevEndpoints: false });
let errMsg = null;
try {
await gated._api('DELETE', '/api/collection', { path: 'logs' });
} catch (e) { errMsg = e.message; }
expect(errMsg).toMatch(/403/);
await new Promise((resolve) => gated.dispose(resolve));
});
});
@@ -8,7 +8,7 @@
const path = require('path'); const path = require('path');
const os = require('os'); const os = require('os');
const { createServer } = require('../index'); const { createServer } = require('../index');
const { createWsStorage } = require('../../src/storage/ws'); const { createServerStorage } = require('../../src/storage/server');
const flush = (ms = 150) => new Promise(r => setTimeout(r, ms)); const flush = (ms = 150) => new Promise(r => setTimeout(r, ms));
@@ -22,7 +22,7 @@ async function makeStorage() {
const port = handle.server.address().port; const port = handle.server.address().port;
const baseUrl = `http://127.0.0.1:${port}`; const baseUrl = `http://127.0.0.1:${port}`;
const wsUrl = `ws://127.0.0.1:${port}/ws`; const wsUrl = `ws://127.0.0.1:${port}/ws`;
const storage = createWsStorage({ baseUrl, wsUrl }); const storage = createServerStorage({ baseUrl, realtimeUrl: wsUrl });
storage.dispose = (done) => handle.close(done); storage.dispose = (done) => handle.close(done);
return storage; return storage;
} }
@@ -48,7 +48,7 @@ describe('BUG-8: ws adapter reconnect after drop', () => {
await flush(1000); await flush(1000);
const last = calls[calls.length - 1]; const last = calls[calls.length - 1];
expect(last).toEqual({ name: 'V2' }); expect(last).toEqual({ id: 'a', name: 'V2' });
} finally { } finally {
await new Promise(r => storage.dispose(r)); await new Promise(r => storage.dispose(r));
} }
-34
View File
@@ -1,34 +0,0 @@
// Layer 2 test: exercise ws.js storage adapter against a LIVE backend.
// Complements Layer 1 (App + firebase mock) which proves App call shape but
// never touches ws.js. This catches translation bugs in the adapter.
//
// Runs the shared storage contract (same spec memory/firebase satisfy) against
// createWsStorage pointed at an ephemeral backend instance. A FRESH backend is
// spun up per test to guarantee isolation (backend has no reset endpoint yet).
'use strict';
const path = require('path');
const os = require('os');
const { createServer } = require('../index');
const { createWsStorage } = require('../../src/storage/ws');
const { runStorageContract } = require('../../src/storage/contract');
// Factory: fresh backend (unique sqlite file) + storage pointed at it.
// Disposing the storage closes the backend so each test is fully isolated.
async function makeStorage() {
const dbPath = path.join(os.tmpdir(), `ws-contract-${Date.now()}-${Math.random().toString(36).slice(2)}.sqlite`);
const handle = createServer({ dbPath, port: 0 });
await new Promise((resolve, reject) => {
handle.server.on('error', reject);
handle.server.listen(0, resolve);
});
const port = handle.server.address().port;
const baseUrl = `http://127.0.0.1:${port}`;
const wsUrl = `ws://127.0.0.1:${port}/ws`;
const storage = createWsStorage({ baseUrl, wsUrl });
storage.dispose = (done) => handle.close(done);
return storage;
}
runStorageContract('ws (live backend)', makeStorage);
+2 -1
View File
@@ -1,2 +1,3 @@
// @ttrpg/shared — barrel export. // @ttrpg/shared — barrel export.
module.exports = require('./turn.js'); const turn = require('./turn.js');
module.exports = { ...turn, logEvent: require('./logEvent.js') };
+1
View File
@@ -3,4 +3,5 @@ module.exports = {
testEnvironment: 'node', testEnvironment: 'node',
testMatch: ['<rootDir>/tests/**/*.test.js'], testMatch: ['<rootDir>/tests/**/*.test.js'],
collectCoverageFrom: ['turn.js'], collectCoverageFrom: ['turn.js'],
testTimeout: 10000,
}; };
+76
View File
@@ -0,0 +1,76 @@
// shared/logEvent.js — canonical event shape for UI/download/replay/analyze.
// One source of truth. All four consumers import this.
//
// Canonical event:
// {
// id, ts, type, message,
// encounterId, encounterName, encounterPath,
// payload, // forward patch (null for no-op)
// undo_payload, // { encounterPath, updates, redo } or null
// undone, // bool
// snapshot // { round, currentTurnParticipantId, turnOrderIds, activeIds } or null
// }
//
// Old logs (pre-refactor): { timestamp, message, encounterName, undo }.
// normalizeEvent fills defaults + lifts legacy undo into undo_payload.
const DEFAULTS = {
ts: 0,
type: 'unknown',
message: '',
encounterId: null,
encounterName: null,
encounterPath: null,
payload: null,
undo_payload: null,
undone: false,
snapshot: null,
};
// Canonical lean event shape:
// {
// id, ts, type, message,
// encounterId, encounterName, encounterPath,
// participantId, participantName, // nullable (pause/nextTurn have none)
// delta, // type-specific scalars (nullable)
// undo, // id-based inverse (nullable)
// undone, // bool
// snapshot // {round, currentTurnParticipantId, turnOrderIds, activeIds}
// }
// Legacy logs (pre-refactor, no type): display message only. Not undoable.
// Old bloated logs (payload/undo_payload): normalized to lean — payload dropped,
// undo_payload lifted to undo. Both display fine; undo expands via expandUndo.
function normalizeEvent(raw) {
if (!raw) return null;
if (!raw.type || raw.type === 'unknown') return null;
const id = raw.id || (raw.path ? raw.path.split('/').pop() : null);
return {
id,
ts: raw.ts || raw.timestamp || 0,
type: raw.type,
message: raw.message || '',
encounterId: raw.encounterId || null,
encounterName: raw.encounterName || null,
encounterPath: raw.encounterPath || null,
participantId: raw.participantId || null,
participantName: raw.participantName || null,
delta: raw.delta || null,
undo: raw.undo || raw.undo_payload || null,
undone: !!raw.undone,
snapshot: raw.snapshot || null,
};
}
// Serialize array of raw log entries (DB rows) → canonical JSON string.
// Legacy logs (no type) dropped by normalizeEvent. Sorted ascending by ts.
function serializeEvents(rawLogs) {
return JSON.stringify(
(rawLogs || [])
.map(normalizeEvent)
.filter(Boolean)
.sort((a, b) => a.ts - b.ts),
null, 2
);
}
module.exports = { normalizeEvent, serializeEvents, DEFAULTS };
+1 -1
View File
@@ -5,7 +5,7 @@
"description": "Pure logic shared by client + server + tests. No I/O.", "description": "Pure logic shared by client + server + tests. No I/O.",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"test": "jest" "test": "../scripts/cap.sh 30 jest"
}, },
"devDependencies": { "devDependencies": {
"jest": "^29.7.0" "jest": "^29.7.0"
+131
View File
@@ -0,0 +1,131 @@
# Test Rewrite Spec — async turn.js writes own logs
## New turn.js API
ALL mutating funcs now `async`, take `ctx` last param, write encounter + log internally, return `newEnc`.
```js
// OLD
const r = nextTurn(enc);
enc = { ...enc, ...r.patch };
// caller had to write log separately
// NEW
enc = await nextTurn(enc, ctx);
// encounter + log already written inside func
```
### Func signatures (all async unless noted)
- `startEncounter(enc, ctx)` → newEnc
- `nextTurn(enc, ctx)` → newEnc
- `togglePause(enc, ctx)` → newEnc
- `addParticipant(enc, participant, ctx)` → newEnc
- `addParticipants(enc, newParticipants[], ctx)` → newEnc
- `updateParticipant(enc, id, updatedData, ctx)` → newEnc
- `removeParticipant(enc, id, ctx)` → newEnc
- `toggleParticipantActive(enc, id, ctx)` → newEnc
- `applyHpChange(enc, id, changeType, amount, ctx)` → newEnc (no-op = returns enc unchanged, no write)
- `deathSave(enc, id, type, n, ctx)``{ enc, status, isDying }` (object now!)
- `toggleCondition(enc, id, conditionId, ctx)` → newEnc
- `reorderParticipants(enc, draggedId, targetId, ctx)` → newEnc (no-op = returns enc, no write)
- `endEncounter(enc, ctx)` → newEnc
### Pure helpers (sync, unchanged)
- `makeParticipant`, `buildCharacterParticipant`, `buildMonsterParticipant`
- `generateId`, `rollD20`, `formatInitMod`
- `sortParticipantsByInitiative`, `syncTurnOrder`, `computeTurnOrderAfterRemoval`
- `snapshotOf`, `buildEntry`
### Display lifecycle (async, no combat log)
- `activateDisplay({campaignId, encounterId}, ctx)` → void
- `clearDisplay(ctx)` → void
- `toggleHidePlayerHp(currentValue, ctx)` → void
## Test helper: shared/tests/_helpers.js
```js
const { createMockStorage, mockCtx, readyEnc } = require('./_helpers');
const { storage, ctx } = mockCtx(); // ctx = {storage, encPath:'encounters/e1', logPath:'logs', displayPath:'activeDisplay/status'}
const enc = readyEnc(); // {id:'e1', name:'TestEnc', round:0, ... 2 participants p1(Bob,init10) p2(Mob,init5)}
```
### Mock storage methods
- `storage.calls` — array of `{fn, path, data}` ordered
- `storage.logs()` — array of log entries written (addDoc data)
- `storage.updatesFor(prefix)` — updateDoc calls matching path prefix
- `storage.docs` — Map path→data
## Transform patterns
### Pattern 1: state assertion
```js
// OLD
const r = startEncounter(enc);
expect(r.patch.round).toBe(1);
// NEW
const newEnc = await startEncounter(enc, ctx);
expect(newEnc.round).toBe(1);
```
### Pattern 2: log assertion
```js
// OLD
const r = startEncounter(enc);
expect(r.log.type).toBe('start_encounter');
// NEW
const newEnc = await startEncounter(enc, ctx);
expect(storage.logs()).toHaveLength(1);
expect(storage.logs()[0].type).toBe('start_encounter');
```
### Pattern 3: chained calls (sequence)
```js
// OLD
let enc = readyEnc();
enc = { ...enc, ...startEncounter(enc).patch };
enc = { ...enc, ...nextTurn(enc).patch };
// NEW
let enc = readyEnc();
enc = await startEncounter(enc, ctx);
enc = await nextTurn(enc, ctx);
```
### Pattern 4: no-op
```js
// OLD (returned {patch:null, log:null})
const r = applyHpChange(enc, 'p1', 'damage', 0);
expect(r.patch).toBeNull();
// NEW (returns enc unchanged, no write)
const newEnc = await applyHpChange(enc, 'p1', 'damage', 0, ctx);
expect(newEnc).toBe(enc); // same ref = no write
expect(storage.calls.filter(c => c.fn === 'updateDoc')).toHaveLength(0);
```
### Pattern 5: deathSave (returns object now)
```js
// OLD
const r = deathSave(enc, 'p1', 'success', 1);
expect(r.status).toBe('pending');
// NEW
const { enc: newEnc, status, isDying } = await deathSave(enc, 'p1', 'success', 1, ctx);
expect(status).toBe('pending');
```
## Static guards
- `static.no-sort.test.js` — KEEP. Still valid (scans turn.js for stray `.sort(`).
- `static.no-unlogged.test.js` — DELETE. Old `{patch, log}` shape gone. Every func writes log internally via `commit()`. No way to "forget".
- `static.eslint.test.js` — KEEP.
## Constraints
- KEEP test names (describe/it blocks) — same coverage intent.
- KEEP assertions — same values checked, just via newEnc or storage.logs().
- Fresh `mockCtx()` per test (isolation).
- For seeded-RNG combat tests: keep the LCG seed + rand helpers, just swap call pattern.
- `await` every mutating func call.
+149
View File
@@ -0,0 +1,149 @@
// Test helper: in-memory mock storage. Records all writes (addDoc/updateDoc).
// Stores docs so getDoc/getCollection return real state. undo() actually
// applies patches + flips log `undone` flag (mirrors firebase/server contract).
function createMockStorage() {
const docs = new Map(); // path -> data
const calls = []; // ordered {fn, path, data}
const applyPatch = (path, patch) => {
if (!docs.has(path)) docs.set(path, {});
const cur = docs.get(path);
// Firestore shallow-merge: arrays replaced, not merged. Patch arrays are
// the FULL new value (participants, turnOrderIds, conditions).
docs.set(path, { ...cur, ...patch });
};
return {
calls,
docs,
async getDoc(path) { return docs.has(path) ? { ...docs.get(path) } : null; },
async setDoc(path, data) {
calls.push({ fn: 'setDoc', path, data });
docs.set(path, { ...data });
},
async addDoc(path, data) {
calls.push({ fn: 'addDoc', path, data });
// Persist log entry so getCollection + undo can read it back.
const id = data.id || `log-${calls.length}`;
const stored = { ...data, id, _path: `${path}/${id}` };
docs.set(`${path}/${id}`, stored);
return id;
},
async updateDoc(path, patch) {
calls.push({ fn: 'updateDoc', path, data: patch });
applyPatch(path, patch);
},
async deleteDoc(path) {
calls.push({ fn: 'deleteDoc', path });
docs.delete(path);
},
async getCollection(path, queryConstraints = []) {
// Return docs whose _path starts with `${path}/` (Firestore collection).
const out = [];
for (const [p, d] of docs.entries()) {
if (p.startsWith(`${path}/`)) out.push({ ...d });
}
// Best-effort query support: where/orderBy/limit.
let filtered = out;
for (const qc of queryConstraints) {
if (!filtered.length) break;
const op = qc[0];
if (op === 'where') {
const [, field, , value] = qc;
filtered = filtered.filter(d => d[field] === value);
} else if (op === 'orderBy') {
const [, field, dir = 'asc'] = qc;
filtered.sort((a, b) => {
const av = a[field], bv = b[field];
if (av < bv) return dir === 'asc' ? -1 : 1;
if (av > bv) return dir === 'asc' ? 1 : -1;
return 0;
});
} else if (op === 'limit') {
filtered = filtered.slice(0, qc[1]);
}
}
return filtered;
},
async batchWrite(ops) {
for (const op of ops) {
if (op.type === 'delete') await this.deleteDoc(op.path);
else await this.updateDoc(op.path, op.data);
}
},
// Transactional undo — mirrors firebase.js / server.js contract:
// redo=false: apply undo.updates to encounterPath, flip log undone=true
// redo=true: apply undo.redo to encounterPath, flip log undone=false
async undo({ logPath, undo, redo = false }) {
calls.push({ fn: 'undo', path: logPath, data: { undo, redo } });
const patch = redo ? (undo.redo || undo.updates) : undo.updates;
applyPatch(undo.encounterPath, patch);
if (docs.has(logPath)) {
const log = docs.get(logPath);
docs.set(logPath, { ...log, undone: !redo });
}
},
// filters
updatesFor(prefix) { return calls.filter(c => c.fn === 'updateDoc' && c.path.startsWith(prefix)); },
logs() { return calls.filter(c => c.fn === 'addDoc').map(c => c.data); },
getLogs() { return calls.filter(c => c.fn === 'addDoc').map(c => c.data); },
};
}
// Undo/redo harness: apply op via shared func (writes enc doc + log) then
// exercise the REAL undo mechanism (storage.undo applies patch + flips flag).
// Reads doc back so assertions reflect actual persisted state.
// applyThenUndo(ctx, encAfterOp) -> enc after undo applied
// applyThenRedo(ctx, encAfterUndo, lastLogId) -> enc after redo applied
async function undoLast(ctx, encAfterOp) {
const { storage, encPath, logPath } = ctx;
const last = storage.logs()[storage.logs().length - 1];
const { expandUndo } = require('./..');
const expanded = expandUndo(last, encAfterOp);
if (!expanded) throw new Error('expandUndo returned null');
const logEntryPath = `${logPath}/${last.id}`;
await storage.undo({ logPath: logEntryPath, undo: expanded, redo: false });
return await storage.getDoc(encPath);
}
async function redoLast(ctx, encAfterUndo, last) {
const { storage, encPath, logPath } = ctx;
const { expandUndo } = require('./..');
const expanded = expandUndo(last, encAfterUndo);
if (!expanded) throw new Error('expandUndo returned null (redo)');
const logEntryPath = `${logPath}/${last.id}`;
await storage.undo({ logPath: logEntryPath, undo: expanded, redo: true });
return await storage.getDoc(encPath);
}
// Standard ctx for tests. encPath + logPath + displayPath preset.
// Returns { storage, ctx } so tests destructure both in one call:
// const { storage, ctx } = mockCtx();
function mockCtx(storage) {
storage = storage || createMockStorage();
const ctx = {
storage,
encPath: 'encounters/e1',
logPath: 'logs',
displayPath: 'activeDisplay/status',
};
return { storage, ctx };
}
// Standard 2-participant ready encounter.
function readyEnc(opts = {}) {
return {
id: 'e1',
name: opts.name || 'TestEnc',
round: 0,
isStarted: false,
isPaused: false,
currentTurnParticipantId: null,
turnOrderIds: [],
participants: opts.participants || [
{ id: 'p1', name: 'Bob', type: 'character', initiative: 10, maxHp: 10, currentHp: 10, isActive: true, conditions: [], status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0 },
{ id: 'p2', name: 'Mob', type: 'monster', initiative: 5, maxHp: 10, currentHp: 10, isActive: true, conditions: [], status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0 },
],
};
}
module.exports = { createMockStorage, mockCtx, readyEnc, undoLast, redoLast };
+81
View File
@@ -0,0 +1,81 @@
// Test logEvent normalizer — canonical shape for UI/download/replay/analyze.
// New design: legacy logs (no type) DROPPED. Not in pipeline. Kept only for
// human scroll in LogsView (raw). download/copy/replay/analyze = typed only.
const { normalizeEvent, serializeEvents } = require('../logEvent.js');
describe('logEvent normalizer', () => {
test('new schema passes through', () => {
const raw = {
id: 'l1', ts: 1000, type: 'next_turn', message: 'Bob turn',
encounterId: 'e1', encounterName: 'Enc', encounterPath: 'encounters/e1',
participantId: 'p1', participantName: 'Bob',
delta: { wrapped: true, prevRound: 1 },
undo: { currentTurnParticipantId: 'p0', round: 1, turnOrderIds: ['p0','p1'] },
undone: false, snapshot: { round: 2, currentTurnParticipantId: 'p1', turnOrderIds: ['p1'], activeIds: ['p1'] },
};
const e = normalizeEvent(raw);
expect(e.id).toBe('l1');
expect(e.type).toBe('next_turn');
expect(e.snapshot.round).toBe(2);
expect(e.delta.wrapped).toBe(true);
expect(e.undo.round).toBe(1);
expect(e.participantName).toBe('Bob');
});
test('old bloated schema (payload/undo_payload) lifts undo, drops payload', () => {
const raw = {
id: 'l1b', ts: 1100, type: 'damage', message: 'dmg',
encounterPath: 'encounters/e1',
payload: { participants: [{ id: 'p1', currentHp: 30 }] },
undo_payload: { encounterPath: 'encounters/e1', updates: { round: 1 }, redo: { round: 2 } },
snapshot: { round: 2, currentTurnParticipantId: 'p1', turnOrderIds: ['p1'], activeIds: ['p1'] },
};
const e = normalizeEvent(raw);
expect(e.type).toBe('damage');
expect(e.undo).toBeTruthy();
expect(e.delta).toBeNull();
});
test('legacy (no type) dropped → null', () => {
const raw = { id: 'l2', timestamp: 500, message: 'old', encounterName: 'Enc', undo: { encounterPath: 'enc/e1', updates: {} } };
expect(normalizeEvent(raw)).toBeNull();
});
test('explicit unknown type dropped → null', () => {
expect(normalizeEvent({ id: 'l3', type: 'unknown' })).toBeNull();
});
test('no type at all dropped → null', () => {
expect(normalizeEvent({ id: 'l4' })).toBeNull();
expect(normalizeEvent({ path: 'logs/abc' })).toBeNull();
});
test('null in null out', () => {
expect(normalizeEvent(null)).toBeNull();
});
test('serialize drops all-legacy input → empty', () => {
const raw = [
{ id: 'c', timestamp: 300 },
{ id: 'a', timestamp: 100 },
{ id: 'b', timestamp: 200 },
];
expect(JSON.parse(serializeEvents(raw))).toEqual([]);
});
test('serialize sorts typed events ascending by ts', () => {
const raw = [
{ id: 'c', type: 'next_turn', ts: 300 },
{ id: 'a', type: 'start_encounter', ts: 100 },
{ id: 'b', type: 'damage', ts: 200 },
];
const arr = JSON.parse(serializeEvents(raw));
expect(arr.map(e => e.id)).toEqual(['a', 'b', 'c']);
});
test('serialize filters nulls + legacy, keeps typed', () => {
const arr = JSON.parse(serializeEvents([null, { id: 'x', type: 'damage' }, { id: 'legacy' }, null]));
expect(arr).toHaveLength(1);
expect(arr[0].id).toBe('x');
});
});
+56
View File
@@ -0,0 +1,56 @@
// STATIC GUARD: prod source must pass eslint with zero errors/warnings.
// Scans App.js + storage adapters + shared modules. Catches unused imports,
// dead code, undefined vars before they hit the browser console.
// Run via: npx eslint <files> --format json -> parse -> fail on any problem.
const { execSync } = require('child_process');
const path = require('path');
const ROOT = path.resolve(__dirname, '..', '..');
const TARGETS = [
'src/App.js',
'src/storage/contract.js',
'src/storage/firebase.js',
'src/storage/index.js',
'src/storage/server.js',
'shared/index.js',
'shared/logEvent.js',
'shared/turn.js',
];
function runEslint(files) {
const abs = files.map(f => `"${path.join(ROOT, f)}"`).join(' ');
// execSync throws on non-zero exit (eslint returns 1 when problems found).
// Capture stdout regardless.
let stdout;
try {
stdout = execSync(`npx eslint ${abs} --format json`, {
encoding: 'utf8',
cwd: ROOT,
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch (err) {
stdout = err.stdout || '';
}
return JSON.parse(stdout || '[]');
}
describe('static guard: eslint clean on prod source', () => {
test('zero eslint errors or warnings', () => {
const results = runEslint(TARGETS);
const problems = [];
let totalErrors = 0;
let totalWarnings = 0;
for (const file of results) {
totalErrors += file.errorCount;
totalWarnings += file.warningCount;
for (const msg of file.messages) {
problems.push(` ${path.relative(ROOT, file.filePath)}:${msg.line}:${msg.column} ${msg.ruleId}${msg.message}`);
}
}
if (totalErrors + totalWarnings > 0) {
const summary = `eslint found ${totalErrors} error(s), ${totalWarnings} warning(s):\n${problems.join('\n')}`;
throw new Error(summary);
}
}, 30000); // eslint spawn can be slow
});
+70
View File
@@ -0,0 +1,70 @@
// STATIC GUARD: no `.sort(` introduced in shared/turn.js outside the ONE
// allowed function (sortParticipantsByInitiative, used by startEncounter to
// freeze the list once). Slot-not-sort design (docs/INITIATIVE_ORDERING.md):
// mutations = insert/move, never wholesale re-sort. A stray `.sort(` after
// start destroys drag tie-break order.
//
// This test errs the moment someone adds `.sort(` anywhere but the allowlist.
// Maintenance: add a function to ALLOWED only if it runs at startEncounter
// (one-time freeze), NOT in add/update/reorder/nextTurn paths.
'use strict';
const fs = require('fs');
const path = require('path');
const SRC = fs.readFileSync(path.join(__dirname, '..', 'turn.js'), 'utf8');
// Functions permitted to call .sort(. Listed so intent is explicit + reviewed.
const ALLOWED_SORT_FUNCTIONS = new Set([
'sortParticipantsByInitiative',
]);
// Collect top-level function bodies by brace counting.
// Matches `function NAME(` decls AND `const NAME = ... =>` arrow fns.
// Returns [{ name, body }].
function collectBody(src, fromIdx) {
let i = fromIdx;
while (i < src.length && src[i] !== '{') i++;
let depth = 0;
const start = i;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') { depth--; if (depth === 0) break; }
}
return src.slice(start, i + 1);
}
function extractFunctions(src) {
const out = [];
const fnRe = /\bfunction\s+([A-Za-z0-9_$]+)\s*\(/g;
const arrowRe = /(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*[^=;]*?=>/g;
let m;
while ((m = fnRe.exec(src)) !== null) {
out.push({ name: m[1], body: collectBody(src, m.index + m[0].length) });
}
while ((m = arrowRe.exec(src)) !== null) {
out.push({ name: m[1], body: collectBody(src, m.index + m[0].length) });
}
return out;
}
describe('STATIC: no .sort( outside allowlist (slot-not-sort design)', () => {
test('every .sort( call lives in an allowed function', () => {
const fns = extractFunctions(SRC);
const offenders = [];
for (const { name, body } of fns) {
if (!ALLOWED_SORT_FUNCTIONS.has(name) && /\.sort\(/.test(body)) {
offenders.push(name);
}
}
expect(offenders).toEqual([]);
});
test('allowed sort function is declared (no silent allowlist drift)', () => {
const declared = new Set(extractFunctions(SRC).map(f => f.name));
for (const name of ALLOWED_SORT_FUNCTIONS) {
expect(declared.has(name)).toBe(true);
}
});
});
+86
View File
@@ -0,0 +1,86 @@
// BUG-10: deact+reactivate same round double-acts participant.
// OLD bug: reactivation re-inserted by initiative (front of pointer) →
// participant acted again before round end.
//
// 1-list model: toggleActive keeps slot position. Reactivation does NOT
// grant second turn. Each participant acts at most once per round.
//
// Test: walk rounds, count visits. Deactivate+reactivate mid-round must
// not cause any participant to act twice in same round.
//
// New API: mutating funcs are async, take ctx, return newEnc.
'use strict';
const {
makeParticipant,
startEncounter, nextTurn, toggleParticipantActive,
} = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers');
function p(id, init) {
return makeParticipant({ id, name: id, type: 'monster',
initiative: init, maxHp: 100, currentHp: 100 });
}
function enc(ps) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
}
describe('BUG-10: deact+reactivate same round', () => {
test('no participant acts twice in a round after deact+reactivate', async () => {
const { ctx } = mockCtx();
// [a(10), b(7), c(3)]
let e = enc([p('a',10),p('b',7),p('c',3)]);
e = await startEncounter(e, ctx); // a current, r1
const r1 = [];
r1.push(e.currentTurnParticipantId); // a acts
e = await nextTurn(e, ctx); r1.push(e.currentTurnParticipantId); // b acts
// b already acted and is still current. Deactivate b: status edit only,
// no turn advance. DM clicks Next Turn explicitly; nextTurn skips inactive b.
e = await toggleParticipantActive(e, 'b', ctx); // b off, current still b
expect(e.currentTurnParticipantId).toBe('b');
e = await nextTurn(e, ctx); // skips inactive b → c
r1.push(e.currentTurnParticipantId);
// reactivate b same round; b must not re-act before round wraps.
e = await toggleParticipantActive(e, 'b', ctx); // b on
// nextTurn from c → round 2 (a). b must NOT re-act in round 1.
e = await nextTurn(e, ctx);
expect(e.round).toBe(2);
expect(e.currentTurnParticipantId).toBe('a');
// b acted once in r1, must act once in r2
e = await nextTurn(e, ctx);
expect(e.currentTurnParticipantId).toBe('b');
e = await nextTurn(e, ctx);
expect(e.currentTurnParticipantId).toBe('c');
// per-round visit count
const bCountR1 = r1.filter(id => id === 'b').length;
expect(bCountR1).toBe(1);
});
test('deactivate+reactivate non-current who already acted: no re-act', async () => {
const { ctx } = mockCtx();
// [a(10), b(7), c(3)]. a acts, b acts, c acts (round 1 done).
// Then deactivate a (already acted, not current since c is current).
// Reactivate a. a must not act again until round 2.
let e = enc([p('a',10),p('b',7),p('c',3)]);
e = await startEncounter(e, ctx); // a
e = await nextTurn(e, ctx); // b
e = await nextTurn(e, ctx); // c (r1 full: a,b,c)
// c is current. deactivate a (not current, already acted).
e = await toggleParticipantActive(e, 'a', ctx); // a off, pointer stays c
expect(e.currentTurnParticipantId).toBe('c');
e = await toggleParticipantActive(e, 'a', ctx); // a on
expect(e.currentTurnParticipantId).toBe('c');
// nextTurn from c → a (round 2). a acts in r2, once.
e = await nextTurn(e, ctx);
expect(e.round).toBe(2);
expect(e.currentTurnParticipantId).toBe('a');
});
});
+79
View File
@@ -0,0 +1,79 @@
// BUG-13: cross-pointer reorder blocked (skip/double-act otherwise).
// nextTurn walks turnOrderIds forward from current pointer. Dragging across
// pointer = ambiguous who-acts-next:
// - upcoming dragged ahead of pointer → walk wraps past → SKIPPED
// - acted dragged behind pointer → acts again → DOUBLE-ACT
// Full fix needs actedThisRound tracking. Pragmatic now: block both dirs
// during active encounter. Pre-combat: free reorder (no pointer).
//
// New API: reorderParticipants is async, takes ctx, returns newEnc.
// Blocked drag = no-op (returns same enc ref, no write).
'use strict';
const {
makeParticipant,
startEncounter, nextTurn, reorderParticipants,
} = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers');
function p(id, init) {
return makeParticipant({ id, name: id, type: 'monster',
initiative: init, maxHp: 100, currentHp: 100 });
}
function enc(ps) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
}
describe('BUG-13: cross-pointer reorder blocked during active encounter', () => {
test('dragging not-yet-acted participant ahead of pointer = no-op', async () => {
const { ctx } = mockCtx();
// [a(10), b(10), c(10)]. a acts -> b current. c not yet acted.
let e = enc([p('a',10),p('b',10),p('c',10)]);
e = await startEncounter(e, ctx); // a (idx0, pointer)
e = await nextTurn(e, ctx); // b (idx1, pointer)
expect(e.currentTurnParticipantId).toBe('b');
// Drag c (idx2, behind pointer) before b (idx1, pointer). Crosses pointer.
// Would let c act by initiative-reinsert but nextTurn forward-walk skips.
// Blocked instead.
const newEnc = await reorderParticipants(e, 'c', 'b', ctx);
expect(newEnc).toBe(e); // no-op: same ref, no write
expect(e.turnOrderIds).toEqual(['a','b','c']); // unchanged
});
test('dragging already-acted participant behind pointer = no-op', async () => {
const { ctx } = mockCtx();
// [a(10), b(10), c(10)]. a acts (idx0, ahead of pointer).
let e = enc([p('a',10),p('b',10),p('c',10)]);
e = await startEncounter(e, ctx); // a (pointer idx0)
e = await nextTurn(e, ctx); // b (pointer idx1)
// Drag a (already acted, ahead of pointer) after c (behind pointer).
// Crosses pointer. Would let a act again. Blocked.
const newEnc = await reorderParticipants(e, 'a', 'c', ctx);
expect(newEnc).toBe(e); // no-op: same ref, no write
});
test('reorder within same side of pointer = allowed', async () => {
const { storage, ctx } = mockCtx();
// [a(10), b(10), c(10), d(10)]. a current (pointer idx0).
// b,c,d all behind pointer (upcoming). Reorder among them = safe.
let e = enc([p('a',10),p('b',10),p('c',10),p('d',10)]);
e = await startEncounter(e, ctx); // a (idx0)
// swap b,c (both idx1,2 — behind pointer). No cross.
const newEnc = await reorderParticipants(e, 'c', 'b', ctx);
// startEncounter logs too; assert the reorder write specifically.
expect(storage.logs().filter(l => l.type === 'reorder')).toHaveLength(1);
expect(newEnc.participants.map(p => p.id)).toEqual(['a','c','b','d']);
});
test('pre-combat: free reorder (no pointer)', async () => {
const { storage, ctx } = mockCtx();
// isStarted=false. No pointer. Reorder allowed freely.
let e = enc([p('a',10),p('b',10),p('c',10)]);
const newEnc = await reorderParticipants(e, 'c', 'a', ctx);
expect(storage.logs().filter(l => l.type === 'reorder')).toHaveLength(1);
expect(newEnc.participants.map(p => p.id)).toEqual(['c','a','b']);
});
});
+61
View File
@@ -0,0 +1,61 @@
// BUG-7: reorderParticipants not logged.
// Every mutating func is now async, takes ctx last, and writes encounter +
// log internally via commit(). reorderParticipants must emit a log entry on a
// real move (so the drag shows up in the combat log + carries an undo payload)
// and stay a silent no-op (no write) when blocked.
//
// RED was: returned { patch, log } with log:null → handler skipped logAction.
// Now: a successful reorder writes exactly one log entry; no-ops write none.
'use strict';
const { makeParticipant, reorderParticipants, expandUndo } = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers');
function p(id, init) {
return makeParticipant({ id, name: id, type: 'monster',
initiative: init, maxHp: 100, currentHp: 100 });
}
function enc(ps) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
}
describe('BUG-7: reorderParticipants logged', () => {
test('returns log object (not null) with message + undo', async () => {
const { storage, ctx } = mockCtx();
const e = enc([p('a', 10), p('b', 10), p('c', 10)]);
await reorderParticipants(e, 'c', 'b', ctx);
expect(storage.logs()).toHaveLength(1);
const entry = storage.logs()[0];
expect(typeof entry.message).toBe('string');
expect(entry.message.length).toBeGreaterThan(0);
expect(entry.undo).toBeDefined();
});
test('undo restores original participants order', async () => {
const { storage, ctx } = mockCtx();
const e = enc([p('a', 10), p('b', 10), p('c', 10)]);
const orig = e.participants.map(p => p.id);
await reorderParticipants(e, 'c', 'b', ctx);
const entry = storage.logs()[0];
const restored = { ...e, ...expandUndo(entry, e).updates };
expect(restored.participants.map(p => p.id)).toEqual(orig);
});
test('no-op (same id) returns null log', async () => {
const { storage, ctx } = mockCtx();
const e = enc([p('a', 10), p('b', 10)]);
const newEnc = await reorderParticipants(e, 'a', 'a', ctx);
expect(newEnc).toBe(e); // same ref = no write
expect(storage.logs()).toHaveLength(0);
});
test('no-op (cross-init blocked) returns null log', async () => {
const { storage, ctx } = mockCtx();
const e = enc([p('a', 10), p('b', 5)]);
const newEnc = await reorderParticipants(e, 'a', 'b', ctx);
expect(newEnc).toBe(e); // same ref = no write
expect(storage.logs()).toHaveLength(0);
});
});
+168 -148
View File
@@ -1,12 +1,15 @@
// Characterization tests for shared/turn.js. // Characterization tests for shared/turn.js.
// Lock CURRENT behavior (bugs included). M3 will extend, M4 will fix. // Lock CURRENT behavior (bugs included). M3 will extend, M4 will fix.
// These tests assert what the code does NOW, not what it SHOULD do. // These tests assert what the code does NOW, not what it SHOULD do.
//
// New API: every mutating func is async, takes ctx last, writes encounter +
// log internally, returns newEnc. We assert against newEnc (merged state) or
// the raw persisted patch (storage.calls) for "field not written" checks.
const shared = require('@ttrpg/shared'); const shared = require('@ttrpg/shared');
const { const {
sortParticipantsByInitiative, sortParticipantsByInitiative,
computeTurnOrderAfterRemoval, computeTurnOrderAfterRemoval,
computeTurnOrderAfterAddition,
startEncounter, startEncounter,
nextTurn, nextTurn,
togglePause, togglePause,
@@ -20,6 +23,7 @@ const {
endEncounter, endEncounter,
makeParticipant, makeParticipant,
} = shared; } = shared;
const { mockCtx } = require('./_helpers');
// Helper: minimal encounter with given participants. // Helper: minimal encounter with given participants.
function enc(participants = [], extra = {}) { function enc(participants = [], extra = {}) {
@@ -43,6 +47,13 @@ function p(id, initiative, extra = {}) {
}); });
} }
// Last updateDoc patch written to storage — for "field absent from patch"
// assertions (field === undefined means func didn't write it).
const lastPatch = (storage) => {
const u = storage.calls.filter(c => c.fn === 'updateDoc').pop();
return u ? u.data : {};
};
describe('sortParticipantsByInitiative', () => { describe('sortParticipantsByInitiative', () => {
test('higher initiative first', () => { test('higher initiative first', () => {
const ps = [p('a', 5), p('b', 15), p('c', 10)]; const ps = [p('a', 5), p('b', 15), p('c', 10)];
@@ -58,44 +69,51 @@ describe('sortParticipantsByInitiative', () => {
}); });
describe('startEncounter', () => { describe('startEncounter', () => {
test('throws if no participants', () => { test('throws if no participants', async () => {
expect(() => startEncounter(enc([]))).toThrow('participants'); const { ctx } = mockCtx();
await expect(startEncounter(enc([]), ctx)).rejects.toThrow('participants');
}); });
test('throws if no active participants', () => { test('throws if no active participants', async () => {
const { ctx } = mockCtx();
const e = enc([p('a', 10, { isActive: false })]); const e = enc([p('a', 10, { isActive: false })]);
expect(() => startEncounter(e)).toThrow('active'); await expect(startEncounter(e, ctx)).rejects.toThrow('active');
}); });
test('sets round 1, turn order sorted, current = highest init', () => { test('sets round 1, turn order sorted, current = highest init', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 5), p('b', 15), p('c', 10)]; const ps = [p('a', 5), p('b', 15), p('c', 10)];
const e = enc(ps); const e = enc(ps);
const { patch } = startEncounter(e); const newEnc = await startEncounter(e, ctx);
expect(patch.isStarted).toBe(true); expect(newEnc.isStarted).toBe(true);
expect(patch.round).toBe(1); expect(newEnc.round).toBe(1);
expect(patch.turnOrderIds).toEqual(['b', 'c', 'a']); expect(newEnc.turnOrderIds).toEqual(['b', 'c', 'a']);
expect(patch.currentTurnParticipantId).toBe('b'); expect(newEnc.currentTurnParticipantId).toBe('b');
}); });
test('inactive stays in turn order slot (1-list model)', () => { test('inactive stays in turn order slot (1-list model)', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 5), p('b', 15, { isActive: false }), p('c', 10)]; const ps = [p('a', 5), p('b', 15, { isActive: false }), p('c', 10)];
const { patch } = startEncounter(enc(ps)); const newEnc = await startEncounter(enc(ps), ctx);
// 1-list: all participants sorted by init (active+inactive), inactive stays in slot // 1-list: all participants sorted by init (active+inactive), inactive stays in slot
expect(patch.turnOrderIds).toEqual(['b', 'c', 'a']); expect(newEnc.turnOrderIds).toEqual(['b', 'c', 'a']);
expect(patch.currentTurnParticipantId).toBe('c'); // b inactive, skipped expect(newEnc.currentTurnParticipantId).toBe('c'); // b inactive, skipped
}); });
}); });
describe('nextTurn', () => { describe('nextTurn', () => {
test('throws if not started', () => { test('throws if not started', async () => {
expect(() => nextTurn(enc([p('a', 10)], { isStarted: false }))).toThrow(); const { ctx } = mockCtx();
await expect(nextTurn(enc([p('a', 10)], { isStarted: false }), ctx)).rejects.toThrow();
}); });
test('throws if paused', () => { test('throws if paused', async () => {
expect(() => nextTurn(enc([p('a', 10)], { isStarted: true, isPaused: true, currentTurnParticipantId: 'a', turnOrderIds: ['a'] }))).toThrow(); const { ctx } = mockCtx();
await expect(nextTurn(enc([p('a', 10)], { isStarted: true, isPaused: true, currentTurnParticipantId: 'a', turnOrderIds: ['a'] }), ctx)).rejects.toThrow();
}); });
test('advances to next in order, no round bump', () => { test('advances to next in order, no round bump', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 5), p('b', 15), p('c', 10)]; const ps = [p('a', 5), p('b', 15), p('c', 10)];
const e = enc(ps, { const e = enc(ps, {
isStarted: true, isStarted: true,
@@ -103,12 +121,13 @@ describe('nextTurn', () => {
currentTurnParticipantId: 'b', currentTurnParticipantId: 'b',
turnOrderIds: ['b', 'c', 'a'], turnOrderIds: ['b', 'c', 'a'],
}); });
const { patch } = nextTurn(e); const newEnc = await nextTurn(e, ctx);
expect(patch.currentTurnParticipantId).toBe('c'); expect(newEnc.currentTurnParticipantId).toBe('c');
expect(patch.round).toBe(1); expect(newEnc.round).toBe(1);
}); });
test('wraps round when last in order', () => { test('wraps round when last in order', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 5), p('b', 15), p('c', 10)]; const ps = [p('a', 5), p('b', 15), p('c', 10)];
const e = enc(ps, { const e = enc(ps, {
isStarted: true, isStarted: true,
@@ -116,12 +135,13 @@ describe('nextTurn', () => {
currentTurnParticipantId: 'a', currentTurnParticipantId: 'a',
turnOrderIds: ['b', 'c', 'a'], turnOrderIds: ['b', 'c', 'a'],
}); });
const { patch } = nextTurn(e); const newEnc = await nextTurn(e, ctx);
expect(patch.currentTurnParticipantId).toBe('b'); expect(newEnc.currentTurnParticipantId).toBe('b');
expect(patch.round).toBe(2); expect(newEnc.round).toBe(2);
}); });
test('ends encounter if no active participants', () => { test('ends encounter if no active participants', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10, { isActive: false })]; const ps = [p('a', 10, { isActive: false })];
const e = enc(ps, { const e = enc(ps, {
isStarted: true, isStarted: true,
@@ -129,186 +149,204 @@ describe('nextTurn', () => {
currentTurnParticipantId: 'a', currentTurnParticipantId: 'a',
turnOrderIds: ['a'], turnOrderIds: ['a'],
}); });
const { patch } = nextTurn(e); const newEnc = await nextTurn(e, ctx);
expect(patch.isStarted).toBe(false); expect(newEnc.isStarted).toBe(false);
expect(patch.currentTurnParticipantId).toBe(null); expect(newEnc.currentTurnParticipantId).toBe(null);
}); });
}); });
describe('togglePause', () => { describe('togglePause', () => {
test('pauses started encounter', () => { test('pauses started encounter', async () => {
const { ctx } = mockCtx();
const e = enc([p('a', 10)], { isStarted: true, isPaused: false }); const e = enc([p('a', 10)], { isStarted: true, isPaused: false });
const { patch } = togglePause(e); const newEnc = await togglePause(e, ctx);
expect(patch.isPaused).toBe(true); expect(newEnc.isPaused).toBe(true);
}); });
test('resume preserves turn order (no re-sort)', () => { test('resume preserves turn order (no re-sort)', async () => {
// BUG-5 fix: resume no longer re-sorts. Re-sort displaced current pointer // BUG-5 fix: resume no longer re-sorts. Re-sort displaced current pointer
// and caused skips. Order frozen at startEncounter, patched incrementally. // and caused skips. Order frozen at startEncounter, patched incrementally.
const { ctx } = mockCtx();
const ps = [p('a', 5), p('b', 15)]; const ps = [p('a', 5), p('b', 15)];
const e = enc(ps, { isStarted: true, isPaused: true, turnOrderIds: ['a', 'b'] }); const e = enc(ps, { isStarted: true, isPaused: true, turnOrderIds: ['a', 'b'] });
const { patch } = togglePause(e); const newEnc = await togglePause(e, ctx);
expect(patch.isPaused).toBe(false); expect(newEnc.isPaused).toBe(false);
expect(patch.turnOrderIds).toEqual(['a', 'b']); expect(newEnc.turnOrderIds).toEqual(['a', 'b']);
}); });
}); });
describe('removeParticipant', () => { describe('removeParticipant', () => {
test('removes from participants array', () => { test('removes from participants array', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10), p('b', 5)]; const ps = [p('a', 10), p('b', 5)];
const { patch } = removeParticipant(enc(ps), 'a'); const newEnc = await removeParticipant(enc(ps), 'a', ctx);
expect(patch.participants.map(x => x.id)).toEqual(['b']); expect(newEnc.participants.map(x => x.id)).toEqual(['b']);
}); });
test('not started: no turn order mutation', () => { test('not started: no turn order mutation', async () => {
const { storage, ctx } = mockCtx();
const ps = [p('a', 10), p('b', 5)]; const ps = [p('a', 10), p('b', 5)];
const { patch } = removeParticipant(enc(ps), 'a'); await removeParticipant(enc(ps), 'a', ctx);
expect(patch.turnOrderIds).toBeUndefined(); expect(lastPatch(storage).turnOrderIds).toBeUndefined();
}); });
test('started: removes from turnOrderIds', () => { test('started: removes from turnOrderIds', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10), p('b', 5)]; const ps = [p('a', 10), p('b', 5)];
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'b' }); const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'b' });
const { patch } = removeParticipant(e, 'a'); const newEnc = await removeParticipant(e, 'a', ctx);
expect(patch.turnOrderIds).toEqual(['b']); expect(newEnc.turnOrderIds).toEqual(['b']);
}); });
test('started: removing current picks next active', () => { test('started: removing current picks next active', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10), p('b', 5), p('c', 3)]; const ps = [p('a', 10), p('b', 5), p('c', 3)];
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b', 'c'], currentTurnParticipantId: 'a' }); const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b', 'c'], currentTurnParticipantId: 'a' });
const { patch } = removeParticipant(e, 'a'); const newEnc = await removeParticipant(e, 'a', ctx);
expect(patch.currentTurnParticipantId).toBe('b'); expect(newEnc.currentTurnParticipantId).toBe('b');
}); });
}); });
describe('toggleParticipantActive', () => { describe('toggleParticipantActive', () => {
test('deactivates participant', () => { test('deactivates participant', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10, { isActive: true })]; const ps = [p('a', 10, { isActive: true })];
const { patch } = toggleParticipantActive(enc(ps), 'a'); const newEnc = await toggleParticipantActive(enc(ps), 'a', ctx);
expect(patch.participants[0].isActive).toBe(false); expect(newEnc.participants[0].isActive).toBe(false);
}); });
test('started: deactivating current advances turn', () => { test('started: deactivating current does not advance turn or round', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10), p('b', 5)]; const ps = [p('a', 10), p('b', 5)];
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' }); const e = enc(ps, { isStarted: true, round: 1, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' });
const { patch } = toggleParticipantActive(e, 'a'); const newEnc = await toggleParticipantActive(e, 'a', ctx);
expect(patch.currentTurnParticipantId).toBe('b'); expect(newEnc.currentTurnParticipantId).toBe('a');
expect(newEnc.round).toBe(1);
expect(newEnc.participants.find(p => p.id === 'a').isActive).toBe(false);
}); });
test('started: reactivating inserts by initiative', () => { test('started: reactivating inserts by initiative', async () => {
// BUG-5 fix: reactivated participant slots by initiative (not appended // BUG-5 fix: reactivated participant slots by initiative (not appended
// to end). Preserves correct rotation order. // to end). Preserves correct rotation order.
const { ctx } = mockCtx();
const ps = [p('a', 10, { isActive: false }), p('b', 5)]; const ps = [p('a', 10, { isActive: false }), p('b', 5)];
const e = enc(ps, { isStarted: true, turnOrderIds: ['b'], currentTurnParticipantId: 'b' }); const e = enc(ps, { isStarted: true, turnOrderIds: ['b'], currentTurnParticipantId: 'b' });
const { patch } = toggleParticipantActive(e, 'a'); const newEnc = await toggleParticipantActive(e, 'a', ctx);
// a init=10 > b init=5 → a slots before b // a init=10 > b init=5 → a slots before b
expect(patch.turnOrderIds).toEqual(['a', 'b']); expect(newEnc.turnOrderIds).toEqual(['a', 'b']);
}); });
}); });
describe('applyHpChange', () => { describe('applyHpChange', () => {
test('damage reduces hp, clamps 0', () => { test('damage reduces hp, clamps 0', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10, { currentHp: 15, maxHp: 20 })]; const ps = [p('a', 10, { currentHp: 15, maxHp: 20 })];
const { patch } = applyHpChange(enc(ps), 'a', 'damage', 5); const newEnc = await applyHpChange(enc(ps), 'a', 'damage', 5, ctx);
expect(patch.participants[0].currentHp).toBe(10); expect(newEnc.participants[0].currentHp).toBe(10);
}); });
test('damage to 0 keeps active + stays in turn order (FEAT-1)', () => { test('damage to 0 marks dying, keeps active, keeps turn order', async () => {
// FEAT-1: death no longer deactivates or removes from turn order. const { storage, ctx } = mockCtx();
// Dead stay in rotation, nextTurn still visits them, PCs get death-save turn. const ps = [p('a', 10, { type: 'character', currentHp: 3 }), p('b', 5)];
const ps = [p('a', 10, { currentHp: 3 }), p('b', 5)];
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' }); const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' });
const { patch } = applyHpChange(e, 'a', 'damage', 5); const newEnc = await applyHpChange(e, 'a', 'damage', 5, ctx);
expect(patch.participants[0].currentHp).toBe(0); expect(newEnc.participants[0].currentHp).toBe(0);
expect(patch.participants[0].isActive).toBe(true); expect(newEnc.participants[0].status).toBe('dying');
expect(patch.turnOrderIds).toBeUndefined(); expect(newEnc.participants[0].isActive).not.toBe(false);
expect(patch.currentTurnParticipantId).toBeUndefined(); expect(lastPatch(storage).turnOrderIds).toBeUndefined();
expect(lastPatch(storage).currentTurnParticipantId).toBeUndefined();
}); });
test('heal above 0 resets death saves, keeps active (FEAT-1)', () => { test('heal from dying makes conscious and resets death-save counters', async () => {
// FEAT-1: revive no longer flips isActive (was already active — death const { ctx } = mockCtx();
// doesn't deactivate). deathSaves still reset. const ps = [p('a', 10, { currentHp: 0, status: 'dying', deathSaveSuccesses: 2, deathSaveFailures: 1 })];
const ps = [p('a', 10, { currentHp: 0, deathSaves: 2 })]; const newEnc = await applyHpChange(enc(ps), 'a', 'heal', 5, ctx);
const { patch } = applyHpChange(enc(ps), 'a', 'heal', 5); expect(newEnc.participants[0].currentHp).toBe(5);
expect(patch.participants[0].currentHp).toBe(5); expect(newEnc.participants[0].status).toBe('conscious');
expect(patch.participants[0].isActive).toBe(true); expect(newEnc.participants[0].deathSaveSuccesses).toBe(0);
expect(patch.participants[0].deathSaves).toBe(0); expect(newEnc.participants[0].deathSaveFailures).toBe(0);
}); });
test('heal clamps to maxHp', () => { test('heal clamps to maxHp', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10, { currentHp: 18, maxHp: 20 })]; const ps = [p('a', 10, { currentHp: 18, maxHp: 20 })];
const { patch } = applyHpChange(enc(ps), 'a', 'heal', 10); const newEnc = await applyHpChange(enc(ps), 'a', 'heal', 10, ctx);
expect(patch.participants[0].currentHp).toBe(20); expect(newEnc.participants[0].currentHp).toBe(20);
}); });
test('zero amount = no-op', () => { test('zero amount = no-op', async () => {
const ps = [p('a', 10, { currentHp: 10 })]; const { storage, ctx } = mockCtx();
const { patch } = applyHpChange(enc(ps), 'a', 'damage', 0); const e = enc([p('a', 10, { currentHp: 10 })]);
expect(patch).toBe(null); const newEnc = await applyHpChange(e, 'a', 'damage', 0, ctx);
expect(newEnc).toBe(e); // same ref = no write
expect(storage.calls.filter(c => c.fn === 'updateDoc')).toHaveLength(0);
}); });
}); });
describe('deathSave', () => { describe('deathSave', () => {
test('increments saves', () => { test('fail increments failure counter while dying', async () => {
const ps = [p('a', 10, { currentHp: 0, deathSaves: 0 })]; const { ctx } = mockCtx();
const { patch } = deathSave(enc(ps), 'a', 1); const ps = [p('a', 10, { currentHp: 0, status: 'dying', deathSaveFailures: 0, deathSaveSuccesses: 0 })];
expect(patch.participants[0].deathSaves).toBe(1); const { enc: newEnc, status } = await deathSave(enc(ps), 'a', 'fail', ctx);
expect(status).toBe('dying');
expect(newEnc.participants[0].deathSaveFailures).toBe(1);
}); });
test('clicking same save decrements (toggle)', () => { test('third fail makes dead and resets counters', async () => {
const ps = [p('a', 10, { currentHp: 0, deathSaves: 2 })]; const { ctx } = mockCtx();
const { patch } = deathSave(enc(ps), 'a', 2); const ps = [p('a', 10, { currentHp: 0, status: 'dying', deathSaveFailures: 2, deathSaveSuccesses: 0 })];
expect(patch.participants[0].deathSaves).toBe(1); const { enc: newEnc, status } = await deathSave(enc(ps), 'a', 'fail', ctx);
}); expect(status).toBe('dead');
expect(newEnc.participants[0].status).toBe('dead');
test('third save sets isDying', () => { expect(newEnc.participants[0].deathSaveFailures).toBe(0);
const ps = [p('a', 10, { currentHp: 0, deathSaves: 2 })];
const result = deathSave(enc(ps), 'a', 3);
expect(result.patch.participants[0].deathSaves).toBe(3);
expect(result.patch.participants[0].isDying).toBe(true);
expect(result.isDying).toBe(true);
}); });
}); });
describe('toggleCondition', () => { describe('toggleCondition', () => {
test('adds condition', () => { test('adds condition', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10, { conditions: [] })]; const ps = [p('a', 10, { conditions: [] })];
const { patch } = toggleCondition(enc(ps), 'a', 'poisoned'); const newEnc = await toggleCondition(enc(ps), 'a', 'poisoned', ctx);
expect(patch.participants[0].conditions).toEqual(['poisoned']); expect(newEnc.participants[0].conditions).toEqual(['poisoned']);
}); });
test('removes condition', () => { test('removes condition', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10, { conditions: ['poisoned', 'blinded'] })]; const ps = [p('a', 10, { conditions: ['poisoned', 'blinded'] })];
const { patch } = toggleCondition(enc(ps), 'a', 'poisoned'); const newEnc = await toggleCondition(enc(ps), 'a', 'poisoned', ctx);
expect(patch.participants[0].conditions).toEqual(['blinded']); expect(newEnc.participants[0].conditions).toEqual(['blinded']);
}); });
}); });
describe('reorderParticipants', () => { describe('reorderParticipants', () => {
test('drag before target (1-list, cross-init allowed)', () => { test('drag downward onto target moves after target (same-init tie)', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10), p('b', 10), p('c', 10)]; const ps = [p('a', 10), p('b', 10), p('c', 10)];
const { patch } = reorderParticipants(enc(ps), 'a', 'c'); const newEnc = await reorderParticipants(enc(ps), 'a', 'c', ctx);
// drag a before c: remove a → [b,c], insert before c → [b,a,c] // drag a downward onto c: remove a → [b,c], insert after c → [b,c,a]
expect(patch.participants.map(x => x.id)).toEqual(['b', 'a', 'c']); expect(newEnc.participants.map(x => x.id)).toEqual(['b', 'c', 'a']);
}); });
test('cross-init drag allowed (1-list, DM override)', () => { test('cross-init drag blocked (no-op)', async () => {
const ps = [p('a', 10), p('b', 5)]; const { storage, ctx } = mockCtx();
const { patch } = reorderParticipants(enc(ps), 'a', 'b'); const e = enc([p('a', 10), p('b', 5)]);
expect(patch.participants.map(x => x.id)).toEqual(['a', 'b']); const newEnc = await reorderParticipants(e, 'a', 'b', ctx);
expect(newEnc).toBe(e); // same ref = no write
expect(storage.calls.filter(c => c.fn === 'updateDoc')).toHaveLength(0);
}); });
}); });
describe('endEncounter', () => { describe('endEncounter', () => {
test('resets all combat state', () => { test('resets all combat state', async () => {
const { ctx } = mockCtx();
const e = enc([p('a', 10)], { const e = enc([p('a', 10)], {
isStarted: true, round: 5, currentTurnParticipantId: 'a', turnOrderIds: ['a'], isStarted: true, round: 5, currentTurnParticipantId: 'a', turnOrderIds: ['a'],
}); });
const { patch } = endEncounter(e); const newEnc = await endEncounter(e, ctx);
expect(patch.isStarted).toBe(false); expect(newEnc.isStarted).toBe(false);
expect(patch.round).toBe(0); expect(newEnc.round).toBe(0);
expect(patch.currentTurnParticipantId).toBe(null); expect(newEnc.currentTurnParticipantId).toBe(null);
expect(patch.turnOrderIds).toEqual([]); expect(newEnc.turnOrderIds).toEqual([]);
}); });
}); });
@@ -327,40 +365,22 @@ 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', async () => {
const { ctx } = mockCtx();
const np = p('z', 7); const np = p('z', 7);
const { patch } = addParticipant(enc([p('a', 10)]), np); const newEnc = await addParticipant(enc([p('a', 10)]), np, ctx);
expect(patch.participants.map(x => x.id)).toEqual(['a', 'z']); expect(newEnc.participants.map(x => x.id)).toEqual(['a', 'z']);
}); });
test('rejects duplicate id (skip-bug root cause)', () => { test('rejects duplicate id (skip-bug root cause)', async () => {
// Two participants with same id → togglePause resume rebuilds order with // Two participants with same id → togglePause resume rebuilds order with
// dup id twice → nextTurn gets stuck repeating that id forever. // dup id twice → nextTurn gets stuck repeating that id forever.
// Audit found this in 100-round replay (addParticipant fired while paused // Audit found this in 100-round replay (addParticipant fired while paused
// because nextTurn threw, loop spun, same totalTurns %10 → re-added). // because nextTurn threw, loop spun, same totalTurns %10 → re-added).
const { ctx } = mockCtx();
const existing = p('x', 5); const existing = p('x', 5);
const dup = makeParticipant({ id: 'x', name: 'x2', type: 'monster', initiative: 10, maxHp: 100, currentHp: 100 }); const dup = makeParticipant({ id: 'x', name: 'x2', type: 'monster', initiative: 10, maxHp: 100, currentHp: 100 });
expect(() => addParticipant(enc([p('a', 10), existing]), dup)).toThrow(); await expect(addParticipant(enc([p('a', 10), existing]), dup, ctx)).rejects.toThrow();
}); });
}); });
+82 -47
View File
@@ -1,4 +1,4 @@
// Combat integrity test: replay exact op sequence through pure turn.js, // Combat integrity test: replay exact op sequence through async turn.js,
// assert rotation + state invariants per round. This IS the test the audit // assert rotation + state invariants per round. This IS the test the audit
// was supposed to be. Deterministic (seeded RNG). RED on current code = BUG-5. // was supposed to be. Deterministic (seeded RNG). RED on current code = BUG-5.
// //
@@ -6,8 +6,11 @@
// damage, heal (cleric), conditions, toggleActive, deathSave, // damage, heal (cleric), conditions, toggleActive, deathSave,
// removeParticipant, addParticipant, updateParticipant, pause/resume, // removeParticipant, addParticipant, updateParticipant, pause/resume,
// reorderParticipants, revive-between-rounds. // reorderParticipants, revive-between-rounds.
//
// Rewritten for async turn.js API: funcs are awaited, take ctx, write own logs.
const shared = require('@ttrpg/shared'); const shared = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers');
const { const {
makeParticipant, buildCharacterParticipant, buildMonsterParticipant, makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
startEncounter, nextTurn, togglePause, startEncounter, nextTurn, togglePause,
@@ -32,6 +35,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({
@@ -50,7 +56,7 @@ function setupEncounter() {
buildMonsterParticipant({ name:'Goblin2', maxHp:100, initMod:2 }).participant, buildMonsterParticipant({ name:'Goblin2', maxHp:100, initMod:2 }).participant,
buildMonsterParticipant({ name:'OrcBoss', maxHp:500, initMod:1 }).participant, buildMonsterParticipant({ name:'OrcBoss', maxHp:500, initMod:1 }).participant,
buildMonsterParticipant({ name:'Wolf', maxHp:120, initMod:3 }).participant, buildMonsterParticipant({ name:'Wolf', maxHp:120, initMod:3 }).participant,
buildMonsterParticipant({ name:'Merchant', maxHp:150, initMod:0, isNpc:true }).participant, buildMonsterParticipant({ name:'Merchant', maxHp:150, initMod:0, asNpc:true }).participant,
]; ];
// give deterministic ids to monsters for assertions // give deterministic ids to monsters for assertions
const idMap = { Goblin1:'m1', Goblin2:'m2', OrcBoss:'m3', Wolf:'m4', Merchant:'n1' }; const idMap = { Goblin1:'m1', Goblin2:'m2', OrcBoss:'m3', Wolf:'m4', Merchant:'n1' };
@@ -67,28 +73,24 @@ function currentParticipant(e) {
return (e.participants || []).find(x => x.id === e.currentTurnParticipantId) || null; return (e.participants || []).find(x => x.id === e.currentTurnParticipantId) || null;
} }
// Apply a result patch if present.
function apply(e, result) {
if (!result || !result.patch) return e;
return { ...e, ...result.patch };
}
describe('combat integrity (100 rounds, full op coverage)', () => { describe('combat integrity (100 rounds, full op coverage)', () => {
jest.setTimeout(30000); jest.setTimeout(30000);
const ROUNDS = 100; const ROUNDS = 100;
const violations = []; const violations = [];
test('every round visits each active participant exactly once', () => { test('every round visits each active participant exactly once', async () => {
const { ctx } = mockCtx();
_seed = 12345; // reset for reproducibility _seed = 12345; // reset for reproducibility
let e = setupEncounter(); let e = setupEncounter();
e = apply(e, startEncounter(e)); e = await startEncounter(e, ctx);
let totalTurns = 0; let totalTurns = 0;
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;
@@ -98,15 +100,15 @@ describe('combat integrity (100 rounds, full op coverage)', () => {
while (e.round === startRound && guard < cap) { while (e.round === startRound && guard < cap) {
// resume if paused (must precede nextTurn) // resume if paused (must precede nextTurn)
if (lastPaused) { e = apply(e, togglePause(e)); lastPaused = false; } if (lastPaused) { e = await togglePause(e, ctx); lastPaused = false; }
// advance // advance
let t; try {
try { t = nextTurn(e); } catch (err) { e = await nextTurn(e, ctx);
} catch (err) {
violations.push({ round: roundN, type: 'nextTurn-throws', msg: err.message }); violations.push({ round: roundN, type: 'nextTurn-throws', msg: err.message });
break; break;
} }
e = apply(e, t);
totalTurns++; totalTurns++;
// only count if turn belongs to THIS round (no wrap) // only count if turn belongs to THIS round (no wrap)
if (e.round === startRound) seenThisRound.push(e.currentTurnParticipantId); if (e.round === startRound) seenThisRound.push(e.currentTurnParticipantId);
@@ -116,58 +118,58 @@ describe('combat integrity (100 rounds, full op coverage)', () => {
// 1. damage // 1. damage
if (actor) { if (actor) {
const foes = e.participants.filter( const foes = e.participants.filter(
p => p.id !== actor.id && p.currentHp > 0 && p.isActive !== false part => part.id !== actor.id && part.currentHp > 0 && part.isActive !== false
); );
if (foes.length > 0) { if (foes.length > 0) {
const tgt = pick(foes); const tgt = pick(foes);
const dmg = 1 + rnd(5); const dmg = 1 + rnd(5);
e = apply(e, applyHpChange(e, tgt.id, 'damage', dmg)); e = await applyHpChange(e, tgt.id, 'damage', dmg, ctx);
} }
} }
// 2. heal (cleric) // 2. heal (cleric)
if (actor && actor.name === 'Cleric' && totalTurns % 2 === 0) { if (actor && actor.name === 'Cleric' && totalTurns % 2 === 0) {
const wounded = e.participants const wounded = e.participants
.filter(p => p.currentHp > 0 && p.currentHp < p.maxHp && p.isActive !== false) .filter(part => part.currentHp > 0 && part.currentHp < part.maxHp && part.isActive !== false)
.sort((a,b)=>(a.currentHp/a.maxHp)-(b.currentHp/b.maxHp)); .sort((a,b)=>(a.currentHp/a.maxHp)-(b.currentHp/b.maxHp));
if (wounded.length > 0) { if (wounded.length > 0) {
const tgt = wounded[0]; const tgt = wounded[0];
const amt = 2 + rnd(5); const amt = 2 + rnd(5);
e = apply(e, applyHpChange(e, tgt.id, 'heal', amt)); e = await applyHpChange(e, tgt.id, 'heal', amt, ctx);
} }
} }
// 3. conditions // 3. conditions
if (condQueue.length > 0) { if (condQueue.length > 0) {
const cond = condQueue[0]; const cond = condQueue[0];
const living = e.participants.filter(p => p.currentHp > 0 && p.isActive !== false); const living = e.participants.filter(part => part.currentHp > 0 && part.isActive !== false);
if (living.length > 0) { if (living.length > 0) {
const tgt = pick(living); const tgt = pick(living);
try { e = apply(e, toggleCondition(e, tgt.id, cond)); condQueue.shift(); } try { e = await toggleCondition(e, tgt.id, cond, ctx); condQueue.shift(); }
catch (err) { condQueue.shift(); } catch (err) { condQueue.shift(); }
} }
} else if (totalTurns % 6 === 0) { } else if (totalTurns % 6 === 0) {
const living = e.participants.filter(p => p.currentHp > 0 && p.isActive !== false); const living = e.participants.filter(part => part.currentHp > 0 && part.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 = await toggleCondition(e, tgt.id, cond, ctx); } catch (err) {}
} }
} }
// 4. toggleParticipantActive // 4. toggleParticipantActive
if (totalTurns % 9 === 0) { if (totalTurns % 9 === 0) {
const living = e.participants.filter(p => p.currentHp > 0); const living = e.participants.filter(part => part.currentHp > 0);
if (living.length > 0) { if (living.length > 0) {
const tgt = pick(living); const tgt = pick(living);
try { e = apply(e, toggleParticipantActive(e, tgt.id)); } catch (err) {} try { e = await toggleParticipantActive(e, tgt.id, ctx); } catch (err) {}
} }
} }
// 5. deathSave // 5. deathSave
if (actor && actor.currentHp <= 0 && !actor.isNpc) { if (actor && actor.status === 'dying' && actor.type !== 'npc') {
try { e = apply(e, deathSave(e, actor.id, 1)); } catch (err) {} try { const r = await deathSave(e, actor.id, 'fail', ctx); e = r.enc; } catch (err) {}
} }
// 6. removeParticipant // 6. removeParticipant (monsters/NPC only; dead PCs stay until DM removes manually)
if (totalTurns % 5 === 0) { if (totalTurns % 5 === 0) {
const dead = e.participants.find(p => (p.isDying || p.currentHp <= 0) && (p.isNpc || p.name.startsWith('Goblin') || p.name === 'OrcBoss' || p.name === 'Wolf')); const dead = e.participants.find(part => (part.status === 'dead' || part.currentHp <= 0) && (part.type === 'npc' || part.name.startsWith('Goblin') || part.name === 'OrcBoss' || part.name === 'Wolf'));
if (dead) { try { e = apply(e, removeParticipant(e, dead.id)); } catch (err) {} } if (dead) { try { e = await removeParticipant(e, dead.id, ctx); } catch (err) {} }
} }
// 7. addParticipant // 7. addParticipant
if (totalTurns % 10 === 0 && reinforcementsAdded < 4) { if (totalTurns % 10 === 0 && reinforcementsAdded < 4) {
@@ -176,27 +178,27 @@ describe('combat integrity (100 rounds, full op coverage)', () => {
{ name:`Summon${reinforcementsAdded+1}`, maxHp:80, initMod:4 }, { name:`Summon${reinforcementsAdded+1}`, maxHp:80, initMod:4 },
]); ]);
const built = buildMonsterParticipant(spec).participant; const built = buildMonsterParticipant(spec).participant;
try { e = apply(e, addParticipant(e, built)); reinforcementsAdded++; } catch (err) {} try { e = await addParticipant(e, built, ctx); reinforcementsAdded++; } catch (err) {}
} }
// 8. updateParticipant // 8. updateParticipant
if (totalTurns % 7 === 0) { if (totalTurns % 7 === 0) {
const living = e.participants.filter(p => p.currentHp > 0); const living = e.participants.filter(part => part.currentHp > 0);
if (living.length > 0) { if (living.length > 0) {
const tgt = pick(living); const tgt = pick(living);
try { e = apply(e, updateParticipant(e, tgt.id, { notes:`edited@turn${totalTurns}` })); } catch (err) {} try { e = await updateParticipant(e, tgt.id, { notes:`edited@turn${totalTurns}` }, ctx); } catch (err) {}
} }
} }
// 9. pause // 9. pause
if (totalTurns % 12 === 0 && !lastPaused) { e = apply(e, togglePause(e)); lastPaused = true; } if (totalTurns % 12 === 0 && !lastPaused) { e = await togglePause(e, ctx); lastPaused = true; }
// 10. reorderParticipants (mirror replay's buggy signature usage — swallowed no-op) // 10. reorderParticipants (mirror replay's buggy signature usage — swallowed no-op)
if (totalTurns % 8 === 0 && lastReorder !== totalTurns) { if (totalTurns % 8 === 0 && lastReorder !== totalTurns) {
const living = e.participants.filter(p => p.currentHp > 0 && p.isActive !== false); const living = e.participants.filter(part => part.currentHp > 0 && part.isActive !== false);
if (living.length >= 2) { if (living.length >= 2) {
const tgt = living[0]; const tgt = living[0];
const newInit = (tgt.initiative || 0) + 1; const newInit = (tgt.initiative || 0) + 1;
try { try {
const reordered = [...e.participants].map(p => p.id === tgt.id ? { ...p, initiative: newInit } : p); const reordered = [...e.participants].map(part => part.id === tgt.id ? { ...part, initiative: newInit } : part);
e = apply(e, reorderParticipants(e, reordered)); e = await reorderParticipants(e, reordered); // array as draggedId throws → swallowed
lastReorder = totalTurns; lastReorder = totalTurns;
} catch (err) {} } catch (err) {}
} }
@@ -211,34 +213,42 @@ describe('combat integrity (100 rounds, full op coverage)', () => {
const uniq = new Set(seenThisRound); const uniq = new Set(seenThisRound);
if (uniq.size !== seenThisRound.length) { if (uniq.size !== seenThisRound.length) {
violations.push({ round: roundN, type: 'rotation-dupe', violations.push({ round: roundN, type: 'rotation-dupe',
seen: seenThisRound.map(id => e.participants.find(p=>p.id===id)?.name||id) }); seen: seenThisRound.map(id => e.participants.find(part=>part.id===id)?.name||id) });
} }
// turnOrderIds no dup // turnOrderIds no dup
const orderUniq = new Set(e.turnOrderIds); const orderUniq = new Set(e.turnOrderIds);
if (orderUniq.size !== e.turnOrderIds.length) { if (orderUniq.size !== e.turnOrderIds.length) {
violations.push({ round: roundN, type: 'turnOrder-dup-id', order: e.turnOrderIds }); violations.push({ round: roundN, type: 'turnOrder-dup-id', order: e.turnOrderIds });
} }
// currentTurn valid + active // currentTurn valid. It may be inactive immediately after DM toggles
// current actor inactive; toggle-active is status edit, not turn advance.
// Next Turn is responsible for skipping inactive current.
if (e.currentTurnParticipantId) { if (e.currentTurnParticipantId) {
const ct = e.participants.find(p => p.id === e.currentTurnParticipantId); const ct = e.participants.find(part => part.id === e.currentTurnParticipantId);
if (!ct) violations.push({ round: roundN, type: 'currentTurn-missing' }); if (!ct) violations.push({ round: roundN, type: 'currentTurn-missing' });
else if (ct.isActive === false && e.isStarted) {
violations.push({ round: roundN, type: 'currentTurn-inactive', id: ct.id });
}
} }
// HP bounds // HP bounds
for (const part of e.participants) { for (const part of e.participants) {
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(part => part.currentHp <= 0 || part.isActive === false);
for (const d of dead) { for (const d of dead) {
try { try {
if (d.isActive === false) e = apply(e, toggleParticipantActive(e, d.id)); if (d.isActive === false) e = await toggleParticipantActive(e, d.id, ctx);
e = apply(e, applyHpChange(e, d.id, 'heal', d.maxHp)); e = await applyHpChange(e, d.id, 'heal', d.maxHp, ctx);
} catch (err) {} } catch (err) {}
} }
} }
@@ -254,4 +264,29 @@ 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', async () => {
const { ctx } = mockCtx();
let e = setupEncounter();
e = await startEncounter(e, ctx);
const tgt = e.participants[0].id;
// add custom (not in built-ins)
e = await toggleCondition(e, tgt, 'hexed', ctx);
let part = e.participants.find(x => x.id === tgt);
expect(part.conditions).toContain('hexed');
// emoji-bearing custom id
e = await toggleCondition(e, tgt, '🛡️blessed', ctx);
part = e.participants.find(x => x.id === tgt);
expect(part.conditions).toContain('🛡️blessed');
// remove
e = await toggleCondition(e, tgt, 'hexed', ctx);
part = e.participants.find(x => x.id === tgt);
expect(part.conditions).not.toContain('hexed');
expect(part.conditions).toContain('🛡️blessed');
});
}); });
+60 -48
View File
@@ -1,21 +1,17 @@
// M4 desired behavior: dead PC stays in turn order, turn still comes up, 'use strict';
// deathSave fires. Current code filters isActive (set false on death) so
// dead participants are SKIPPED. Test asserts desired state = RED on current.
const shared = require('@ttrpg/shared'); const shared = require('@ttrpg/shared');
const { makeParticipant, startEncounter, nextTurn, applyHpChange, deathSave } = shared; const { makeParticipant, startEncounter, nextTurn, applyHpChange, deathSave } = shared;
const { mockCtx } = require('./_helpers');
function p(id, init, extra = {}) {
return makeParticipant({
id, name: id, type: 'monster',
initiative: init, maxHp: 100, currentHp: 100,
...extra,
});
}
function pc(id, init, extra = {}) { function pc(id, init, extra = {}) {
return makeParticipant({ return makeParticipant({
id, name: id, type: 'character', id,
initiative: init, maxHp: 100, currentHp: 100, name: id,
type: 'character',
initiative: init,
maxHp: 100,
currentHp: 100,
...extra, ...extra,
}); });
} }
@@ -23,51 +19,67 @@ function enc(ps) {
return { name:'t', participants:ps, isStarted:false, isPaused:false, return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[] }; round:0, currentTurnParticipantId:null, turnOrderIds:[] };
} }
const byId = (e, id) => e.participants.find(p => p.id === id);
describe('M4: dead participants stay in turn order', () => { describe('death state: participant remains in initiative and encounter', () => {
test('dead PC not removed from turnOrderIds', () => { test('dropping to 0 keeps PC active, in participants, and in turnOrderIds', async () => {
const ps = [pc('a', 20), pc('b', 15), pc('c', 10)]; const { ctx } = mockCtx();
let e = enc(ps); let e = enc([pc('a', 20), pc('b', 15), pc('c', 10)]);
e = { ...e, ...startEncounter(e).patch }; e = await startEncounter(e, ctx);
const orderBefore = e.turnOrderIds.slice(); const orderBefore = e.turnOrderIds.slice();
// kill b
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch }; e = await applyHpChange(e, 'b', 'damage', 100, ctx);
expect(byId(e, 'b').status).toBe('dying');
expect(byId(e, 'b').isActive).not.toBe(false);
expect(e.participants.map(p => p.id)).toContain('b');
expect(e.turnOrderIds).toEqual(orderBefore); expect(e.turnOrderIds).toEqual(orderBefore);
}); });
test('dead PC turn still comes up (nextTurn visits them)', () => { test('dead PC remains in participants and turnOrderIds after three failures', async () => {
const ps = [pc('a', 20), pc('b', 15), pc('c', 10)]; const { ctx } = mockCtx();
let e = enc(ps); let e = enc([pc('a', 20), pc('b', 15), pc('c', 10)]);
e = { ...e, ...startEncounter(e).patch }; e = await startEncounter(e, ctx);
// kill b const orderBefore = e.turnOrderIds.slice();
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch }; e = await applyHpChange(e, 'b', 'damage', 100, ctx);
// advance: a→b→c. b's turn should come up.
e = { ...e, ...nextTurn(e).patch }; e = (await deathSave(e, 'b', 'fail', ctx)).enc;
expect(e.currentTurnParticipantId).toBe('b'); e = (await deathSave(e, 'b', 'fail', ctx)).enc;
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
expect(byId(e, 'b').status).toBe('dead');
expect(byId(e, 'b').isActive).not.toBe(false);
expect(e.participants.map(p => p.id)).toContain('b');
expect(e.turnOrderIds).toEqual(orderBefore);
}); });
test('dead PC on their turn can deathSave', () => { test('nextTurn may still land on dead PC because DM/tool events may matter', async () => {
const ps = [pc('a', 20), pc('b', 15)]; const { ctx } = mockCtx();
let e = enc(ps); let e = enc([pc('a', 20), pc('b', 15), pc('c', 10)]);
e = { ...e, ...startEncounter(e).patch }; e = await startEncounter(e, ctx);
// kill b (current = a) e = await applyHpChange(e, 'b', 'damage', 100, ctx);
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch }; e = (await deathSave(e, 'b', 'fail', ctx)).enc;
// advance to b's turn e = (await deathSave(e, 'b', 'fail', ctx)).enc;
e = { ...e, ...nextTurn(e).patch }; e = (await deathSave(e, 'b', 'fail', ctx)).enc;
e = await nextTurn(e, ctx);
expect(e.currentTurnParticipantId).toBe('b'); expect(e.currentTurnParticipantId).toBe('b');
// b is dead, on their turn: deathSave should not throw expect(byId(e, 'b').status).toBe('dead');
const r = deathSave(e, 'b', 1);
expect(r.patch).toBeTruthy();
const b = r.patch.participants.find(x => x.id === 'b');
expect(b.deathSaves).toBe(1);
}); });
test('dead PC not auto-set isActive=false by applyHpChange', () => { test('deathSave rejected/no-op once dead', async () => {
const ps = [pc('a', 20), pc('b', 15)]; const { ctx } = mockCtx();
let e = enc(ps); let e = enc([pc('a', 20), pc('b', 15)]);
e = { ...e, ...startEncounter(e).patch }; e = await startEncounter(e, ctx);
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch }; e = await applyHpChange(e, 'b', 'damage', 100, ctx);
const b = e.participants.find(x => x.id === 'b'); e = (await deathSave(e, 'b', 'fail', ctx)).enc;
expect(b.isActive).toBe(true); e = (await deathSave(e, 'b', 'fail', ctx)).enc;
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
const before = byId(e, 'b');
try { e = (await deathSave(e, 'b', 'fail', ctx)).enc; } catch (err) {}
expect(byId(e, 'b')).toMatchObject(before);
}); });
}); });
+334
View File
@@ -0,0 +1,334 @@
'use strict';
const {
makeParticipant,
startEncounter,
applyHpChange,
deathSave,
stabilizeParticipant,
reviveParticipant,
} = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers');
function pc(id, extra = {}) {
return makeParticipant({
id,
name: id,
type: 'character',
initiative: 10,
maxHp: 100,
currentHp: 100,
...extra,
});
}
function monster(id, extra = {}) {
return makeParticipant({
id,
name: id,
type: 'monster',
initiative: 10,
maxHp: 100,
currentHp: 100,
...extra,
});
}
function enc(participants, extra = {}) {
return {
id: 'e1',
name: 'death-save-test',
participants,
isStarted: false,
isPaused: false,
round: 0,
currentTurnParticipantId: null,
turnOrderIds: [],
...extra,
};
}
async function startedAtZero() {
const { ctx } = mockCtx();
let e = enc([pc('a')]);
e = await startEncounter(e, ctx);
const originalTurnOrderIds = [...e.turnOrderIds];
e = await applyHpChange(e, 'a', 'damage', 100, ctx);
return { e, ctx, originalTurnOrderIds };
}
const part = e => e.participants.find(p => p.id === 'a');
describe('death saves: D&D 5e status state machine', () => {
test('damage drops character to dying without removal or deactivation', async () => {
const { e, originalTurnOrderIds } = await startedAtZero();
const p = part(e);
expect(p.currentHp).toBe(0);
expect(p.status).toBe('dying');
expect(p.deathSaveSuccesses).toBe(0);
expect(p.deathSaveFailures).toBe(0);
expect(p.isActive).not.toBe(false);
expect(e.participants.map(x => x.id)).toContain('a');
expect(e.turnOrderIds).toEqual(originalTurnOrderIds);
});
test('damage drops non-NPC monster to dead/inactive, not dying', async () => {
const { ctx } = mockCtx();
let e = enc([monster('a')]);
e = await startEncounter(e, ctx);
const orderBefore = [...e.turnOrderIds];
e = await applyHpChange(e, 'a', 'damage', 100, ctx);
expect(part(e).status).toBe('dead');
expect(part(e).deathSaveSuccesses).toBe(0);
expect(part(e).deathSaveFailures).toBe(0);
expect(part(e).conditions).not.toContain('unconscious');
expect(part(e).isActive).toBe(false);
expect(e.turnOrderIds).toEqual(orderBefore);
});
test('dead non-NPC monster with stale active flag repairs to inactive', async () => {
const { ctx } = mockCtx();
let e = enc([monster('a', { currentHp: 0, status: 'dead', isActive: true })]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 1, ctx);
expect(part(e).status).toBe('dead');
expect(part(e).isActive).toBe(false);
});
test('damage drops NPC to dying', async () => {
const { ctx } = mockCtx();
let e = enc([monster('a', { type: 'npc' })]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 100, ctx);
expect(part(e).type).toBe('npc');
expect(part(e).status).toBe('dying');
expect(part(e).conditions).toContain('unconscious');
});
test('three failures immediately makes dead, resets counters, keeps participant and turn order', async () => {
let { e, ctx, originalTurnOrderIds } = await startedAtZero();
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
expect(part(e).status).toBe('dying');
expect(part(e).deathSaveFailures).toBe(1);
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
expect(part(e).status).toBe('dying');
expect(part(e).deathSaveFailures).toBe(2);
const res = await deathSave(e, 'a', 'fail', ctx);
e = res.enc;
expect(res.status).toBe('dead');
expect(part(e).status).toBe('dead');
expect(part(e).currentHp).toBe(0);
expect(part(e).deathSaveSuccesses).toBe(0);
expect(part(e).deathSaveFailures).toBe(0);
expect(part(e).isActive).not.toBe(false);
expect(e.participants.map(x => x.id)).toContain('a');
expect(e.turnOrderIds).toEqual(originalTurnOrderIds);
});
test('three successes immediately makes stable and resets counters', async () => {
let { e, ctx } = await startedAtZero();
e = (await deathSave(e, 'a', 'success', ctx)).enc;
e = (await deathSave(e, 'a', 'success', ctx)).enc;
const res = await deathSave(e, 'a', 'success', ctx);
e = res.enc;
expect(res.status).toBe('stable');
expect(part(e).status).toBe('stable');
expect(part(e).currentHp).toBe(0);
expect(part(e).deathSaveSuccesses).toBe(0);
expect(part(e).deathSaveFailures).toBe(0);
});
test('successes and failures are independent before terminal transition', async () => {
let { e, ctx } = await startedAtZero();
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
e = (await deathSave(e, 'a', 'success', ctx)).enc;
e = (await deathSave(e, 'a', 'success', ctx)).enc;
expect(part(e).status).toBe('dying');
expect(part(e).deathSaveFailures).toBe(2);
expect(part(e).deathSaveSuccesses).toBe(2);
e = (await deathSave(e, 'a', 'success', ctx)).enc;
expect(part(e).status).toBe('stable');
expect(part(e).deathSaveFailures).toBe(0);
expect(part(e).deathSaveSuccesses).toBe(0);
});
test('nat1 adds two failures and can kill', async () => {
let { e, ctx } = await startedAtZero();
e = (await deathSave(e, 'a', 'nat1', ctx)).enc;
expect(part(e).status).toBe('dying');
expect(part(e).deathSaveFailures).toBe(2);
e = (await deathSave(e, 'a', 'nat1', ctx)).enc;
expect(part(e).status).toBe('dead');
expect(part(e).deathSaveFailures).toBe(0);
});
test('nat1 at one failure immediately kills', async () => {
let { e, ctx } = await startedAtZero();
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
e = (await deathSave(e, 'a', 'nat1', ctx)).enc;
expect(part(e).status).toBe('dead');
expect(part(e).deathSaveFailures).toBe(0);
});
test('nat20 restores to 1 HP conscious and resets counters', async () => {
let { e, ctx } = await startedAtZero();
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
e = (await deathSave(e, 'a', 'success', ctx)).enc;
e = (await deathSave(e, 'a', 'nat20', ctx)).enc;
expect(part(e).status).toBe('conscious');
expect(part(e).currentHp).toBe(1);
expect(part(e).deathSaveSuccesses).toBe(0);
expect(part(e).deathSaveFailures).toBe(0);
});
test('damage while dying adds one failure', async () => {
let { e, ctx } = await startedAtZero();
e = await applyHpChange(e, 'a', 'damage', 1, ctx);
expect(part(e).status).toBe('dying');
expect(part(e).deathSaveFailures).toBe(1);
});
test('critical damage while dying adds two failures', async () => {
let { e, ctx } = await startedAtZero();
e = await applyHpChange(e, 'a', 'damage', 1, { isCriticalHit: true }, ctx);
expect(part(e).status).toBe('dying');
expect(part(e).deathSaveFailures).toBe(2);
});
test('damage while stable returns to dying and applies failure', async () => {
let { e, ctx } = await startedAtZero();
e = (await deathSave(e, 'a', 'success', ctx)).enc;
e = (await deathSave(e, 'a', 'success', ctx)).enc;
e = (await deathSave(e, 'a', 'success', ctx)).enc;
expect(part(e).status).toBe('stable');
e = await applyHpChange(e, 'a', 'damage', 1, ctx);
expect(part(e).status).toBe('dying');
expect(part(e).deathSaveSuccesses).toBe(0);
expect(part(e).deathSaveFailures).toBe(1);
});
test('healing while dying or stable makes conscious and clears counters', async () => {
let { e, ctx } = await startedAtZero();
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
e = await applyHpChange(e, 'a', 'heal', 10, ctx);
expect(part(e).status).toBe('conscious');
expect(part(e).currentHp).toBe(10);
expect(part(e).deathSaveFailures).toBe(0);
e = await applyHpChange(e, 'a', 'damage', 10, ctx);
e = (await deathSave(e, 'a', 'success', ctx)).enc;
e = (await deathSave(e, 'a', 'success', ctx)).enc;
e = (await deathSave(e, 'a', 'success', ctx)).enc;
expect(part(e).status).toBe('stable');
e = await applyHpChange(e, 'a', 'heal', 5, ctx);
expect(part(e).status).toBe('conscious');
expect(part(e).currentHp).toBe(5);
expect(part(e).deathSaveSuccesses).toBe(0);
expect(part(e).deathSaveFailures).toBe(0);
});
test('normal healing does not revive dead', async () => {
let { e, ctx } = await startedAtZero();
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
expect(part(e).status).toBe('dead');
try { e = await applyHpChange(e, 'a', 'heal', 10, ctx); } catch (err) {}
expect(part(e).status).toBe('dead');
expect(part(e).currentHp).toBe(0);
});
test('stabilize sets stable, hp zero, counters zero', async () => {
let { e, ctx } = await startedAtZero();
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
e = await stabilizeParticipant(e, 'a', ctx);
expect(part(e).status).toBe('stable');
expect(part(e).currentHp).toBe(0);
expect(part(e).deathSaveSuccesses).toBe(0);
expect(part(e).deathSaveFailures).toBe(0);
});
test('negative heal acts as damage and can drop to dying', async () => {
const { ctx } = mockCtx();
let e = enc([pc('a', { currentHp: 1 })]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'heal', -1, ctx);
expect(part(e).currentHp).toBe(0);
expect(part(e).status).toBe('dying');
});
test('negative damage acts as healing', async () => {
let { e, ctx } = await startedAtZero();
e = await applyHpChange(e, 'a', 'damage', -5, ctx);
expect(part(e).currentHp).toBe(5);
expect(part(e).status).toBe('conscious');
});
test('revive moves dead participant to 0 HP stable/unconscious', async () => {
let { e, ctx } = await startedAtZero();
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
expect(part(e).status).toBe('dead');
e = await reviveParticipant(e, 'a', ctx);
expect(part(e).currentHp).toBe(0);
expect(part(e).status).toBe('stable');
expect(part(e).deathSaveSuccesses).toBe(0);
expect(part(e).deathSaveFailures).toBe(0);
expect(part(e).conditions).toContain('unconscious');
expect(part(e).isActive).toBe(true);
});
test('death save outside dying state is rejected or no-op', async () => {
const { ctx } = mockCtx();
let e = enc([pc('a')]);
e = await startEncounter(e, ctx);
try { e = (await deathSave(e, 'a', 'fail', ctx)).enc; } catch (err) {}
expect(part(e).status).toBe('conscious');
expect(part(e).deathSaveFailures).toBe(0);
});
});
+195
View File
@@ -0,0 +1,195 @@
// Undo roundtrip for DEATH paths: damage to 0 HP (dying/dead), deathSave,
// stabilize, revive, dead-monster auto-deactivate. Each must restore full
// prior state including status, death-save counters, conditions (unconscious),
// and isActive.
//
// Pattern: apply op (writes encounter + log) -> read last log -> expandUndo
// against newEnc -> merge -> deepEqual original snapshot.
const shared = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers');
const { expandUndo } = shared;
const {
makeParticipant, startEncounter,
applyHpChange, deathSave, stabilizeParticipant, reviveParticipant,
} = shared;
function char(id, hp = 100) {
return makeParticipant({
id, name: id, type: 'character',
initiative: 10, maxHp: hp, currentHp: hp,
});
}
function mon(id, hp = 100) {
return makeParticipant({
id, name: id, type: 'monster',
initiative: 10, maxHp: hp, currentHp: hp,
});
}
function enc(ps) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
}
const snap = (e) => JSON.parse(JSON.stringify(e));
function lastUndo(storage, enc) {
const logs = storage.logs();
const last = logs[logs.length - 1];
expect(last.undo).toBeTruthy();
const expanded = expandUndo(last, enc);
expect(expanded).toBeTruthy();
return expanded.updates;
}
describe('death-path undo roundtrip', () => {
test('damage to 0 HP (dying) undo restores status + unconscious condition', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a')]);
e = await startEncounter(e, ctx);
const before = snap(e);
const newEnc = await applyHpChange(e, 'a', 'damage', 100, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.status).toBe('dying');
expect(p.conditions).toContain('unconscious');
const undo = lastUndo(storage, newEnc);
const after = { ...newEnc, ...undo };
expect(snap(after)).toEqual(before);
});
test('damage while dying undo restores status + failures + conditions', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a')]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
const before = snap(e);
const newEnc = await applyHpChange(e, 'a', 'damage', 5, ctx); // +1 fail
expect(newEnc.participants.find(x => x.id === 'a').deathSaveFailures).toBe(1);
const undo = lastUndo(storage, newEnc);
const after = { ...newEnc, ...undo };
expect(snap(after)).toEqual(before);
});
test('damage while stable undo restores stable + reset failures', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a')]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
e = await stabilizeParticipant(e, 'a', ctx); // -> stable
const before = snap(e);
const newEnc = await applyHpChange(e, 'a', 'damage', 1, ctx); // -> dying 1 fail
const undo = lastUndo(storage, newEnc);
const after = { ...newEnc, ...undo };
expect(snap(after)).toEqual(before);
});
test('massive damage (dead) undo restores conscious', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a', 50)]);
e = await startEncounter(e, ctx);
const before = snap(e);
const newEnc = await applyHpChange(e, 'a', 'damage', 200, ctx);
expect(newEnc.participants.find(x => x.id === 'a').status).toBe('dead');
const undo = lastUndo(storage, newEnc);
const after = { ...newEnc, ...undo };
expect(snap(after)).toEqual(before);
});
test('dead monster auto-deactivate undo restores active', async () => {
const { storage, ctx } = mockCtx();
let e = enc([mon('a')]);
e = await startEncounter(e, ctx);
// first make it dead+inactive
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dead + inactive
// second damage at 0 while dead: no-op (returns same enc) but if it had
// repaired a stale active-dead monster it writes deactivate_dead_monster.
// Force the repair path: set stale isActive=true then damage.
e = { ...e, participants: e.participants.map(p => p.id === 'a' ? { ...p, isActive: true } : p) };
const before = snap(e); // stale active-dead = state right before the op
const newEnc = await applyHpChange(e, 'a', 'damage', 1, ctx);
expect(newEnc.participants.find(x => x.id === 'a').isActive).toBe(false);
const undo = lastUndo(storage, newEnc);
const after = { ...newEnc, ...undo };
expect(snap(after)).toEqual(before);
});
test('deathSave fail undo restores dying + counters + conditions', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a')]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
const before = snap(e);
const r = await deathSave(e, 'a', 'fail', ctx);
expect(r.enc.participants.find(x => x.id === 'a').deathSaveFailures).toBe(1);
const undo = lastUndo(storage, r.enc);
const after = { ...r.enc, ...undo };
expect(snap(after)).toEqual(before);
});
test('deathSave nat1 (2 fails) undo restores prior counters', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a')]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
const before = snap(e);
const r = await deathSave(e, 'a', 'nat1', ctx);
expect(r.enc.participants.find(x => x.id === 'a').deathSaveFailures).toBe(2);
const undo = lastUndo(storage, r.enc);
const after = { ...r.enc, ...undo };
expect(snap(after)).toEqual(before);
});
test('deathSave nat20 (conscious) undo restores dying + unconscious', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a')]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
const before = snap(e);
const r = await deathSave(e, 'a', 'nat20', ctx);
expect(r.enc.participants.find(x => x.id === 'a').status).toBe('conscious');
expect(r.enc.participants.find(x => x.id === 'a').currentHp).toBe(1);
const undo = lastUndo(storage, r.enc);
const after = { ...r.enc, ...undo };
expect(snap(after)).toEqual(before);
});
test('deathSave to stable undo restores dying + unconscious', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a')]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
e = await deathSave(e, 'a', 'success', ctx).then(x => x.enc);
e = await deathSave(e, 'a', 'success', ctx).then(x => x.enc);
const before = snap(e); // 2 successes, dying
const r = await deathSave(e, 'a', 'success', ctx); // -> stable
expect(r.enc.participants.find(x => x.id === 'a').status).toBe('stable');
const undo = lastUndo(storage, r.enc);
const after = { ...r.enc, ...undo };
expect(snap(after)).toEqual(before);
});
test('stabilize undo restores dying + unconscious + counters', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a')]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
e = await deathSave(e, 'a', 'fail', ctx).then(x => x.enc); // 1 fail
const before = snap(e);
const newEnc = await stabilizeParticipant(e, 'a', ctx);
expect(newEnc.participants.find(x => x.id === 'a').status).toBe('stable');
const undo = lastUndo(storage, newEnc);
const after = { ...newEnc, ...undo };
expect(snap(after)).toEqual(before);
});
test('revive undo restores dead + isActive + counters', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a')]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 200, ctx); // -> dead (active)
const before = snap(e);
const newEnc = await reviveParticipant(e, 'a', ctx);
expect(newEnc.participants.find(x => x.id === 'a').status).toBe('stable');
const undo = lastUndo(storage, newEnc);
const after = { ...newEnc, ...undo };
expect(snap(after)).toEqual(before);
});
});
+35 -35
View File
@@ -1,11 +1,12 @@
// DRY guard (BUG-5 fix): nextTurn and computeTurnOrderAfterRemoval share one // Toggle-active semantics guard.
// advance core (nextActiveAfter). Both must pick the SAME next-active target // Design: toggle active is a status edit, NOT a turn action.
// for identical state. If this goes RED, the two paths drifted. // Deactivating current does not advance turn or increment round. DM clicks
// Next Turn explicitly; Next Turn skips inactive participants.
'use strict'; 'use strict';
const shared = require('@ttrpg/shared'); const { makeParticipant, nextTurn, toggleParticipantActive } = require('@ttrpg/shared');
const { makeParticipant, startEncounter, nextTurn, toggleParticipantActive } = shared; const { mockCtx } = require('./_helpers');
function p(id, init, extra = {}) { function p(id, init, extra = {}) {
return makeParticipant({ id, name: id, type: 'monster', return makeParticipant({ id, name: id, type: 'monster',
@@ -16,37 +17,36 @@ function enc(ps, extra = {}) {
round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra }; round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra };
} }
describe('DRY: deact-current advance == nextTurn advance', () => { describe('toggle-active is not a turn advance', () => {
test('mid-round: same target (not current)', () => { test('mid-round: deactivating current leaves current unchanged', async () => {
// order a,b,c. a current. nextTurn → b. deact a → advance → b. const { ctx } = mockCtx();
const e = enc([p('a',10),p('b',5),p('c',3)], { isStarted:true, const e = enc([p('a',10),p('b',5),p('c',3)], { isStarted:true, round:1,
turnOrderIds:['a','b','c'], currentTurnParticipantId:'a' }); turnOrderIds:['a','b','c'], currentTurnParticipantId:'a' });
const nt = nextTurn(e).patch.currentTurnParticipantId; const deact = await toggleParticipantActive(e, 'a', ctx);
const deact = toggleParticipantActive(e, 'a').patch.currentTurnParticipantId;
expect(deact).toBe(nt);
expect(deact).toBe('b');
});
test('mid-round with inactive skipper: same target', () => {
// order a,x,b,c; x inactive. a current. nextTurn → b. deact a → b.
const x = p('x',7,{ isActive:false });
const e = enc([p('a',10),x,p('b',5),p('c',3)], { isStarted:true,
turnOrderIds:['a','x','b','c'], currentTurnParticipantId:'a' });
const nt = nextTurn(e).patch.currentTurnParticipantId;
const deact = toggleParticipantActive(e, 'a').patch.currentTurnParticipantId;
expect(deact).toBe(nt);
expect(deact).toBe('b');
});
test('wrap: same target + round bump', () => {
// order a,b,c. c current. nextTurn → wrap → a (r+1). deact c → wrap → a (r+1).
const e = enc([p('a',10),p('b',5),p('c',3)], { isStarted:true,
turnOrderIds:['a','b','c'], currentTurnParticipantId:'c', round:2 });
const nt = nextTurn(e).patch;
const deact = toggleParticipantActive(e, 'c').patch;
expect(deact.currentTurnParticipantId).toBe(nt.currentTurnParticipantId);
expect(deact.currentTurnParticipantId).toBe('a'); expect(deact.currentTurnParticipantId).toBe('a');
expect(deact.round).toBe(nt.round); expect(deact.round).toBe(1);
expect(deact.round).toBe(3); expect(deact.participants.find(p => p.id === 'a').isActive).toBe(false);
});
test('nextTurn after deactivating current skips inactive participant', async () => {
const { ctx } = mockCtx();
const e = enc([p('a',10),p('b',5),p('c',3)], { isStarted:true, round:1,
turnOrderIds:['a','b','c'], currentTurnParticipantId:'a' });
const deact = await toggleParticipantActive(e, 'a', ctx);
const next = await nextTurn(deact, ctx);
expect(next.currentTurnParticipantId).toBe('b');
expect(next.round).toBe(1);
});
test('wrap: deactivating current does not increment round; nextTurn does', async () => {
const { ctx } = mockCtx();
const e = enc([p('a',10),p('b',5),p('c',3)], { isStarted:true, round:2,
turnOrderIds:['a','b','c'], currentTurnParticipantId:'c' });
const deact = await toggleParticipantActive(e, 'c', ctx);
expect(deact.currentTurnParticipantId).toBe('c');
expect(deact.round).toBe(2);
const next = await nextTurn(deact, ctx);
expect(next.currentTurnParticipantId).toBe('a');
expect(next.round).toBe(3);
}); });
}); });
+60 -59
View File
@@ -5,6 +5,10 @@
// //
// RED now: current code has two lists (sort on display, frozen turnOrderIds), // RED now: current code has two lists (sort on display, frozen turnOrderIds),
// reorder throws on cross-init. Refactor (1-list model) greens these. // reorder throws on cross-init. Refactor (1-list model) greens these.
//
// New API: mutating funcs are async, take ctx last, write encounter + log
// internally, return newEnc. Chained calls become `e = await fn(e, ctx)`.
// No-ops (reorder blocked) return the same enc ref.
'use strict'; 'use strict';
@@ -15,6 +19,7 @@ const {
toggleParticipantActive, togglePause, applyHpChange, toggleParticipantActive, togglePause, applyHpChange,
reorderParticipants, endEncounter, reorderParticipants, endEncounter,
} = shared; } = shared;
const { mockCtx } = require('./_helpers');
function p(id, init, extra = {}) { function p(id, init, extra = {}) {
return makeParticipant({ id, name: id, type: 'monster', return makeParticipant({ id, name: id, type: 'monster',
@@ -24,101 +29,97 @@ function enc(ps, extra = {}) {
return { name:'t', participants:ps, isStarted:false, isPaused:false, return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra }; round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra };
} }
const apply = (e, r) => r && r.patch ? { ...e, ...r.patch } : e;
// walk one full rotation from current, collect active ids in list order
function walkRotation(e) {
if (!e.isStarted || e.isPaused || !e.currentTurnParticipantId) return [];
let cur = e;
const start = cur.currentTurnParticipantId;
const seen = [];
for (let i = 0; i < (cur.turnOrderIds || []).length + 1; i++) {
const curP = (cur.participants || []).find(p => p.id === cur.currentTurnParticipantId);
if (curP && curP.isActive) seen.push(cur.currentTurnParticipantId);
const nxt = nextTurn(cur);
cur = apply(cur, nxt);
if (cur.currentTurnParticipantId === start) break;
}
return seen;
}
describe('1-list model: turnOrderIds === participants.map(id), no re-sort', () => { describe('1-list model: turnOrderIds === participants.map(id), no re-sort', () => {
test('startEncounter: list = sorted-active participants order', () => { test('startEncounter: list = sorted-active participants order', async () => {
const { ctx } = mockCtx();
let e = enc([p('a',3),p('b',10),p('c',5)]); let e = enc([p('a',3),p('b',10),p('c',5)]);
e = apply(e, startEncounter(e)); e = await startEncounter(e, ctx);
expect(e.turnOrderIds).toEqual(['b','c','a']); // 10,5,3 expect(e.turnOrderIds).toEqual(['b','c','a']); // 10,5,3
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id)); expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
}); });
test('startEncounter: inactive stays in list slot (skipped by nextTurn)', () => { test('startEncounter: inactive stays in list slot (skipped by nextTurn)', async () => {
const { ctx } = mockCtx();
let e = enc([p('a',10),p('b',5,{isActive:false}),p('c',3)]); let e = enc([p('a',10),p('b',5,{isActive:false}),p('c',3)]);
e = apply(e, startEncounter(e)); e = await startEncounter(e, ctx);
// 1-list: inactive b stays in slot, nextTurn skips it // 1-list: inactive b stays in slot, nextTurn skips it
expect(e.turnOrderIds).toEqual(['a','b','c']); expect(e.turnOrderIds).toEqual(['a','b','c']);
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id)); expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
expect(e.currentTurnParticipantId).toBe('a'); // b inactive, skipped on start expect(e.currentTurnParticipantId).toBe('a'); // b inactive, skipped on start
}); });
test('addParticipant mid-encounter: inserted by init, list synced', () => { test('addParticipant mid-encounter: inserted by init, list synced', async () => {
const { ctx } = mockCtx();
let e = enc([p('a',10),p('c',3)]); let e = enc([p('a',10),p('c',3)]);
e = apply(e, startEncounter(e)); // [a,c] e = await startEncounter(e, ctx); // [a,c]
e = apply(e, addParticipant(e, p('b',7))); // insert between a,c e = await addParticipant(e, p('b',7), ctx); // insert between a,c
expect(e.turnOrderIds).toEqual(['a','b','c']); expect(e.turnOrderIds).toEqual(['a','b','c']);
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id)); expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
}); });
test('addParticipant: list === participants.map(id) after add', () => { test('addParticipant: list === participants.map(id) after add', async () => {
const { ctx } = mockCtx();
let e = enc([p('a',10)]); let e = enc([p('a',10)]);
e = apply(e, startEncounter(e)); e = await startEncounter(e, ctx);
e = apply(e, addParticipant(e, p('b',5))); e = await addParticipant(e, p('b',5), ctx);
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id)); expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
}); });
test('removeParticipant: list synced, order preserved', () => { test('removeParticipant: list synced, order preserved', async () => {
const { ctx } = mockCtx();
let e = enc([p('a',10),p('b',7),p('c',3)]); let e = enc([p('a',10),p('b',7),p('c',3)]);
e = apply(e, startEncounter(e)); e = await startEncounter(e, ctx);
e = apply(e, removeParticipant(e, 'b')); e = await removeParticipant(e, 'b', ctx);
expect(e.turnOrderIds).toEqual(['a','c']); expect(e.turnOrderIds).toEqual(['a','c']);
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id)); expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
}); });
test('reorder cross-init: allowed, list + rotation reflect new order', () => { test('reorder cross-init: blocked (no-op)', async () => {
const { ctx } = mockCtx();
let e = enc([p('a',10),p('b',7),p('c',3)]); let e = enc([p('a',10),p('b',7),p('c',3)]);
e = apply(e, startEncounter(e)); // [a,b,c] e = await startEncounter(e, ctx); // [a,b,c]
e = apply(e, reorderParticipants(e, 'c', 'a')); // drag c before a const newEnc = await reorderParticipants(e, 'c', 'a', ctx); // cross-init
expect(e.turnOrderIds).toEqual(['c','a','b']); expect(newEnc).toBe(e); // same ref = no write
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id)); expect(e.turnOrderIds).toEqual(['a','b','c']); // unchanged
}); });
test('reorder: rotation follows new list order', () => { test('reorder same-init: within upcoming side of pointer follows new order', async () => {
let e = enc([p('a',10),p('b',7),p('c',3)]); const { ctx } = mockCtx();
e = apply(e, startEncounter(e)); // [a,b,c], cur=a let e = enc([p('a',10),p('b',10),p('c',10),p('d',3)]); // a,b,c tie
e = apply(e, reorderParticipants(e, 'b', 'a')); // [b,a,c], cur still a e = await startEncounter(e, ctx); // [a,b,c,d], cur=a (idx0)
const rot = walkRotation(e); // start a, next c (wrap), next b, back a // Reorder c before b (both upcoming idx1,2). Within same side = allowed.
expect(rot).toEqual(['a','c','b']); e = await reorderParticipants(e, 'c', 'b', ctx); // [a,c,b,d], cur=a
}); expect(e.turnOrderIds).toEqual(['a','c','b','d']);
// walk: a current, next c, next b, next d, wrap a
test('toggle inactive: list unchanged (stays in rotation slot)', () => { e = await nextTurn(e, ctx);
let e = enc([p('a',10),p('b',7),p('c',3)]);
e = apply(e, startEncounter(e)); // [a,b,c]
e = apply(e, toggleParticipantActive(e, 'b')); // b off
expect(e.turnOrderIds).toEqual(['a','b','c']); // b stays in slot
});
test('toggle inactive: nextTurn skips b, visits a→c', () => {
let e = enc([p('a',10),p('b',7),p('c',3)]);
e = apply(e, startEncounter(e)); // cur=a
e = apply(e, toggleParticipantActive(e, 'b')); // b inactive
e = apply(e, nextTurn(e)); // skip b → c
expect(e.currentTurnParticipantId).toBe('c'); expect(e.currentTurnParticipantId).toBe('c');
}); });
test('hp death + revive: list unchanged', () => { test('toggle inactive: list unchanged (stays in rotation slot)', async () => {
const { ctx } = mockCtx();
let e = enc([p('a',10),p('b',7),p('c',3)]); let e = enc([p('a',10),p('b',7),p('c',3)]);
e = apply(e, startEncounter(e)); e = await startEncounter(e, ctx); // [a,b,c]
e = apply(e, applyHpChange(e, 'b', 'damage', 100)); // b dies e = await toggleParticipantActive(e, 'b', ctx); // b off
expect(e.turnOrderIds).toEqual(['a','b','c']); // b stays in slot
});
test('toggle inactive: nextTurn skips b, visits a→c', async () => {
const { ctx } = mockCtx();
let e = enc([p('a',10),p('b',7),p('c',3)]);
e = await startEncounter(e, ctx); // cur=a
e = await toggleParticipantActive(e, 'b', ctx); // b inactive
e = await nextTurn(e, ctx); // skip b → c
expect(e.currentTurnParticipantId).toBe('c');
});
test('hp death + revive: list unchanged', async () => {
const { ctx } = mockCtx();
let e = enc([p('a',10),p('b',7),p('c',3)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'b', 'damage', 100, ctx); // b dies
expect(e.turnOrderIds).toEqual(['a','b','c']); expect(e.turnOrderIds).toEqual(['a','b','c']);
e = apply(e, applyHpChange(e, 'b', 'heal', 50)); // b revive e = await applyHpChange(e, 'b', 'heal', 50, ctx); // b revive
expect(e.turnOrderIds).toEqual(['a','b','c']); expect(e.turnOrderIds).toEqual(['a','b','c']);
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id)); expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
}); });
+248
View File
@@ -0,0 +1,248 @@
// Logging contract: every mutating combat op writes a log entry to ctx.logPath
// via ctx.storage.addDoc. No-op (no state change) performs NO writes and
// returns the same encounter reference. Display lifecycle ops write the display
// doc, no combat log.
//
// Contract = every combat mutation produces a log entry.
// New shape: mutating funcs are async, take ctx, write encounter (updateDoc) +
// log (addDoc) internally, return newEnc. Logs are read back via storage.logs().
//
// undo payloads now live on the written log entry's `undo_payload.updates`.
'use strict';
const shared = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers');
const {
makeParticipant,
startEncounter, nextTurn, togglePause,
addParticipant, addParticipants, updateParticipant, removeParticipant,
toggleParticipantActive, applyHpChange, deathSave, toggleCondition,
reorderParticipants, endEncounter,
} = shared;
function p(id, init, extra = {}) {
return makeParticipant({ id, name: id, type: 'monster',
initiative: init, maxHp: 100, currentHp: 100, ...extra });
}
function enc(ps, extra = {}) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra };
}
// Assert last written log entry is a well-formed mutation log.
function expectLastLogged(storage) {
const logs = storage.logs();
expect(logs.length).toBeGreaterThan(0);
const last = logs[logs.length - 1];
expect(typeof last.message).toBe('string');
expect(last.message.length).toBeGreaterThan(0);
return last;
}
describe('Logging contract: mutating ops', () => {
let storage, ctx;
beforeEach(() => {
({ storage, ctx } = mockCtx());
});
test('startEncounter logs', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
await startEncounter(e, ctx);
expect(storage.logs()).toHaveLength(1);
expectLastLogged(storage);
});
test('nextTurn logs', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
const started = await startEncounter(e, ctx); // 1 log
await nextTurn(started, ctx); // 2 logs
expect(storage.logs()).toHaveLength(2);
expectLastLogged(storage);
});
test('togglePause does NOT log (lifecycle excluded from undo)', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
const started = await startEncounter(e, ctx);
await togglePause(started, ctx);
expect(storage.logs()).toHaveLength(1); // start only
});
test('addParticipant logs', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
await addParticipant(e, p('d', 5), ctx);
expect(storage.logs()).toHaveLength(1);
expectLastLogged(storage);
});
test('removeParticipant logs', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
await removeParticipant(e, 'b', ctx);
expect(storage.logs()).toHaveLength(1);
expectLastLogged(storage);
});
test('toggleParticipantActive logs', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
await toggleParticipantActive(e, 'b', ctx);
expect(storage.logs()).toHaveLength(1);
expectLastLogged(storage);
});
test('applyHpChange (damage) logs', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
await applyHpChange(e, 'b', 'damage', 10, ctx);
expect(storage.logs()).toHaveLength(1);
expectLastLogged(storage);
});
test('applyHpChange (heal) logs', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
await applyHpChange(e, 'b', 'heal', 5, ctx);
expect(storage.logs()).toHaveLength(1);
expectLastLogged(storage);
});
test('deathSave logs', async () => {
const e = enc([p('a', 10), p('b', 7, { type: 'character' }), p('c', 3)]);
const started = await startEncounter(e, ctx); // 1 log
const dying = await applyHpChange(started, 'b', 'damage', 100, ctx); // 2 logs
const r = await deathSave(dying, 'b', 'fail', ctx); // 3 logs
expect(r.status).toBe('dying');
expect(storage.logs()).toHaveLength(3);
expectLastLogged(storage);
});
test('toggleCondition logs', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
await toggleCondition(e, 'b', 'poisoned', ctx);
expect(storage.logs()).toHaveLength(1);
expectLastLogged(storage);
});
test('reorderParticipants logs (BUG-7)', async () => {
// same-init tie (both 10) for valid reorder (unstarted: no pointer check)
const e2 = enc([p('a', 10), p('x', 10), p('c', 3)]);
await reorderParticipants(e2, 'x', 'a', ctx);
expect(storage.logs()).toHaveLength(1);
expectLastLogged(storage);
});
test('endEncounter logs', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
const started = await startEncounter(e, ctx);
await endEncounter(started, ctx);
expect(storage.logs()).toHaveLength(2);
expectLastLogged(storage);
});
});
describe('Logging contract: no-ops', () => {
let storage, ctx;
beforeEach(() => {
({ storage, ctx } = mockCtx());
});
test('reorder same-id = no-op', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
const newEnc = await reorderParticipants(e, 'a', 'a', ctx);
expect(newEnc).toBe(e); // same ref = no write
expect(storage.logs()).toHaveLength(0);
});
test('reorder cross-init = no-op', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
const newEnc = await reorderParticipants(e, 'a', 'b', ctx);
expect(newEnc).toBe(e); // same ref = no write
expect(storage.logs()).toHaveLength(0);
});
});
describe('Logging undo payloads', () => {
let storage, ctx;
beforeEach(() => {
({ storage, ctx } = mockCtx());
});
test('startEncounter undo restores pre-combat state', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
await startEncounter(e, ctx);
const log = expectLastLogged(storage);
expect(log.undo).toBeDefined();
expect(log.undo.isStarted).toBe(false);
});
test('endEncounter undo restores combat state', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
const started = await startEncounter(e, ctx);
await endEncounter(started, ctx);
const log = expectLastLogged(storage);
expect(log.undo).toBeDefined();
expect(log.undo.isStarted).toBe(true);
});
test('applyHpChange undo restores prior hp', async () => {
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
const newEnc = await applyHpChange(e, 'b', 'damage', 10, ctx);
const log = expectLastLogged(storage);
expect(log.undo).toBeDefined();
const restored = { ...newEnc, ...shared.expandUndo(log, newEnc).updates };
expect(restored.participants.find(x => x.id === 'b').currentHp).toBe(100);
});
test('reorder undo restores prior order (BUG-7)', async () => {
const e2 = enc([p('a', 10), p('x', 10), p('c', 3)]);
const orig = e2.participants.map(p => p.id);
const newEnc = await reorderParticipants(e2, 'x', 'a', ctx);
const log = expectLastLogged(storage);
expect(log.undo).toBeDefined();
const restored = { ...newEnc, ...shared.expandUndo(log, newEnc).updates };
expect(restored.participants.map(p => p.id)).toEqual(orig);
});
});
describe('Logging: addParticipants + updateParticipant', () => {
let storage, ctx;
beforeEach(() => {
({ storage, ctx } = mockCtx());
});
test('addParticipants logs', async () => {
const e = enc([p('a', 10), p('b', 7)]);
await addParticipants(e, [p('c', 3)], ctx);
expect(storage.logs()).toHaveLength(1);
expectLastLogged(storage);
});
test('updateParticipant (same slot) logs', async () => {
const e = enc([p('a', 10), p('b', 7)]);
await updateParticipant(e, 'b', { name: 'B' }, ctx);
expect(storage.logs()).toHaveLength(1);
expectLastLogged(storage);
});
test('updateParticipant (init change) logs', async () => {
const e = enc([p('a', 10), p('b', 7)]);
await updateParticipant(e, 'b', { initiative: 5 }, ctx);
expect(storage.logs()).toHaveLength(1);
expectLastLogged(storage);
});
test('addParticipants undo restores prior list', async () => {
const e = enc([p('a', 10), p('b', 7)]);
const orig = e.participants.map(p => p.id);
const newEnc = await addParticipants(e, [p('c', 3)], ctx);
const log = expectLastLogged(storage);
const restored = { ...newEnc, ...shared.expandUndo(log, newEnc).updates };
expect(restored.participants.map(p => p.id)).toEqual(orig);
});
test('updateParticipant undo restores prior participant', async () => {
const e = enc([p('a', 10), p('b', 7)]);
const orig = e.participants.map(p => p.id);
const newEnc = await updateParticipant(e, 'b', { name: 'B' }, ctx);
const log = expectLastLogged(storage);
const restored = { ...newEnc, ...shared.expandUndo(log, newEnc).updates };
expect(restored.participants.map(p => p.id)).toEqual(orig);
});
});
+27 -21
View File
@@ -7,8 +7,11 @@
// self-inflicted dup (loop spun while paused, re-added same `r${totalTurns}`). // self-inflicted dup (loop spun while paused, re-added same `r${totalTurns}`).
// Validates real bug reachable via normal UI flow (DM adds monster while paused, // Validates real bug reachable via normal UI flow (DM adds monster while paused,
// resumes). // resumes).
//
// Rewritten for async turn.js API: funcs are awaited, take ctx, write own logs.
const shared = require('@ttrpg/shared'); const shared = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers');
const { startEncounter, nextTurn, togglePause, addParticipant, makeParticipant } = shared; const { startEncounter, nextTurn, togglePause, addParticipant, makeParticipant } = shared;
function p(id, initiative, extra = {}) { function p(id, initiative, extra = {}) {
@@ -28,24 +31,25 @@ function enc(ps) {
} }
describe('addParticipant + pause/resume rotation corruption', () => { describe('addParticipant + pause/resume rotation corruption', () => {
test('add fresh participant while paused, resume, rotation completes full cycle', () => { test('add fresh participant while paused, resume, rotation completes full cycle', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 20), p('b', 15), p('c', 10)]; const ps = [p('a', 20), p('b', 15), p('c', 10)];
let e = enc(ps); let e = enc(ps);
e = { ...e, ...startEncounter(e).patch }; e = await startEncounter(e, ctx);
const baseOrder = e.turnOrderIds.slice(); // [a,b,c] const baseOrder = e.turnOrderIds.slice(); // [a,b,c]
e = { ...e, ...nextTurn(e).patch }; // current=b e = await nextTurn(e, ctx); // current=b
e = { ...e, ...togglePause(e).patch }; // pause e = await togglePause(e, ctx); // pause
// add fresh participant x (initiative 25, would sort first) // add fresh participant x (initiative 25, would sort first)
const x = p('x', 25); const x = p('x', 25);
e = { ...e, ...addParticipant(e, x).patch }; e = await addParticipant(e, x, ctx);
e = { ...e, ...togglePause(e).patch }; // resume (rebuilds order) e = await togglePause(e, ctx); // resume (rebuilds order)
// after resume, complete one full round: visit each active participant once // after resume, complete one full round: visit each active participant once
const visited = [e.currentTurnParticipantId]; const visited = [e.currentTurnParticipantId];
for (let i = 0; i < e.turnOrderIds.length - 1; i++) { for (let i = 0; i < e.turnOrderIds.length - 1; i++) {
e = { ...e, ...nextTurn(e).patch }; e = await nextTurn(e, ctx);
visited.push(e.currentTurnParticipantId); visited.push(e.currentTurnParticipantId);
} }
const uniq = new Set(visited); const uniq = new Set(visited);
@@ -53,24 +57,25 @@ describe('addParticipant + pause/resume rotation corruption', () => {
expect(uniq.size).toBe(e.turnOrderIds.length); expect(uniq.size).toBe(e.turnOrderIds.length);
}); });
test('multiple adds while paused, resume, rotation visits all', () => { test('multiple adds while paused, resume, rotation visits all', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 20), p('b', 15), p('c', 10)]; const ps = [p('a', 20), p('b', 15), p('c', 10)];
let e = enc(ps); let e = enc(ps);
e = { ...e, ...startEncounter(e).patch }; e = await startEncounter(e, ctx);
e = { ...e, ...nextTurn(e).patch }; // current=b e = await nextTurn(e, ctx); // current=b
e = { ...e, ...togglePause(e).patch }; // pause e = await togglePause(e, ctx); // pause
// add 3 fresh participants // add 3 fresh participants
for (const id of ['x', 'y', 'z']) { for (const id of ['x', 'y', 'z']) {
const np = p(id, 5 + Math.floor(Math.random() * 30)); const np = p(id, 5 + Math.floor(Math.random() * 30));
e = { ...e, ...addParticipant(e, np).patch }; e = await addParticipant(e, np, ctx);
} }
e = { ...e, ...togglePause(e).patch }; // resume e = await togglePause(e, ctx); // resume
const visited = [e.currentTurnParticipantId]; const visited = [e.currentTurnParticipantId];
for (let i = 0; i < e.turnOrderIds.length + 2; i++) { for (let i = 0; i < e.turnOrderIds.length + 2; i++) {
e = { ...e, ...nextTurn(e).patch }; e = await nextTurn(e, ctx);
visited.push(e.currentTurnParticipantId); visited.push(e.currentTurnParticipantId);
} }
const uniq = new Set(visited); const uniq = new Set(visited);
@@ -78,20 +83,21 @@ describe('addParticipant + pause/resume rotation corruption', () => {
expect(uniq.size).toBe(e.turnOrderIds.length); expect(uniq.size).toBe(e.turnOrderIds.length);
}); });
test('add while running, then pause+resume, rotation stays valid', () => { test('add while running, then pause+resume, rotation stays valid', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 20), p('b', 15), p('c', 10)]; const ps = [p('a', 20), p('b', 15), p('c', 10)];
let e = enc(ps); let e = enc(ps);
e = { ...e, ...startEncounter(e).patch }; e = await startEncounter(e, ctx);
e = { ...e, ...nextTurn(e).patch }; // current=b e = await nextTurn(e, ctx); // current=b
const x = p('x', 25); const x = p('x', 25);
e = { ...e, ...addParticipant(e, x).patch }; // add while running e = await addParticipant(e, x, ctx); // add while running
e = { ...e, ...togglePause(e).patch }; // pause e = await togglePause(e, ctx); // pause
e = { ...e, ...togglePause(e).patch }; // resume e = await togglePause(e, ctx); // resume
const visited = [e.currentTurnParticipantId]; const visited = [e.currentTurnParticipantId];
for (let i = 0; i < e.turnOrderIds.length + 2; i++) { for (let i = 0; i < e.turnOrderIds.length + 2; i++) {
e = { ...e, ...nextTurn(e).patch }; e = await nextTurn(e, ctx);
visited.push(e.currentTurnParticipantId); visited.push(e.currentTurnParticipantId);
} }
const uniq = new Set(visited); const uniq = new Set(visited);
+29 -20
View File
@@ -1,7 +1,11 @@
// removeParticipant + computeTurnOrderAfterRemoval edge cases. // removeParticipant + computeTurnOrderAfterRemoval edge cases.
//
// New API: mutating funcs are async, take ctx last, write encounter + log
// internally, return newEnc. Chained calls become `e = await fn(e, ctx)`.
const shared = require('@ttrpg/shared'); const shared = require('@ttrpg/shared');
const { makeParticipant, startEncounter, nextTurn, removeParticipant, toggleParticipantActive, applyHpChange } = shared; const { makeParticipant, startEncounter, nextTurn, removeParticipant, toggleParticipantActive, applyHpChange } = shared;
const { mockCtx } = require('./_helpers');
function p(id, init, extra = {}) { function p(id, init, extra = {}) {
return makeParticipant({ id, name: id, type: 'monster', return makeParticipant({ id, name: id, type: 'monster',
@@ -13,51 +17,56 @@ function enc(ps) {
} }
describe('removeParticipant turn-order edges', () => { describe('removeParticipant turn-order edges', () => {
test('removing current picks next active as current', () => { test('removing current picks next active as current', async () => {
const { ctx } = mockCtx();
let e = enc([p('a',20),p('b',15),p('c',10)]); let e = enc([p('a',20),p('b',15),p('c',10)]);
e = { ...e, ...startEncounter(e).patch }; e = await startEncounter(e, ctx);
e = { ...e, ...removeParticipant(e, 'a').patch }; // a was current e = await removeParticipant(e, 'a', ctx); // a was current
expect(e.currentTurnParticipantId).toBe('b'); expect(e.currentTurnParticipantId).toBe('b');
}); });
test('removing last in order wraps current to first', () => { test('removing last in order wraps current to first', async () => {
const { ctx } = mockCtx();
let e = enc([p('a',20),p('b',15),p('c',10)]); let e = enc([p('a',20),p('b',15),p('c',10)]);
e = { ...e, ...startEncounter(e).patch }; e = await startEncounter(e, ctx);
e = { ...e, ...nextTurn(e).patch }; // b e = await nextTurn(e, ctx); // b
e = { ...e, ...nextTurn(e).patch }; // c (current) e = await nextTurn(e, ctx); // c (current)
e = { ...e, ...removeParticipant(e, 'c').patch }; e = await removeParticipant(e, 'c', ctx);
expect(e.currentTurnParticipantId).toBe('a'); expect(e.currentTurnParticipantId).toBe('a');
}); });
test('removing current when all others inactive → no active, isStarted stays (BUG-9 candidate)', () => { test('removing current when all others inactive → no active, isStarted stays (BUG-9 candidate)', async () => {
const { ctx } = mockCtx();
let e = enc([p('a',20),p('b',15),p('c',10)]); let e = enc([p('a',20),p('b',15),p('c',10)]);
e = { ...e, ...startEncounter(e).patch }; // [a,b,c], cur=a e = await startEncounter(e, ctx); // [a,b,c], cur=a
// deactivate b + c (stay in slot, inactive) // deactivate b + c (stay in slot, inactive)
e = { ...e, ...toggleParticipantActive(e, 'b').patch }; e = await toggleParticipantActive(e, 'b', ctx);
e = { ...e, ...toggleParticipantActive(e, 'c').patch }; e = await toggleParticipantActive(e, 'c', ctx);
// remove current a // remove current a
e = { ...e, ...removeParticipant(e, 'a').patch }; e = await removeParticipant(e, 'a', ctx);
// 1-list: turnOrderIds=[b,c], no active → current null, isStarted stays true // 1-list: turnOrderIds=[b,c], no active → current null, isStarted stays true
expect(e.turnOrderIds).toEqual(['b', 'c']); expect(e.turnOrderIds).toEqual(['b', 'c']);
expect(e.currentTurnParticipantId).toBeNull(); expect(e.currentTurnParticipantId).toBeNull();
// isStarted still true but no turn → nextTurn throws (stale state) // isStarted still true but no turn → nextTurn throws (stale state)
}); });
test('removing non-current keeps currentTurn', () => { test('removing non-current keeps currentTurn', async () => {
const { ctx } = mockCtx();
let e = enc([p('a',20),p('b',15),p('c',10)]); let e = enc([p('a',20),p('b',15),p('c',10)]);
e = { ...e, ...startEncounter(e).patch }; e = await startEncounter(e, ctx);
e = { ...e, ...removeParticipant(e, 'b').patch }; e = await removeParticipant(e, 'b', ctx);
expect(e.currentTurnParticipantId).toBe('a'); expect(e.currentTurnParticipantId).toBe('a');
expect(e.turnOrderIds).toEqual(['a', 'c']); expect(e.turnOrderIds).toEqual(['a', 'c']);
}); });
test('removing current that is dead (HP=0) - BUG-3 overlap', () => { test('removing current that is dead (HP=0) - BUG-3 overlap', async () => {
// Dead participant removed mid-combat. Desired (M4): they STAY in order. // Dead participant removed mid-combat. Desired (M4): they STAY in order.
// removeParticipant is explicit DM action, distinct from auto-skip. // removeParticipant is explicit DM action, distinct from auto-skip.
const { ctx } = mockCtx();
let e = enc([p('a',20),p('b',15),p('c',10)]); let e = enc([p('a',20),p('b',15),p('c',10)]);
e = { ...e, ...startEncounter(e).patch }; e = await startEncounter(e, ctx);
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch }; // b dead e = await applyHpChange(e, 'b', 'damage', 100, ctx); // b dead
e = { ...e, ...removeParticipant(e, 'b').patch }; e = await removeParticipant(e, 'b', ctx);
expect(e.turnOrderIds).not.toContain('b'); expect(e.turnOrderIds).not.toContain('b');
expect(e.participants.find(x => x.id === 'b')).toBeUndefined(); expect(e.participants.find(x => x.id === 'b')).toBeUndefined();
}); });
+58 -26
View File
@@ -1,9 +1,14 @@
// Characterization for reorderParticipants correct usage. // Characterization for reorderParticipants correct usage.
// replay-combat.js calls it with wrong signature (swallowed by try/catch), // replay-combat.js calls it with wrong signature (swallowed by try/catch),
// so real behavior untested. Lock what it actually does. // so real behavior untested. Lock what it actually does.
//
// New API: reorderParticipants is async, takes ctx last, writes encounter +
// log internally, returns newEnc. A blocked move (cross-init / same id /
// cross-pointer) returns the SAME enc ref (no write). Missing id rejects.
const shared = require('@ttrpg/shared'); const shared = require('@ttrpg/shared');
const { makeParticipant, startEncounter, nextTurn, reorderParticipants } = shared; const { makeParticipant, startEncounter, nextTurn, reorderParticipants } = shared;
const { mockCtx } = require('./_helpers');
function p(id, init, extra = {}) { function p(id, init, extra = {}) {
return makeParticipant({ return makeParticipant({
@@ -18,50 +23,77 @@ function enc(ps) {
} }
describe('reorderParticipants', () => { describe('reorderParticipants', () => {
test('drag before target (1-list model)', () => { test('drag upward inserts before target (1-list model, pre-combat)', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10), p('b', 20), p('c', 20)]; // b,c tie 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 }; const newEnc = await reorderParticipants(e, 'c', 'b', ctx);
// initial order: b,c,a (init 20,20,10) // drag c upward onto b: insert before b → [a,c,b]
expect(e.turnOrderIds).toEqual(['b', 'c', 'a']); expect(newEnc.participants.map(p => p.id)).toEqual(['a', 'c', 'b']);
const r = reorderParticipants(e, 'c', 'b');
// drag c before b: remove c → [b,a], insert before b → [c,b,a]
expect(r.patch.participants.map(p => p.id)).toEqual(['c', 'b', 'a']);
}); });
test('cross-init drag allowed (1-list, DM override)', () => { test('drag downward inserts after target (1-list model, pre-combat)', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 20), p('b', 20), p('c', 10)]; // a,b tie
let e = enc(ps); // pre-combat, no pointer
const newEnc = await reorderParticipants(e, 'a', 'b', ctx);
// drag a downward onto b: insert after b → [b,a,c]
expect(newEnc.participants.map(p => p.id)).toEqual(['b', 'a', 'c']);
});
test('cross-init drag blocked (no-op)', async () => {
const { storage, ctx } = mockCtx();
const ps = [p('a', 10), p('b', 20)]; const ps = [p('a', 10), p('b', 20)];
let e = enc(ps); let e = enc(ps);
e = { ...e, ...startEncounter(e).patch }; // [b,a] e = await startEncounter(e, ctx); // [b,a]
const r = reorderParticipants(e, 'a', 'b'); const before = storage.calls.filter(c => c.fn === 'updateDoc').length;
expect(r.patch.participants.map(p => p.id)).toEqual(['a', 'b']); const newEnc = await reorderParticipants(e, 'a', 'b', ctx);
expect(newEnc).toBe(e); // same ref = no write
expect(storage.calls.filter(c => c.fn === 'updateDoc')).toHaveLength(before); // no new write
}); });
test('throws if id not found', () => { test('throws if id not found', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10), p('b', 20)]; const ps = [p('a', 10), p('b', 20)];
let e = enc(ps); let e = enc(ps);
e = { ...e, ...startEncounter(e).patch }; e = await startEncounter(e, ctx);
expect(() => reorderParticipants(e, 'a', 'zzz')).toThrow(); await expect(reorderParticipants(e, 'a', 'zzz', ctx)).rejects.toThrow();
}); });
test('syncs turnOrderIds = participants order (1-list, fixes BUG-6)', () => { test('paused combat allows reorder across current-turn pointer', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 20), p('b', 20), p('c', 20), p('d', 20)];
let e = await startEncounter(enc(ps), ctx); // current=a
e = { ...e, isPaused: true };
const newEnc = await reorderParticipants(e, 'd', 'a', ctx);
expect(newEnc).not.toBe(e);
expect(newEnc.participants.map(p => p.id)).toEqual(['d', 'a', 'b', 'c']);
expect(newEnc.turnOrderIds).toEqual(['d', 'a', 'b', 'c']);
});
test('syncs turnOrderIds = participants order (1-list, fixes BUG-6)', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 10), p('b', 20), p('c', 20)]; 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 = await startEncounter(e, ctx); // 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 = await reorderParticipants(pre, 'c', 'b', ctx);
expect(pre.turnOrderIds).toEqual(['a','c','b']);
expect(pre.turnOrderIds).toEqual(pre.participants.map(p => p.id));
}); });
// BUG-6 candidate: reorder should affect turnOrderIds so mid-combat // 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)', async () => {
const { ctx } = mockCtx();
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 }; e = await reorderParticipants(e, 'c', 'b', ctx);
// order: b,c,a expect(e.turnOrderIds).toEqual(['a', 'c', 'b']);
e = { ...e, ...reorderParticipants(e, 'c', 'b').patch };
expect(e.turnOrderIds).toEqual(['c', 'b', 'a']);
}); });
}); });
+39 -33
View File
@@ -2,9 +2,10 @@
// Audit of 100-round replay found 124 skips + 78 dupes (round 1 already missing Fighter // Audit of 100-round replay found 124 skips + 78 dupes (round 1 already missing Fighter
// before any coverage action). nextTurn has core bug, not just coverage-path issue. // before any coverage action). nextTurn has core bug, not just coverage-path issue.
// //
// This test is RED until nextTurn fixed. // Rewritten for async turn.js API: funcs are awaited, take ctx, write own logs.
const shared = require('@ttrpg/shared'); const shared = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers');
const { startEncounter, nextTurn, makeParticipant } = shared; const { startEncounter, nextTurn, makeParticipant } = shared;
function p(id, initiative, extra = {}) { function p(id, initiative, extra = {}) {
@@ -24,17 +25,18 @@ function enc(ps) {
} }
describe('round rotation integrity', () => { describe('round rotation integrity', () => {
test('3 participants: one full round visits each exactly once', () => { test('3 participants: one full round visits each exactly once', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 20), p('b', 15), p('c', 10)]; const ps = [p('a', 20), p('b', 15), p('c', 10)];
let e = enc(ps); let e = enc(ps);
e = { ...e, ...startEncounter(e).patch }; e = await startEncounter(e, ctx);
const startOrder = e.turnOrderIds.slice(); const startOrder = e.turnOrderIds.slice();
const visited = [e.currentTurnParticipantId]; const visited = [e.currentTurnParticipantId];
// advance (len-1) turns: visits remaining participants, round NOT yet wrapped. // advance (len-1) turns: visits remaining participants, round NOT yet wrapped.
for (let i = 0; i < startOrder.length - 1; i++) { for (let i = 0; i < startOrder.length - 1; i++) {
e = { ...e, ...nextTurn(e).patch }; e = await nextTurn(e, ctx);
visited.push(e.currentTurnParticipantId); visited.push(e.currentTurnParticipantId);
} }
@@ -44,18 +46,19 @@ describe('round rotation integrity', () => {
expect(visited.length).toBe(startOrder.length); expect(visited.length).toBe(startOrder.length);
}); });
test('8 participants (replay shape): one full round visits each exactly once', () => { test('8 participants (replay shape): one full round visits each exactly once', async () => {
const { ctx } = mockCtx();
const ps = [ const ps = [
p('Goblin1', 12), p('Wolf', 13), p('Merchant', 8), p('OrcBoss', 11), p('Goblin1', 12), p('Wolf', 13), p('Merchant', 8), p('OrcBoss', 11),
p('Goblin2', 12), p('Fighter', 14), p('Rogue', 15), p('Cleric', 10), p('Goblin2', 12), p('Fighter', 14), p('Rogue', 15), p('Cleric', 10),
]; ];
let e = enc(ps); let e = enc(ps);
e = { ...e, ...startEncounter(e).patch }; e = await startEncounter(e, ctx);
const startOrder = e.turnOrderIds.slice(); const startOrder = e.turnOrderIds.slice();
const visited = [e.currentTurnParticipantId]; const visited = [e.currentTurnParticipantId];
for (let i = 0; i < startOrder.length - 1; i++) { for (let i = 0; i < startOrder.length - 1; i++) {
e = { ...e, ...nextTurn(e).patch }; e = await nextTurn(e, ctx);
visited.push(e.currentTurnParticipantId); visited.push(e.currentTurnParticipantId);
} }
@@ -65,10 +68,11 @@ describe('round rotation integrity', () => {
expect(visited.length).toBe(startOrder.length); expect(visited.length).toBe(startOrder.length);
}); });
test('multiple rounds: each round visits each participant exactly once', () => { test('multiple rounds: each round visits each participant exactly once', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)]; const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)];
let e = enc(ps); let e = enc(ps);
e = { ...e, ...startEncounter(e).patch }; e = await startEncounter(e, ctx);
const startOrder = e.turnOrderIds.slice(); const startOrder = e.turnOrderIds.slice();
const expectedRound = e.round; const expectedRound = e.round;
@@ -76,7 +80,7 @@ describe('round rotation integrity', () => {
// capture exactly one full round (current + len-1 advances), no wrap yet. // capture exactly one full round (current + len-1 advances), no wrap yet.
const visited = [e.currentTurnParticipantId]; const visited = [e.currentTurnParticipantId];
for (let i = 0; i < startOrder.length - 1; i++) { for (let i = 0; i < startOrder.length - 1; i++) {
e = { ...e, ...nextTurn(e).patch }; e = await nextTurn(e, ctx);
visited.push(e.currentTurnParticipantId); visited.push(e.currentTurnParticipantId);
} }
const uniq = new Set(visited); const uniq = new Set(visited);
@@ -88,63 +92,65 @@ describe('round rotation integrity', () => {
describe('round rotation with mid-round state changes', () => { describe('round rotation with mid-round state changes', () => {
const { toggleParticipantActive, addParticipant, removeParticipant, reorderParticipants, applyHpChange } = shared; const { toggleParticipantActive, addParticipant, removeParticipant, reorderParticipants, applyHpChange } = shared;
test('toggle a participant inactive mid-round, others still each visited once', () => { test('toggle a participant inactive mid-round, others still each visited once', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)]; const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)];
let e = enc(ps); let e = enc(ps);
e = { ...e, ...startEncounter(e).patch }; e = await startEncounter(e, ctx);
const startOrder = e.turnOrderIds.slice(); const startOrder = e.turnOrderIds.slice();
const visited = [e.currentTurnParticipantId]; const visited = [e.currentTurnParticipantId];
e = { ...e, ...nextTurn(e).patch }; visited.push(e.currentTurnParticipantId); e = await nextTurn(e, ctx); visited.push(e.currentTurnParticipantId);
// now mark 'a' inactive (already took its turn) // now mark 'a' inactive (already took its turn)
e = { ...e, ...toggleParticipantActive(e, 'a').patch }; e = await toggleParticipantActive(e, 'a', ctx);
e = { ...e, ...nextTurn(e).patch }; visited.push(e.currentTurnParticipantId); e = await nextTurn(e, ctx); visited.push(e.currentTurnParticipantId);
e = { ...e, ...nextTurn(e).patch }; visited.push(e.currentTurnParticipantId); e = await nextTurn(e, ctx); visited.push(e.currentTurnParticipantId);
// round should wrap, but 'a' inactive so only b,c,d visited // round should wrap, but 'a' inactive so only b,c,d visited
const visitedActive = visited.filter(id => id !== 'a'); const visitedActive = visited.filter(id => id !== 'a');
const uniq = new Set(visitedActive); const uniq = new Set(visitedActive);
expect(uniq.size).toBe(startOrder.length - 1); // b,c,d each once expect(uniq.size).toBe(startOrder.length - 1); // b,c,d each once
}); });
test('reactivate inactive participant mid-round, it gets a turn this round', () => { test('reactivate inactive participant mid-round, it gets a turn this round', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)]; const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)];
let e = enc(ps); let e = enc(ps);
// start with 'c' inactive // start with 'c' inactive
e.participants = e.participants.map(p => p.id === 'c' ? { ...p, isActive: false } : p); e.participants = e.participants.map(p => p.id === 'c' ? { ...p, isActive: false } : p);
e = { ...e, ...startEncounter(e).patch }; e = await startEncounter(e, ctx);
// 1-list: c stays in slot (inactive), skipped by nextTurn // 1-list: c stays in slot (inactive), skipped by nextTurn
expect(e.turnOrderIds).toEqual(['a', 'b', 'c', 'd']); expect(e.turnOrderIds).toEqual(['a', 'b', 'c', 'd']);
expect(e.currentTurnParticipantId).toBe('a'); // c inactive, a first expect(e.currentTurnParticipantId).toBe('a'); // c inactive, a first
// advance one turn, then reactivate c // advance one turn, then reactivate c
e = { ...e, ...nextTurn(e).patch }; // b e = await nextTurn(e, ctx); // b
e = { ...e, ...toggleParticipantActive(e, 'c').patch }; e = await toggleParticipantActive(e, 'c', ctx);
// continue rotation - c should now be reachable // continue rotation - c should now be reachable
const visited = [e.currentTurnParticipantId]; const visited = [e.currentTurnParticipantId];
for (let i = 0; i < e.turnOrderIds.length; i++) { for (let i = 0; i < e.turnOrderIds.length; i++) {
e = { ...e, ...nextTurn(e).patch }; e = await nextTurn(e, ctx);
visited.push(e.currentTurnParticipantId); visited.push(e.currentTurnParticipantId);
} }
expect(visited).toContain('c'); expect(visited).toContain('c');
}); });
test('addParticipant mid-round: new participant gets turn this round or next', () => { test('addParticipant mid-round: new participant gets turn this round or next', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 20), p('b', 15), p('c', 10)]; const ps = [p('a', 20), p('b', 15), p('c', 10)];
let e = enc(ps); let e = enc(ps);
e = { ...e, ...startEncounter(e).patch }; e = await startEncounter(e, ctx);
const startOrder = e.turnOrderIds.slice(); const startOrder = e.turnOrderIds.slice();
e = { ...e, ...nextTurn(e).patch }; // advance one e = await nextTurn(e, ctx); // advance one
// add new participant // add new participant
const newP = p('x', 25); const newP = p('x', 25);
e = { ...e, ...addParticipant(e, newP).patch }; e = await addParticipant(e, newP, ctx);
// finish round - original 3 should still each get exactly one turn // finish round - original 3 should still each get exactly one turn
const visited = [startOrder[0], e.currentTurnParticipantId]; const visited = [startOrder[0], e.currentTurnParticipantId];
while (e.round === 1) { while (e.round === 1) {
const r = nextTurn(e); e = await nextTurn(e, ctx);
e = { ...e, ...r.patch };
visited.push(e.currentTurnParticipantId); visited.push(e.currentTurnParticipantId);
if (visited.length > 20) break; // safety if (visited.length > 20) break; // safety
} }
@@ -153,23 +159,23 @@ describe('round rotation with mid-round state changes', () => {
expect(uniq.size).toBe(3); expect(uniq.size).toBe(3);
}); });
test('reorderParticipants mid-round keeps rotation valid', () => { test('reorderParticipants mid-round keeps rotation valid', async () => {
const { ctx } = mockCtx();
const ps = [p('a', 20), p('b', 15), p('c', 15), p('d', 5)]; // b,c same init (15) const ps = [p('a', 20), p('b', 15), p('c', 15), p('d', 5)]; // b,c same init (15)
let e = enc(ps); let e = enc(ps);
e = { ...e, ...startEncounter(e).patch }; e = await startEncounter(e, ctx);
const startOrder = e.turnOrderIds.slice(); const startOrder = e.turnOrderIds.slice();
e = { ...e, ...nextTurn(e).patch }; e = await nextTurn(e, ctx);
// reorder: swap b,c (same initiative) // reorder: swap b,c (same initiative)
e = { ...e, ...reorderParticipants(e, 'b', 'c').patch }; e = await reorderParticipants(e, 'b', 'c', ctx);
const visited = [startOrder[0], e.currentTurnParticipantId]; const visited = [startOrder[0], e.currentTurnParticipantId];
for (let i = 0; i < startOrder.length; i++) { for (let i = 0; i < startOrder.length; i++) {
e = { ...e, ...nextTurn(e).patch }; e = await nextTurn(e, ctx);
visited.push(e.currentTurnParticipantId); visited.push(e.currentTurnParticipantId);
} }
const uniq = new Set(visited); const uniq = new Set(visited);
expect(uniq.size).toBeGreaterThanOrEqual(startOrder.length); expect(uniq.size).toBeGreaterThanOrEqual(startOrder.length);
}); });
}); });
+22 -16
View File
@@ -4,6 +4,10 @@
// //
// Guards BUG-5 fix (slot-array turn order, no re-sort on wrap/resume). // Guards BUG-5 fix (slot-array turn order, no re-sort on wrap/resume).
// If this goes RED, turn order rotation is skipping participants again. // If this goes RED, turn order rotation is skipping participants again.
//
// New API: mutating funcs are async, take ctx last, write encounter + log
// internally, return newEnc. setup(ctx) returns the started encounter;
// loop bodies await each mutating call.
'use strict'; 'use strict';
@@ -13,14 +17,14 @@ const {
startEncounter, nextTurn, togglePause, addParticipant, removeParticipant, startEncounter, nextTurn, togglePause, addParticipant, removeParticipant,
toggleParticipantActive, toggleParticipantActive,
} = shared; } = shared;
const { mockCtx } = require('./_helpers');
const apply = (e, r) => (r && r.patch) ? { ...e, ...r.patch } : e;
const nm = (enc) => (id) => { const nm = (enc) => (id) => {
const f = enc.participants.find(p => p.id === id); const f = enc.participants.find(p => p.id === id);
return f ? f.name : id; return f ? f.name : id;
}; };
function setup() { function setup(ctx) {
const ps = [ const ps = [
buildCharacterParticipant({ id: 'c1', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 }).participant, buildCharacterParticipant({ id: 'c1', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 }).participant,
buildCharacterParticipant({ id: 'c2', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 }).participant, buildCharacterParticipant({ id: 'c2', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 }).participant,
@@ -29,20 +33,21 @@ function setup() {
buildMonsterParticipant({ name: 'Goblin2', maxHp: 100, initMod: 2 }).participant, buildMonsterParticipant({ name: 'Goblin2', maxHp: 100, initMod: 2 }).participant,
buildMonsterParticipant({ name: 'OrcBoss', maxHp: 500, initMod: 1 }).participant, buildMonsterParticipant({ name: 'OrcBoss', maxHp: 500, initMod: 1 }).participant,
buildMonsterParticipant({ name: 'Wolf', maxHp: 120, initMod: 3 }).participant, buildMonsterParticipant({ name: 'Wolf', maxHp: 120, initMod: 3 }).participant,
buildMonsterParticipant({ name: 'Merchant', maxHp: 150, initMod: 0, isNpc: true }).participant, buildMonsterParticipant({ name: 'Merchant', maxHp: 150, initMod: 0, asNpc: true }).participant,
]; ];
let e = { let e = {
name: 't', participants: ps, isStarted: false, isPaused: false, name: 't', participants: ps, isStarted: false, isPaused: false,
round: 0, currentTurnParticipantId: null, turnOrderIds: [], round: 0, currentTurnParticipantId: null, turnOrderIds: [],
}; };
return apply(e, startEncounter(e)); return startEncounter(e, ctx); // async → Promise<newEnc>
} }
describe('BUG-5: turn-order rotation never skips (deterministic)', () => { describe('BUG-5: turn-order rotation never skips (deterministic)', () => {
jest.setTimeout(15000); jest.setTimeout(15000);
test('pure nextTurn: 0 skips across 100 rounds', () => { test('pure nextTurn: 0 skips across 100 rounds', async () => {
let e = setup(); const { ctx } = mockCtx();
let e = await setup(ctx);
let totalSkips = 0; let totalSkips = 0;
for (let roundN = 1; roundN <= 100; roundN++) { for (let roundN = 1; roundN <= 100; roundN++) {
const startRound = e.round; const startRound = e.round;
@@ -52,7 +57,7 @@ describe('BUG-5: turn-order rotation never skips (deterministic)', () => {
let guard = 0; let guard = 0;
const cap = e.participants.length + 1; const cap = e.participants.length + 1;
while (e.round === startRound && guard < cap) { while (e.round === startRound && guard < cap) {
e = apply(e, nextTurn(e)); e = await nextTurn(e, ctx);
if (e.round === startRound) acted.add(e.currentTurnParticipantId); if (e.round === startRound) acted.add(e.currentTurnParticipantId);
guard++; guard++;
} }
@@ -65,8 +70,9 @@ describe('BUG-5: turn-order rotation never skips (deterministic)', () => {
expect(totalSkips).toBe(0); expect(totalSkips).toBe(0);
}); });
test('with pause/resume + add/remove/toggle: 0 skips across ~540 rounds', () => { test('with pause/resume + add/remove/toggle: 0 skips across ~540 rounds', async () => {
let e = setup(); const { ctx } = mockCtx();
let e = await setup(ctx);
const N = nm(e); const N = nm(e);
let curRound = null; let curRound = null;
let activeAtRoundStart = new Set(); let activeAtRoundStart = new Set();
@@ -85,10 +91,10 @@ describe('BUG-5: turn-order rotation never skips (deterministic)', () => {
const MAX_TURNS = 2000; const MAX_TURNS = 2000;
while (turns < MAX_TURNS && e.isStarted) { while (turns < MAX_TURNS && e.isStarted) {
turns++; turns++;
if (e.isPaused) e = apply(e, togglePause(e)); if (e.isPaused) e = await togglePause(e, ctx);
if (turns % 7 === 0 && !e.isPaused) { e = apply(e, togglePause(e)); continue; } if (turns % 7 === 0 && !e.isPaused) { e = await togglePause(e, ctx); continue; }
const prevRound = e.round; const prevRound = e.round;
e = apply(e, nextTurn(e)); e = await nextTurn(e, ctx);
if (e.round !== prevRound) { if (e.round !== prevRound) {
const skipped = [...activeAtRoundStart].filter(id => { const skipped = [...activeAtRoundStart].filter(id => {
const p = e.participants.find(x => x.id === id); const p = e.participants.find(x => x.id === id);
@@ -102,18 +108,18 @@ describe('BUG-5: turn-order rotation never skips (deterministic)', () => {
if (turns % 9 === 0 && added < 8) { if (turns % 9 === 0 && added < 8) {
const b = buildMonsterParticipant({ name: `R${added + 1}`, maxHp: 120, initMod: 3 }).participant; const b = buildMonsterParticipant({ name: `R${added + 1}`, maxHp: 120, initMod: 3 }).participant;
b.id = `reinforce${added + 1}`; b.id = `reinforce${added + 1}`;
e = apply(e, addParticipant(e, b)); added++; e = await addParticipant(e, b, ctx); added++;
} }
if (turns % 13 === 0) { if (turns % 13 === 0) {
const cand = e.participants.filter(p => p.type === 'monster' && p.isActive && p.id !== e.currentTurnParticipantId); const cand = e.participants.filter(p => p.type === 'monster' && p.isActive && p.id !== e.currentTurnParticipantId);
if (cand.length) e = apply(e, removeParticipant(e, cand[0].id)); if (cand.length) e = await removeParticipant(e, cand[0].id, ctx);
} }
if (turns % 17 === 0) { if (turns % 17 === 0) {
const cand = e.participants.filter(p => p.isActive && p.id !== e.currentTurnParticipantId); const cand = e.participants.filter(p => p.isActive && p.id !== e.currentTurnParticipantId);
if (cand.length) { if (cand.length) {
const t = cand[0]; const t = cand[0];
e = apply(e, toggleParticipantActive(e, t.id)); e = await toggleParticipantActive(e, t.id, ctx);
e = apply(e, toggleParticipantActive(e, t.id)); e = await toggleParticipantActive(e, t.id, ctx);
} }
} }
} }
+81
View File
@@ -0,0 +1,81 @@
// SLOT-NOT-SORT invariant: addParticipant + updateParticipant must PRESERVE
// manually-dragged tie order (same-initiative participants).
//
// Design doc (docs/INITIATIVE_ORDERING.md):
// "Re-slotting on add/edit must preserve drag-established tie order."
// "Tie-break = original add order. Later additions slot AFTER existing
// same-init participants. Stable insertion."
//
// startEncounter re-sorts once to freeze the list; after that add/update use
// stable slot insertion (slotIndexForInit), never wholesale re-sort — so drag
// tie order survives.
//
// New API: addParticipant / updateParticipant / reorderParticipants are async,
// take ctx, return newEnc.
'use strict';
const {
makeParticipant,
startEncounter, addParticipant, updateParticipant, reorderParticipants,
} = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers');
function p(id, init, extra = {}) {
return makeParticipant({ id, name: id, type: 'monster',
initiative: init, maxHp: 100, currentHp: 100, ...extra });
}
function enc(ps, extra = {}) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra };
}
describe('SLOT-NOT-SORT: drag tie order survives add/edit', () => {
test('addParticipant preserves existing drag tie order', async () => {
const { ctx } = mockCtx();
// Two same-init participants. DM drags b BEFORE a (tie override).
let e = enc([p('a', 10), p('b', 10)]);
e = await reorderParticipants(e, 'b', 'a', ctx); // drag b ahead of a → [b,a]
expect(e.participants.map(x => x.id)).toEqual(['b', 'a']);
// Add unrelated participant (different init). Tie order [b,a] MUST survive.
e = await addParticipant(e, p('c', 5), ctx);
const ids = e.participants.map(x => x.id);
// c slots last (init 5 < 10). b,a tie order preserved.
expect(ids).toEqual(['b', 'a', 'c']);
});
test('addParticipant with same init as dragged pair slots AFTER tie group', async () => {
const { ctx } = mockCtx();
// DM drags b before a (both init 10). Then add d also init 10.
// d must slot AFTER existing same-init pair (stable insertion), not re-sort.
let e = enc([p('a', 10), p('b', 10)]);
e = await reorderParticipants(e, 'b', 'a', ctx); // [b,a]
e = await addParticipant(e, p('d', 10), ctx); // init 10, after tie group
const ids = e.participants.map(x => x.id);
expect(ids).toEqual(['b', 'a', 'd']);
});
test('updateParticipant preserves drag tie order on unrelated field edit', async () => {
const { ctx } = mockCtx();
let e = enc([p('a', 10), p('b', 10)]);
e = await reorderParticipants(e, 'b', 'a', ctx); // [b,a]
// Edit a's name (not initiative). Tie order MUST survive.
e = await updateParticipant(e, 'a', { name: 'A2' }, ctx);
expect(e.participants.map(x => x.id)).toEqual(['b', 'a']);
});
test('updateParticipant init change slots, preserves OTHER tie group order', async () => {
const { ctx } = mockCtx();
// Two tie groups: [a,b] init 10, [c,d] init 5. DM drags b before a.
let e = enc([p('a', 10), p('b', 10), p('c', 5), p('d', 5)]);
e = await reorderParticipants(e, 'b', 'a', ctx); // [b,a,c,d]
// Edit c's initiative to 10 — slots into top group. [c] order within its
// own old group irrelevant (it leaves that group). But [b,a] tie MUST stay.
e = await updateParticipant(e, 'c', { initiative: 10 }, ctx);
const topTwo = e.participants.filter(x => x.initiative === 10).map(x => x.id);
// b before a preserved. c joins the 10-group (exact slot less critical
// than b,a order surviving).
expect(topTwo.indexOf('b')).toBeLessThan(topTwo.indexOf('a'));
});
});
+147 -70
View File
@@ -1,7 +1,17 @@
// Undo roundtrip: every op that returns log.undo must restore prior state. // Undo + redo roundtrip: every op that writes a log entry must restore prior
// Apply op → patch → apply undo → assert deepEqual original. // state on undo AND re-apply forward state on redo. Uses REAL mock storage
// undo() (applies patch + flips undone flag), not just expandUndo output.
//
// Pattern per case:
// before = snap(enc) // state before op
// newEnc = await op(enc) // apply op (writes enc doc + log entry)
// afterUndo = undoLast() // real undo via storage.undo
// expect(afterUndo) === before
// afterRedo = redoLast() // real redo via storage.undo({redo:true})
// expect(afterRedo) === snap(newEnc)
const shared = require('@ttrpg/shared'); const shared = require('@ttrpg/shared');
const { mockCtx, undoLast, redoLast } = require('./_helpers');
const { expandUndo } = shared;
const { const {
makeParticipant, startEncounter, nextTurn, togglePause, makeParticipant, startEncounter, nextTurn, togglePause,
addParticipant, removeParticipant, toggleParticipantActive, addParticipant, removeParticipant, toggleParticipantActive,
@@ -21,110 +31,177 @@ function enc(ps) {
} }
const snap = (e) => JSON.parse(JSON.stringify(e)); const snap = (e) => JSON.parse(JSON.stringify(e));
describe('undo roundtrip', () => { describe('undo + redo roundtrip', () => {
test('startEncounter undo restores pre-start', () => { test('startEncounter', async () => {
const { storage, ctx } = mockCtx();
const before = enc([p('a',10),p('b',20)]); const before = enc([p('a',10),p('b',20)]);
const r = startEncounter(before); await storage.setDoc(ctx.encPath, before);
expect(r.log.undo).toBeTruthy(); const newEnc = await startEncounter(before, ctx);
// undo restores isStarted/isPaused/round/current/turnOrderIds. const last = storage.logs().at(-1);
// participants[] may be reordered (1-list sort on start) — undo snapshot const afterUndo = await undoLast(ctx, newEnc);
// captures turn-state fields, not participant order. // participants[] order may differ (start sorts); check turn-state fields.
const after = { ...before, ...r.patch, ...r.log.undo }; expect(afterUndo.isStarted).toBe(before.isStarted);
expect(after.isStarted).toBe(before.isStarted); expect(afterUndo.isPaused).toBe(before.isPaused);
expect(after.isPaused).toBe(before.isPaused); expect(afterUndo.round).toBe(before.round);
expect(after.round).toBe(before.round); expect(afterUndo.currentTurnParticipantId).toBe(before.currentTurnParticipantId);
expect(after.currentTurnParticipantId).toBe(before.currentTurnParticipantId); expect(afterUndo.turnOrderIds).toEqual(before.turnOrderIds);
expect(after.turnOrderIds).toEqual(before.turnOrderIds); const afterRedo = await redoLast(ctx, afterUndo, last);
expect(afterRedo.isStarted).toBe(newEnc.isStarted);
expect(afterRedo.round).toBe(newEnc.round);
expect(afterRedo.currentTurnParticipantId).toBe(newEnc.currentTurnParticipantId);
expect(afterRedo.turnOrderIds).toEqual(newEnc.turnOrderIds);
}); });
test('nextTurn undo restores prior currentTurn/round', () => { test('nextTurn', async () => {
const { storage, ctx } = mockCtx();
let e = enc([p('a',10),p('b',20),p('c',5)]); let e = enc([p('a',10),p('b',20),p('c',5)]);
e = { ...e, ...startEncounter(e).patch }; e = await startEncounter(e, ctx);
await storage.setDoc(ctx.encPath, e);
const before = snap(e); const before = snap(e);
const r = nextTurn(e); const newEnc = await nextTurn(e, ctx);
expect(r.log.undo).toBeTruthy(); const last = storage.logs().at(-1);
const after = { ...e, ...r.patch, ...r.log.undo }; const afterUndo = await undoLast(ctx, newEnc);
expect(snap(after)).toEqual(before); expect(snap(afterUndo)).toEqual(before);
const afterRedo = await redoLast(ctx, afterUndo, last);
expect(snap(afterRedo)).toEqual(snap(newEnc));
}); });
test('togglePause undo restores prior paused state', () => { test('togglePause excluded from undo (no log entry)', async () => {
const { storage, ctx } = mockCtx();
let e = enc([p('a',10),p('b',20)]); let e = enc([p('a',10),p('b',20)]);
e = { ...e, ...startEncounter(e).patch }; e = await startEncounter(e, ctx);
await storage.setDoc(ctx.encPath, e);
const before = snap(e); const before = snap(e);
const r = togglePause(e); const newEnc = await togglePause(e, ctx);
expect(r.log.undo).toBeTruthy(); // state flips (isPaused), but no log written
const after = { ...e, ...r.patch, ...r.log.undo }; expect(newEnc.isPaused).toBe(true);
expect(snap(after)).toEqual(before); expect(storage.logs()).toHaveLength(1); // start only
}); });
test('applyHpChange undo restores prior participants', () => { test('applyHpChange damage restores HP on undo, re-applies on redo', async () => {
const { storage, ctx } = mockCtx();
let e = enc([p('a',10,{maxHp:100,currentHp:100}),p('b',20)]); let e = enc([p('a',10,{maxHp:100,currentHp:100}),p('b',20)]);
e = { ...e, ...startEncounter(e).patch }; e = await startEncounter(e, ctx);
await storage.setDoc(ctx.encPath, e);
const before = snap(e); const before = snap(e);
const r = applyHpChange(e, 'a', 'damage', 20); const newEnc = await applyHpChange(e, 'a', 'damage', 20, ctx);
expect(r.log.undo).toBeTruthy(); const last = storage.logs().at(-1);
const after = { ...e, ...r.patch, ...r.log.undo }; const afterUndo = await undoLast(ctx, newEnc);
expect(snap(after)).toEqual(before); expect(snap(afterUndo)).toEqual(before);
const afterRedo = await redoLast(ctx, afterUndo, last);
expect(snap(afterRedo)).toEqual(snap(newEnc));
}); });
test('toggleCondition undo restores prior participants', () => { test('applyHpChange heal restores HP on undo, re-applies on redo', async () => {
const { storage, ctx } = mockCtx();
let e = enc([p('a',10,{maxHp:100,currentHp:50})]);
e = await startEncounter(e, ctx);
await storage.setDoc(ctx.encPath, e);
const before = snap(e);
const newEnc = await applyHpChange(e, 'a', 'heal', 20, ctx);
const last = storage.logs().at(-1);
const afterUndo = await undoLast(ctx, newEnc);
expect(snap(afterUndo)).toEqual(before);
const afterRedo = await redoLast(ctx, afterUndo, last);
expect(snap(afterRedo)).toEqual(snap(newEnc));
});
test('toggleCondition', async () => {
const { storage, ctx } = mockCtx();
let e = enc([p('a',10),p('b',20)]); let e = enc([p('a',10),p('b',20)]);
e = { ...e, ...startEncounter(e).patch }; e = await startEncounter(e, ctx);
await storage.setDoc(ctx.encPath, e);
const before = snap(e); const before = snap(e);
const r = toggleCondition(e, 'a', 'stunned'); const newEnc = await toggleCondition(e, 'a', 'stunned', ctx);
expect(r.log.undo).toBeTruthy(); const last = storage.logs().at(-1);
const after = { ...e, ...r.patch, ...r.log.undo }; const afterUndo = await undoLast(ctx, newEnc);
expect(snap(after)).toEqual(before); expect(snap(afterUndo)).toEqual(before);
const afterRedo = await redoLast(ctx, afterUndo, last);
expect(snap(afterRedo)).toEqual(snap(newEnc));
}); });
test('toggleParticipantActive undo restores prior participants + turn order', () => { test('toggleParticipantActive restores participants + turn order, redo re-applies', async () => {
const { storage, ctx } = mockCtx();
let e = enc([p('a',10),p('b',20),p('c',5)]); let e = enc([p('a',10),p('b',20),p('c',5)]);
e = { ...e, ...startEncounter(e).patch }; e = await startEncounter(e, ctx);
await storage.setDoc(ctx.encPath, e);
const before = snap(e); const before = snap(e);
const r = toggleParticipantActive(e, 'b'); const newEnc = await toggleParticipantActive(e, 'b', ctx);
expect(r.log.undo).toBeTruthy(); const last = storage.logs().at(-1);
const after = { ...e, ...r.patch, ...r.log.undo }; const afterUndo = await undoLast(ctx, newEnc);
expect(snap(after)).toEqual(before); expect(snap(afterUndo)).toEqual(before);
const afterRedo = await redoLast(ctx, afterUndo, last);
expect(snap(afterRedo)).toEqual(snap(newEnc));
}); });
test('addParticipant undo restores prior participants', () => { test('addParticipant', async () => {
const { storage, ctx } = mockCtx();
let e = enc([p('a',10),p('b',20)]); let e = enc([p('a',10),p('b',20)]);
e = { ...e, ...startEncounter(e).patch }; e = await startEncounter(e, ctx);
await storage.setDoc(ctx.encPath, e);
const before = snap(e); const before = snap(e);
const np = makeParticipant({ id:'z', name:'z', type:'monster', initiative:15, maxHp:50, currentHp:50 }); const np = makeParticipant({ id:'z', name:'z', type:'monster', initiative:15, maxHp:50, currentHp:50 });
const r = addParticipant(e, np); const newEnc = await addParticipant(e, np, ctx);
expect(r.log.undo).toBeTruthy(); const last = storage.logs().at(-1);
const after = { ...e, ...r.patch, ...r.log.undo }; const afterUndo = await undoLast(ctx, newEnc);
expect(snap(after)).toEqual(before); expect(snap(afterUndo)).toEqual(before);
const afterRedo = await redoLast(ctx, afterUndo, last);
expect(snap(afterRedo)).toEqual(snap(newEnc));
}); });
test('removeParticipant undo restores prior participants + turn order', () => { test('removeParticipant restores prior participants + turn order, redo re-applies', async () => {
const { storage, ctx } = mockCtx();
let e = enc([p('a',10),p('b',20),p('c',5)]); let e = enc([p('a',10),p('b',20),p('c',5)]);
e = { ...e, ...startEncounter(e).patch }; e = await startEncounter(e, ctx);
await storage.setDoc(ctx.encPath, e);
const before = snap(e); const before = snap(e);
const r = removeParticipant(e, 'b'); const newEnc = await removeParticipant(e, 'b', ctx);
expect(r.log.undo).toBeTruthy(); const last = storage.logs().at(-1);
const after = { ...e, ...r.patch, ...r.log.undo }; const afterUndo = await undoLast(ctx, newEnc);
expect(snap(after)).toEqual(before); expect(snap(afterUndo)).toEqual(before);
const afterRedo = await redoLast(ctx, afterUndo, last);
expect(snap(afterRedo)).toEqual(snap(newEnc));
}); });
test('endEncounter undo restores prior state', () => { test('endEncounter', async () => {
const { storage, ctx } = mockCtx();
let e = enc([p('a',10),p('b',20)]); let e = enc([p('a',10),p('b',20)]);
e = { ...e, ...startEncounter(e).patch }; e = await startEncounter(e, ctx);
await storage.setDoc(ctx.encPath, e);
const before = snap(e); const before = snap(e);
const r = endEncounter(e); const newEnc = await endEncounter(e, ctx);
expect(r.log.undo).toBeTruthy(); const last = storage.logs().at(-1);
const after = { ...e, ...r.patch, ...r.log.undo }; const afterUndo = await undoLast(ctx, newEnc);
expect(snap(after)).toEqual(before); expect(snap(afterUndo)).toEqual(before);
const afterRedo = await redoLast(ctx, afterUndo, last);
expect(snap(afterRedo)).toEqual(snap(newEnc));
}); });
test('reorderParticipants has no undo (log: null) — BUG candidate', () => { test('reorderParticipants (BUG-7 fixed)', async () => {
const { storage, ctx } = mockCtx();
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);
e = { ...e, ...startEncounter(e).patch }; await storage.setDoc(ctx.encPath, e);
const r = reorderParticipants(e, 'c', 'b'); const before = snap(e);
// Documents: reorderParticipants returns log: null. Cannot undo. const newEnc = await reorderParticipants(e, 'c', 'b', ctx);
// If undo expected here, this is BUG-7. const last = storage.logs().at(-1);
expect(r.log).toBeNull(); const afterUndo = await undoLast(ctx, newEnc);
expect(snap(afterUndo)).toEqual(before);
const afterRedo = await redoLast(ctx, afterUndo, last);
expect(snap(afterRedo)).toEqual(snap(newEnc));
});
test('updateParticipant', async () => {
const { storage, ctx } = mockCtx();
let e = enc([p('a',10),p('b',20)]);
e = await startEncounter(e, ctx);
await storage.setDoc(ctx.encPath, e);
const before = snap(e);
const newEnc = await shared.updateParticipant(e, 'a', { initiative: 99 }, ctx);
const last = storage.logs().at(-1);
const afterUndo = await undoLast(ctx, newEnc);
expect(snap(afterUndo)).toEqual(before);
const afterRedo = await redoLast(ctx, afterUndo, last);
expect(snap(afterRedo)).toEqual(snap(newEnc));
}); });
}); });
+705 -367
View File
File diff suppressed because it is too large Load Diff
+902 -566
View File
File diff suppressed because it is too large Load Diff
+25 -3
View File
@@ -38,11 +38,33 @@ export const MOCK_DB = {
return () => state.subscribers.get(path)?.delete(cb); return () => state.subscribers.get(path)?.delete(cb);
}, },
_notify(path) { _notify(path) {
// notify exact doc path subscribers // notify exact doc path subscribers (wrapped in act for test isolation).
if (state.subscribers.has(path)) state.subscribers.get(path).forEach(cb => cb()); // Set flag true around cb: testing-library asyncWrapper (waitFor) flips it
// false during poll, which makes raw react act() warn 'not configured'.
if (state.subscribers.has(path)) state.subscribers.get(path).forEach(cb => {
try {
const { act } = require('react');
const prev = globalThis.IS_REACT_ACT_ENVIRONMENT;
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
act(() => cb());
globalThis.IS_REACT_ACT_ENVIRONMENT = prev;
} catch {
cb();
}
});
// notify parent collection subscribers // notify parent collection subscribers
const parent = path.split('/').slice(0, -1).join('/'); const parent = path.split('/').slice(0, -1).join('/');
if (parent && state.subscribers.has(parent)) state.subscribers.get(parent).forEach(cb => cb()); if (parent && state.subscribers.has(parent)) state.subscribers.get(parent).forEach(cb => {
try {
const { act } = require('react');
const prev = globalThis.IS_REACT_ACT_ENVIRONMENT;
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
act(() => cb());
globalThis.IS_REACT_ACT_ENVIRONMENT = prev;
} catch {
cb();
}
});
}, },
nextId() { state.counter += 1; return String(state.counter).padStart(3, '0'); }, nextId() { state.counter += 1; return String(state.counter).padStart(3, '0'); },
_state: state, _state: state,
+41 -3
View File
@@ -13,13 +13,18 @@ export function doc(db, path, extra) {
} }
export function collection(db, path) { return ref(path); } export function collection(db, path) { return ref(path); }
export function query(refOrColl, ...constraints) { return { ref: refOrColl, constraints }; } export function query(refOrColl, ...constraints) { return { ref: refOrColl, constraints }; }
export function where(field, op, value) { return { __type: 'where', field, op, value }; }
export function orderBy(field, dir) { return { __type: 'orderBy', field, dir }; } export function orderBy(field, dir) { return { __type: 'orderBy', field, dir }; }
export function limit(n) { return { __type: 'limit', n }; } export function limit(n) { return { __type: 'limit', n }; }
// writes // writes
export async function setDoc(docRef, data, opts) { export async function setDoc(docRef, data, opts) {
recordCall({ fn: 'setDoc', path: docRef.path, data: clone(data), opts: opts || null }); recordCall({ fn: 'setDoc', path: docRef.path, data: clone(data), opts: opts || null });
MOCK_DB.set(docRef.path, clone(data)); if (opts && opts.merge) {
MOCK_DB.merge(docRef.path, clone(data));
} else {
MOCK_DB.set(docRef.path, clone(data));
}
return undefined; return undefined;
} }
export async function updateDoc(docRef, patch) { export async function updateDoc(docRef, patch) {
@@ -63,13 +68,22 @@ export async function getDoc(docRef) {
} }
export async function getDocs(collRefOrQuery) { export async function getDocs(collRefOrQuery) {
const collPath = collRefOrQuery.ref ? collRefOrQuery.ref.path : collRefOrQuery.path; const collPath = collRefOrQuery.ref ? collRefOrQuery.ref.path : collRefOrQuery.path;
const docs = MOCK_DB.collection(collPath); let docs = MOCK_DB.collection(collPath);
const constraints = collRefOrQuery.constraints || [];
docs = applyConstraints(docs, constraints);
return { docs: docs.map(d => ({ id: d.id, data: () => d.data, ref: { path: `${collPath}/${d.id}` } })) }; return { docs: docs.map(d => ({ id: d.id, data: () => d.data, ref: { path: `${collPath}/${d.id}` } })) };
} }
export async function getCountFromServer(collRefOrQuery) {
const collPath = collRefOrQuery.ref ? collRefOrQuery.ref.path : collRefOrQuery.path;
const docs = MOCK_DB.collection(collPath);
return { data: () => ({ count: docs.length }) };
}
// realtime — emit from mock DB, capture unsub // realtime — emit from mock DB, capture unsub
export function onSnapshot(refOrQuery, onSuccess, onError) { export function onSnapshot(refOrQuery, onSuccess, onError) {
const path = refOrQuery.path || (refOrQuery.ref && refOrQuery.ref.path); const path = refOrQuery.path || (refOrQuery.ref && refOrQuery.ref.path);
const constraints = refOrQuery.constraints || [];
// fire immediately with current state // fire immediately with current state
const emit = () => { const emit = () => {
if (refOrQuery.__ref && refOrQuery.path && path.split('/').length % 2 === 0) { if (refOrQuery.__ref && refOrQuery.path && path.split('/').length % 2 === 0) {
@@ -80,7 +94,8 @@ export function onSnapshot(refOrQuery, onSuccess, onError) {
data: () => data, data: () => data,
}); });
} else { } else {
const docs = MOCK_DB.collection(path); let docs = MOCK_DB.collection(path);
docs = applyConstraints(docs, constraints);
onSuccess({ docs: docs.map(d => ({ id: d.id, data: () => d.data })) }); onSuccess({ docs: docs.map(d => ({ id: d.id, data: () => d.data })) });
} }
}; };
@@ -90,6 +105,29 @@ export function onSnapshot(refOrQuery, onSuccess, onError) {
return unsub; return unsub;
} }
// Apply Firestore-style query constraints (orderBy desc/asc, limit) to mock docs.
// Mirrors real SDK semantics enough for contract tests. Only orderBy + limit
// supported (App's LOG_QUERY uses exactly these).
function applyConstraints(docs, constraints) {
let out = [...docs];
for (const c of constraints) {
if (c.__type === 'orderBy') {
out.sort((a, b) => {
const av = a.data[c.field];
const bv = b.data[c.field];
if (av === bv) return 0;
const cmp = av > bv ? 1 : -1;
return c.dir === 'desc' ? -cmp : cmp;
});
} else if (c.__type === 'limit') {
out = out.slice(0, c.n);
} else if (c.__type === 'where') {
out = out.filter(d => d.data[c.field] === c.value);
}
}
return out;
}
function clone(v) { function clone(v) {
if (v === null || v === undefined) return v; if (v === null || v === undefined) return v;
return JSON.parse(JSON.stringify(v)); return JSON.parse(JSON.stringify(v));
+25
View File
@@ -2,6 +2,31 @@
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { resetMockDb } from './__mocks__/firebase/_mock-db'; import { resetMockDb } from './__mocks__/firebase/_mock-db';
// RTL v14: tell React this is an act environment. Without it every
// async update warns "current testing environment is not configured to
// support act(...)". RTL reads getGlobalThis(), so set on globalThis.
// Must be set before any component renders.
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,
// deprecations, prop errors). App code's own error logs allowed only inside
// try/catch failure paths — those tests should assert the error was handled,
// not silently log it. Override the spies to throw.
const originalError = console.error;
const originalWarn = console.warn;
console.error = (...args) => {
originalError(...args);
throw new Error(`console.error in test: ${args.map(String).join(' ').slice(0, 500)}`);
};
console.warn = (...args) => {
originalWarn(...args);
throw new Error(`console.warn in test: ${args.map(String).join(' ').slice(0, 500)}`);
};
// polyfill crypto.randomUUID for jsdom (used by generateId in App.js). // polyfill crypto.randomUUID for jsdom (used by generateId in App.js).
if (!global.crypto) global.crypto = {}; if (!global.crypto) global.crypto = {};
if (!global.crypto.randomUUID) { if (!global.crypto.randomUUID) {
+139 -34
View File
@@ -6,8 +6,6 @@
// const { runStorageContract } = require('./contract.test'); // const { runStorageContract } = require('./contract.test');
// runStorageContract('memory', () => createMemoryStorage()); // runStorageContract('memory', () => createMemoryStorage());
'use strict';
// Each impl factory returns a fresh storage instance (async-creatable is fine). // Each impl factory returns a fresh storage instance (async-creatable is fine).
// Interface every impl MUST provide: // Interface every impl MUST provide:
// getDoc(path) -> Promise<obj|null> // getDoc(path) -> Promise<obj|null>
@@ -17,6 +15,7 @@
// addDoc(collectionPath, data) -> Promise<{id, path}> (auto-gen id) // addDoc(collectionPath, data) -> Promise<{id, path}> (auto-gen id)
// getCollection(path) -> Promise<arr> (immediate child docs) // getCollection(path) -> Promise<arr> (immediate child docs)
// batchWrite(ops) -> Promise<void> ops: [{type, path, data?}] // batchWrite(ops) -> Promise<void> ops: [{type, path, data?}]
// undo({logPath, undo, redo}) -> Promise<void> tx: apply updates + flip undone
// subscribeDoc(path, cb) -> unsubscribe fn cb(doc|null) // subscribeDoc(path, cb) -> unsubscribe fn cb(doc|null)
// subscribeCollection(path, cb) -> unsubscribe fn cb(arr) // subscribeCollection(path, cb) -> unsubscribe fn cb(arr)
@@ -27,10 +26,10 @@ function runStorageContract(name, factory) {
afterEach(async () => { if (storage && storage.dispose) await storage.dispose(); }); afterEach(async () => { if (storage && storage.dispose) await storage.dispose(); });
describe('getDoc / setDoc', () => { describe('getDoc / setDoc', () => {
test('setDoc then getDoc returns the doc', async () => { test('setDoc then getDoc returns the doc (with id)', async () => {
await storage.setDoc('campaigns/a', { name: 'Alpha' }); await storage.setDoc('campaigns/a', { name: 'Alpha' });
const doc = await storage.getDoc('campaigns/a'); const doc = await storage.getDoc('campaigns/a');
expect(doc).toEqual({ name: 'Alpha' }); expect(doc).toEqual({ id: 'a', name: 'Alpha' });
}); });
test('getDoc on missing path returns null', async () => { test('getDoc on missing path returns null', async () => {
@@ -42,7 +41,15 @@ function runStorageContract(name, factory) {
await storage.setDoc('campaigns/a', { name: 'Alpha', players: [] }); await storage.setDoc('campaigns/a', { name: 'Alpha', players: [] });
await storage.setDoc('campaigns/a', { name: 'Beta' }); await storage.setDoc('campaigns/a', { name: 'Beta' });
const doc = await storage.getDoc('campaigns/a'); const doc = await storage.getDoc('campaigns/a');
expect(doc).toEqual({ name: 'Beta' }); expect(doc).toEqual({ id: 'a', name: 'Beta' });
});
// main L1624: setDoc(path, data, {merge:true}) — merge flag.
test('setDoc with {merge:true} merges into existing doc', async () => {
await storage.setDoc('campaigns/a', { name: 'Alpha', players: [] });
await storage.setDoc('campaigns/a', { players: [1] }, { merge: true });
const doc = await storage.getDoc('campaigns/a');
expect(doc).toEqual({ id: 'a', name: 'Alpha', players: [1] });
}); });
}); });
@@ -51,13 +58,13 @@ function runStorageContract(name, factory) {
await storage.setDoc('campaigns/a', { name: 'Alpha', players: [1] }); await storage.setDoc('campaigns/a', { name: 'Alpha', players: [1] });
await storage.updateDoc('campaigns/a', { players: [1, 2] }); await storage.updateDoc('campaigns/a', { players: [1, 2] });
const doc = await storage.getDoc('campaigns/a'); const doc = await storage.getDoc('campaigns/a');
expect(doc).toEqual({ name: 'Alpha', players: [1, 2] }); expect(doc).toEqual({ id: 'a', name: 'Alpha', players: [1, 2] });
}); });
test('updateDoc on missing doc creates it', async () => { test('updateDoc on missing doc creates it', async () => {
await storage.updateDoc('campaigns/a', { name: 'Alpha' }); await storage.updateDoc('campaigns/a', { name: 'Alpha' });
const doc = await storage.getDoc('campaigns/a'); const doc = await storage.getDoc('campaigns/a');
expect(doc).toEqual({ name: 'Alpha' }); expect(doc).toEqual({ id: 'a', name: 'Alpha' });
}); });
}); });
@@ -79,7 +86,7 @@ function runStorageContract(name, factory) {
expect(id).toBeTruthy(); expect(id).toBeTruthy();
expect(path).toBe(`campaigns/a/encounters/${id}`); expect(path).toBe(`campaigns/a/encounters/${id}`);
const doc = await storage.getDoc(path); const doc = await storage.getDoc(path);
expect(doc).toEqual({ name: 'E1' }); expect(doc).toEqual({ id, name: 'E1' });
}); });
test('two addDocs produce distinct ids', async () => { test('two addDocs produce distinct ids', async () => {
@@ -91,33 +98,15 @@ function runStorageContract(name, factory) {
describe('firebase-prefixed path identity', () => { describe('firebase-prefixed path identity', () => {
// App passes firebase-prefixed paths (artifacts/{APP_ID}/public/data/...). // App passes firebase-prefixed paths (artifacts/{APP_ID}/public/data/...).
// Adapter must normalize internally so write+read at prefixed path round-trips // main prod truth: ALWAYS prefixed. Write+read same prefixed round-trips.
// AND collection queries at bare canonical path find prefixed-written docs. // Cross bare<->prefixed lookup is NOT required by main (App never does it).
// Catches replay-script bug (wrote prefixed, adapter reads bare, missed). // Kept as same-prefix roundtrip only — matches prod.
const PREFIX = 'artifacts/test-app/public/data'; const PREFIX = 'artifacts/test-app/public/data';
test('setDoc prefixed then getCollection bare finds it', async () => { test('setDoc prefixed then getDoc same prefixed path returns it (id)', async () => {
await storage.setDoc(`${PREFIX}/campaigns/c1`, { name: 'P1' });
const docs = await storage.getCollection('campaigns');
expect(docs.some(d => d.name === 'P1')).toBe(true);
});
test('setDoc prefixed then getDoc same prefixed path returns it', async () => {
await storage.setDoc(`${PREFIX}/campaigns/c2`, { name: 'P2' }); await storage.setDoc(`${PREFIX}/campaigns/c2`, { name: 'P2' });
const doc = await storage.getDoc(`${PREFIX}/campaigns/c2`); const doc = await storage.getDoc(`${PREFIX}/campaigns/c2`);
expect(doc).toEqual({ name: 'P2' }); expect(doc).toEqual({ id: 'c2', name: 'P2' });
});
test('setDoc prefixed then getDoc bare path returns it', async () => {
await storage.setDoc(`${PREFIX}/campaigns/c3`, { name: 'P3' });
const doc = await storage.getDoc('campaigns/c3');
expect(doc).toEqual({ name: 'P3' });
});
test('setDoc bare then getCollection prefixed finds it', async () => {
await storage.setDoc('campaigns/c4', { name: 'P4' });
const docs = await storage.getCollection(`${PREFIX}/campaigns`);
expect(docs.some(d => d.name === 'P4')).toBe(true);
}); });
}); });
@@ -165,7 +154,84 @@ function runStorageContract(name, factory) {
{ type: 'delete', path: 'campaigns/a' }, { type: 'delete', path: 'campaigns/a' },
]); ]);
expect(await storage.getDoc('campaigns/a')).toBeNull(); expect(await storage.getDoc('campaigns/a')).toBeNull();
expect(await storage.getDoc('campaigns/b')).toEqual({ name: 'B' }); expect(await storage.getDoc('campaigns/b')).toEqual({ id: 'b', name: 'B' });
});
// set-only batch (not used in main currently, but interface allows).
test('applies set-only batch', async () => {
await storage.batchWrite([
{ type: 'set', path: 'campaigns/a', data: { name: 'A' } },
{ type: 'set', path: 'campaigns/b', data: { name: 'B' } },
]);
expect(await storage.getDoc('campaigns/a')).toEqual({ id: 'a', name: 'A' });
expect(await storage.getDoc('campaigns/b')).toEqual({ id: 'b', name: 'B' });
});
// update-only batch (interface allows).
test('applies update-only batch', async () => {
await storage.setDoc('campaigns/a', { name: 'A', hp: 10 });
await storage.setDoc('campaigns/b', { name: 'B', hp: 5 });
await storage.batchWrite([
{ type: 'update', path: 'campaigns/a', data: { hp: 8 } },
{ type: 'update', path: 'campaigns/b', data: { hp: 2 } },
]);
expect(await storage.getDoc('campaigns/a')).toEqual({ id: 'a', name: 'A', hp: 8 });
expect(await storage.getDoc('campaigns/b')).toEqual({ id: 'b', name: 'B', hp: 2 });
});
});
describe('undo', () => {
test('undo applies updates + flips undone true', async () => {
await storage.setDoc('encounters/e1', { round: 2, name: 'Enc' });
await storage.setDoc('logs/l1', { message: 'x', undone: false });
await storage.undo({
logPath: 'logs/l1',
undo: { encounterPath: 'encounters/e1', updates: { round: 1 }, redo: { round: 2 } },
});
const enc = await storage.getDoc('encounters/e1');
const log = await storage.getDoc('logs/l1');
expect(enc.round).toBe(1);
expect(log.undone).toBe(true);
});
test('redo applies redo patch + flips undone false', async () => {
await storage.setDoc('encounters/e1', { round: 1 });
await storage.setDoc('logs/l1', { message: 'x', undone: true });
await storage.undo({
logPath: 'logs/l1',
undo: { encounterPath: 'encounters/e1', updates: { round: 1 }, redo: { round: 2 } },
redo: true,
});
const enc = await storage.getDoc('encounters/e1');
const log = await storage.getDoc('logs/l1');
expect(enc.round).toBe(2);
expect(log.undone).toBe(false);
});
});
describe('deleteCollection', () => {
test('deletes all docs in collection', async () => {
await storage.addDoc('logs', { type: 'a' });
await storage.addDoc('logs', { type: 'b' });
const deleted = await storage.deleteCollection('logs');
expect(deleted).toBe(2);
const remaining = await storage.getCollection('logs');
expect(remaining).toHaveLength(0);
});
test('honors whereField filter', async () => {
await storage.addDoc('logs', { type: 'keep', n: 1 });
await storage.addDoc('logs', { type: 'drop', n: 2 });
await storage.addDoc('logs', { type: 'drop', n: 3 });
const deleted = await storage.deleteCollection('logs', 'type', 'drop');
expect(deleted).toBe(2);
const remaining = await storage.getCollection('logs');
expect(remaining).toHaveLength(1);
});
test('empty collection returns 0', async () => {
const deleted = await storage.deleteCollection('logs');
expect(deleted).toBe(0);
}); });
}); });
@@ -176,7 +242,7 @@ function runStorageContract(name, factory) {
storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc)); storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc));
await flush(); await flush();
expect(calls).toHaveLength(1); expect(calls).toHaveLength(1);
expect(calls[0]).toEqual({ name: 'Alpha' }); expect(calls[0]).toEqual({ id: 'a', name: 'Alpha' });
}); });
test('fires cb on subsequent change', async () => { test('fires cb on subsequent change', async () => {
@@ -186,7 +252,7 @@ function runStorageContract(name, factory) {
await storage.setDoc('campaigns/a', { name: 'Alpha' }); await storage.setDoc('campaigns/a', { name: 'Alpha' });
await flush(); await flush();
const last = calls[calls.length - 1]; const last = calls[calls.length - 1];
expect(last).toEqual({ name: 'Alpha' }); expect(last).toEqual({ id: 'a', name: 'Alpha' });
}); });
test('unsubscribe stops callbacks', async () => { test('unsubscribe stops callbacks', async () => {
@@ -220,6 +286,45 @@ function runStorageContract(name, factory) {
expect(last).toHaveLength(1); expect(last).toHaveLength(1);
}); });
}); });
// queryConstraints: orderBy + limit. App's combat log uses
// [orderBy('timestamp','desc'), limit(500)] — newest 500 entries.
// Adapter MUST honor these. Constraint shape = neutral {__type} objects
// (what firebase mock produces; what App passes via shared builders).
describe('subscribeCollection queryConstraints', () => {
const orderByC = (field, dir) => ({ __type: 'orderBy', field, dir });
const limitC = (n) => ({ __type: 'limit', n });
beforeEach(async () => {
// seed 5 log docs, timestamps out of order
await storage.setDoc('logs/l1', { msg: 'one', timestamp: 1 });
await storage.setDoc('logs/l2', { msg: 'two', timestamp: 3 });
await storage.setDoc('logs/l3', { msg: 'three', timestamp: 5 });
await storage.setDoc('logs/l4', { msg: 'four', timestamp: 2 });
await storage.setDoc('logs/l5', { msg: 'five', timestamp: 4 });
});
test('orderBy desc sorts newest-first', async () => {
const result = await new Promise((resolve) => {
storage.subscribeCollection('logs', resolve, [orderByC('timestamp', 'desc')]);
});
expect(result.map(d => d.msg)).toEqual(['three', 'five', 'two', 'four', 'one']);
});
test('limit returns only first N after ordering', async () => {
const result = await new Promise((resolve) => {
storage.subscribeCollection('logs', resolve, [orderByC('timestamp', 'desc'), limitC(2)]);
});
expect(result.map(d => d.msg)).toEqual(['three', 'five']);
});
test('no constraints returns all (insertion/id order)', async () => {
const result = await new Promise((resolve) => {
storage.subscribeCollection('logs', resolve, []);
});
expect(result).toHaveLength(5);
});
});
}); });
} }
+74 -17
View File
@@ -1,18 +1,14 @@
// firebase.js — storage adapter wrapping Firebase SDK. Default impl (upstream-unchanged). // firebase.js — storage adapter wrapping Firebase SDK. Default impl (upstream-unchanged).
// Matches interface of memory.js / ws.js so App.js calls stay identical. // Matches interface of memory.js / ws.js so App.js calls stay identical.
// //
// NOTE: App.js currently imports SDK directly. This adapter extracted verbatim. // App.js imports SDK via this module (doc, setDoc, etc re-exported) for both
// Two-phase refactor: // the adapter (createFirebaseStorage) and direct SDK calls. ONE import path.
// Phase A (now): adapter exists, wraps SDK. Hooks/writes can switch incrementally.
// Phase B (later): App.js imports storage factory, drops direct SDK imports.
'use strict';
import { initializeApp } from 'firebase/app'; import { initializeApp } from 'firebase/app';
import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth'; import { getAuth } from 'firebase/auth';
import { import {
getFirestore, doc, setDoc, getDoc as getDocReal, getDocs as getDocsReal, addDoc, collection, getFirestore, doc, setDoc, getDoc as getDocReal, getDocs as getDocsReal, addDoc, collection,
onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch, serverTimestamp, onSnapshot, updateDoc, deleteDoc, query, where, orderBy, limit, writeBatch, getCountFromServer,
} from 'firebase/firestore'; } from 'firebase/firestore';
// Adapter call recorder (instrumentation, no behavior change). // Adapter call recorder (instrumentation, no behavior change).
@@ -104,11 +100,26 @@ export function createFirebaseStorage() {
return { id: ref.id, path: `${collectionPath}/${ref.id}` }; return { id: ref.id, path: `${collectionPath}/${ref.id}` };
}, },
async getCollection(collectionPath) { async getCollection(collectionPath, queryConstraints = []) {
const snapshot = await getDocsReal(collection(db, collectionPath)); // Translate neutral {__type} constraints to firebase SDK builders.
const fbConstraints = [];
for (const c of queryConstraints || []) {
if (!c || !c.__type) continue;
if (c.__type === 'where') fbConstraints.push(where(c.field, c.op, c.value));
else if (c.__type === 'orderBy') fbConstraints.push(orderBy(c.field, c.dir || 'asc'));
else if (c.__type === 'limit') fbConstraints.push(limit(c.n));
// firebase SDK has no offset; skip (UI uses server adapter in dev).
}
const q = fbConstraints.length ? query(collection(db, collectionPath), ...fbConstraints) : collection(db, collectionPath);
const snapshot = await getDocsReal(q);
return snapshot.docs.map(d => ({ id: d.id, ...d.data() })); return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
}, },
async countCollection(collectionPath) {
const snapshot = await getCountFromServer(collection(db, collectionPath));
return snapshot.data().count;
},
async batchWrite(ops) { async batchWrite(ops) {
const batch = writeBatch(db); const batch = writeBatch(db);
for (const op of ops) { for (const op of ops) {
@@ -119,22 +130,67 @@ export function createFirebaseStorage() {
await batch.commit(); await batch.commit();
}, },
// Bulk delete collection (optionally where-filtered). Firestore has no
// single bulk-by-path op: fetch matching docs, batch-delete in chunks
// of 500 (firestore batch limit). DEV ONLY.
async deleteCollection(path, whereField, whereValue) {
if (process.env.NODE_ENV !== 'development' && process.env.NODE_ENV !== 'test') {
throw new Error('deleteCollection: dev only');
}
let q = collection(db, path);
if (whereField) q = query(q, where(whereField, '==', whereValue));
const snap = await getDocsReal(q);
const docs = snap.docs;
let count = 0;
for (let i = 0; i < docs.length; i += 500) {
const batch = writeBatch(db);
for (const d of docs.slice(i, i + 500)) batch.delete(d.ref);
await batch.commit();
count += Math.min(500, docs.length - i);
}
return count;
},
// Transactional undo via batch (atomic in firestore). Apply updates +
// flip undone flag in single commit.
async undo({ logPath, undo, redo = false }) {
const batch = writeBatch(db);
const updates = redo ? (undo.redo || undo.updates) : undo.updates;
batch.update(doc(db, undo.encounterPath), updates);
batch.update(doc(db, logPath), { undone: !redo });
await batch.commit();
},
// Subscribe = onSnapshot. cb fires immediately + on change. Returns unsubscribe. // Subscribe = onSnapshot. cb fires immediately + on change. Returns unsubscribe.
subscribeDoc(path, cb) { subscribeDoc(path, cb, errCb) {
recordAdapterCall({ fn: 'subscribeDoc', path }); recordAdapterCall({ fn: 'subscribeDoc', path });
return onSnapshot(doc(db, path), (snap) => { return onSnapshot(doc(db, path), (snap) => {
cb(snap.exists() ? { id: snap.id, ...snap.data() } : null); cb(snap.exists() ? { id: snap.id, ...snap.data() } : null);
}, (err) => console.error(`subscribeDoc ${path}:`, err)); }, (err) => {
console.error(`subscribeDoc ${path}:`, err);
if (typeof errCb === 'function') errCb(err);
});
}, },
subscribeCollection(collectionPath, cb, queryConstraints = []) { subscribeCollection(collectionPath, cb, queryConstraints = [], errCb) {
recordAdapterCall({ fn: 'subscribeCollection', path: collectionPath }); recordAdapterCall({ fn: 'subscribeCollection', path: collectionPath });
const q = queryConstraints.length > 0 // queryConstraints = neutral {__type} builders (from index.js).
? query(collection(db, collectionPath), ...queryConstraints) // Translate to SDK orderBy/limit.
const sdkConstraints = queryConstraints.map(c => {
if (c.__type === 'where') return where(c.field, c.op, c.value);
if (c.__type === 'orderBy') return orderBy(c.field, c.dir);
if (c.__type === 'limit') return limit(c.n);
return c; // pass-through (forward compat)
});
const q = sdkConstraints.length > 0
? query(collection(db, collectionPath), ...sdkConstraints)
: collection(db, collectionPath); : collection(db, collectionPath);
return onSnapshot(q, (snap) => { return onSnapshot(q, (snap) => {
cb(snap.docs.map(d => ({ id: d.id, ...d.data() }))); cb(snap.docs.map(d => ({ id: d.id, ...d.data() })));
}, (err) => console.error(`subscribeCollection ${collectionPath}:`, err)); }, (err) => {
console.error(`subscribeCollection ${collectionPath}:`, err);
if (typeof errCb === 'function') errCb(err);
});
}, },
dispose() { /* SDK managed; no-op */ }, dispose() { /* SDK managed; no-op */ },
@@ -142,7 +198,8 @@ export function createFirebaseStorage() {
} }
// Re-export SDK pieces App.js uses directly (until full refactor). // Re-export SDK pieces App.js uses directly (until full refactor).
// orderBy/limit NOT re-exported: App uses neutral builders from index.js.
export { export {
doc, setDoc, updateDoc, deleteDoc, addDoc, collection, onSnapshot, doc, setDoc, updateDoc, deleteDoc, addDoc, collection, onSnapshot,
query, orderBy, limit, writeBatch, query, writeBatch,
}; };
+18 -9
View File
@@ -1,5 +1,6 @@
// src/storage/index.js — storage factory + SDK re-exports. // src/storage/index.js — storage factory + SDK re-exports.
// STORAGE=firebase (default): adapter wraps SDK. STORAGE=ws: backend. // STORAGE=firebase (default): adapter wraps SDK. STORAGE=server: backend.
// orderBy/limit below = NEUTRAL builders (not SDK passthrough).
// App.js imports getStorage() for subscribe; still imports SDK pieces for writes (per-group refactor pending). // App.js imports getStorage() for subscribe; still imports SDK pieces for writes (per-group refactor pending).
import { initializeApp } from 'firebase/app'; import { initializeApp } from 'firebase/app';
@@ -8,11 +9,10 @@ import {
} from 'firebase/auth'; } from 'firebase/auth';
import { import {
getFirestore, doc, setDoc, getDoc, getDocs, addDoc, collection, getFirestore, doc, setDoc, getDoc, getDocs, addDoc, collection,
onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch, onSnapshot, updateDoc, deleteDoc, query, writeBatch,
} from 'firebase/firestore'; } from 'firebase/firestore';
import { initFirebase, createFirebaseStorage } from './firebase'; import { initFirebase, createFirebaseStorage } from './firebase';
import { createWsStorage } from './ws'; import { createServerStorage } from './server';
import { createMemoryStorage } from './memory';
let storageInstance = null; let storageInstance = null;
@@ -24,13 +24,13 @@ export function getStorage() {
const ok = initFirebase(); const ok = initFirebase();
if (!ok) throw new Error('Firebase config missing. Check REACT_APP_FIREBASE_* env.'); if (!ok) throw new Error('Firebase config missing. Check REACT_APP_FIREBASE_* env.');
storageInstance = createFirebaseStorage(); storageInstance = createFirebaseStorage();
} else if (mode === 'ws') { } else if (mode === 'server') {
storageInstance = createWsStorage({ storageInstance = createServerStorage({
baseUrl: process.env.REACT_APP_BACKEND_URL || '', baseUrl: process.env.REACT_APP_BACKEND_URL || '',
wsUrl: process.env.REACT_APP_BACKEND_WS || '', realtimeUrl: process.env.REACT_APP_BACKEND_REALTIME_URL || '',
}); });
} else { } else {
storageInstance = createMemoryStorage(); throw new Error(`Unknown REACT_APP_STORAGE mode: ${mode}. Use 'firebase' or 'server'.`);
} }
return storageInstance; return storageInstance;
} }
@@ -39,9 +39,18 @@ export function getStorageMode() {
return process.env.REACT_APP_STORAGE || 'firebase'; return process.env.REACT_APP_STORAGE || 'firebase';
} }
// Neutral query-constraint builders. App builds constraints with these (not
// raw SDK), so both adapters read the same shape. firebase adapter translates
// neutral -> SDK; server adapter applies client-side. Combat log uses these:
// [orderBy('timestamp','desc'), limit(500)]
export function where(field, op, value) { return { __type: 'where', field, op, value }; }
export function orderBy(field, dir) { return { __type: 'orderBy', field, dir }; }
export function limit(n) { return { __type: 'limit', n }; }
export function offset(n) { return { __type: 'offset', offset: n }; }
export { export {
initializeApp, initializeApp,
getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken, getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken,
getFirestore, doc, setDoc, getDoc, getDocs, addDoc, collection, getFirestore, doc, setDoc, getDoc, getDocs, addDoc, collection,
onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch, onSnapshot, updateDoc, deleteDoc, query, writeBatch,
}; };
-140
View File
@@ -1,140 +0,0 @@
// memory.js — in-process storage impl. Test seed.
// Map<docPath, data>. EventEmitter for subscribe.
// Mirrors firebase semantics: setDoc=replace, updateDoc=shallow merge, addDoc=auto-id.
'use strict';
import { EventEmitter } from 'events';
function createMemoryStorage() {
const docs = new Map(); // path -> data obj
const bus = new EventEmitter();
bus.setMaxListeners(1000);
// Firebase-prefixed paths (artifacts/{APP_ID}/public/data/...) normalized to
// bare canonical. Matches ws.js norm() so all impls share path identity.
function norm(p) {
if (!p) return p;
return p.replace(/^[\s\S]*\/public\/data\//, '');
}
// ---- path helpers ----
// collection path = path with even number of segments OR known collection.
// doc path = odd segments (coll/doc, coll/doc/subcoll/subdoc).
// getCollection(path) returns all docs whose path === path/id for any single id segment.
function isCollectionPath(p) {
return p.split('/').length % 2 === 1;
}
function emitDoc(path, data) { bus.emit('doc:' + path, data); }
function emitCollection(collPath) {
const children = collectionDocs(collPath);
bus.emit('coll:' + collPath, children);
}
function collectionDocs(collPath) {
const out = [];
const segLen = collPath.split('/').length + 1;
for (const [p, data] of docs) {
const segs = p.split('/');
if (segs.length !== segLen) continue;
const parent = segs.slice(0, -1).join('/');
if (parent === collPath) out.push(data);
}
return out;
}
function genId() {
return (typeof crypto !== 'undefined' && crypto.randomUUID)
? crypto.randomUUID()
: `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
}
const storage = {
async getDoc(rawPath) {
const path = norm(rawPath);
return docs.has(path) ? deepClone(docs.get(path)) : null;
},
async setDoc(rawPath, data) {
const path = norm(rawPath);
docs.set(path, deepClone(data));
emitDoc(path, deepClone(data));
const segs = path.split('/');
if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/'));
},
async updateDoc(rawPath, patch) {
const path = norm(rawPath);
const existing = docs.has(path) ? docs.get(path) : {};
const merged = { ...existing, ...patch };
docs.set(path, merged);
emitDoc(path, deepClone(merged));
const segs = path.split('/');
if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/'));
},
async deleteDoc(rawPath) {
const path = norm(rawPath);
docs.delete(path);
emitDoc(path, null);
const segs = path.split('/');
if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/'));
},
async addDoc(rawCollectionPath, data) {
const collectionPath = norm(rawCollectionPath);
const id = genId();
const path = `${collectionPath}/${id}`;
docs.set(path, deepClone(data));
emitDoc(path, deepClone(data));
emitCollection(collectionPath);
return { id, path };
},
async getCollection(rawCollPath) {
const collPath = norm(rawCollPath);
return collectionDocs(collPath).map(deepClone);
},
async batchWrite(ops) {
for (const op of ops) {
const mop = { ...op, path: norm(op.path) };
if (mop.type === 'set') await storage.setDoc(mop.path, mop.data);
else if (mop.type === 'delete') await storage.deleteDoc(mop.path);
else if (mop.type === 'update') await storage.updateDoc(mop.path, mop.data);
}
},
subscribeDoc(rawPath, cb) {
const path = norm(rawPath);
const cur = docs.has(path) ? deepClone(docs.get(path)) : null;
Promise.resolve().then(() => cb(cur));
const handler = (data) => cb(data);
bus.on('doc:' + path, handler);
return () => bus.off('doc:' + path, handler);
},
subscribeCollection(rawCollPath, cb) {
const collPath = norm(rawCollPath);
Promise.resolve().then(() => cb(collectionDocs(collPath).map(deepClone)));
const handler = (docs) => cb(docs);
bus.on('coll:' + collPath, handler);
return () => bus.off('coll:' + collPath, handler);
},
dispose() { bus.removeAllListeners(); docs.clear(); },
// test/debug
_docs: docs,
};
return storage;
}
function deepClone(v) {
if (v === null || v === undefined) return v;
return JSON.parse(JSON.stringify(v));
}
export { createMemoryStorage };
+119 -29
View File
@@ -2,8 +2,6 @@
// Passthrough: no shape translation. Backend = firebase mirror. // Passthrough: no shape translation. Backend = firebase mirror.
// Implements same interface as memory.js. Tested by storage contract vs running server. // Implements same interface as memory.js. Tested by storage contract vs running server.
'use strict';
// Native browser WebSocket if present, else ws pkg (Node/jest). // Native browser WebSocket if present, else ws pkg (Node/jest).
// Lazy load ws pkg so CRA prod build (ESM) doesn't choke on require(). // Lazy load ws pkg so CRA prod build (ESM) doesn't choke on require().
let WebSocketImpl; let WebSocketImpl;
@@ -11,13 +9,13 @@ if (typeof WebSocket !== 'undefined') {
WebSocketImpl = WebSocket; WebSocketImpl = WebSocket;
} }
function createWsStorage({ baseUrl, wsUrl } = {}) { function createServerStorage({ baseUrl, realtimeUrl } = {}) {
// Same-origin by default: empty baseUrl = relative fetch (Caddy/proxy). // Same-origin by default: empty baseUrl = relative fetch (Caddy/proxy).
// Fallback to localhost for bare `npm start` dev without proxy. // Fallback to localhost for bare `npm start` dev without proxy.
const API = (baseUrl || (typeof window !== 'undefined' && window.location ? '' : 'http://127.0.0.1:4001')).replace(/\/$/, ''); const API = (baseUrl || (typeof window !== 'undefined' && window.location ? '' : 'http://127.0.0.1:4001')).replace(/\/$/, '');
let WS; let WS;
if (wsUrl) { if (realtimeUrl) {
WS = wsUrl; WS = realtimeUrl;
} else if (typeof window !== 'undefined' && window.location) { } else if (typeof window !== 'undefined' && window.location) {
// derive from current origin (http→ws, https→wss), same host/port. // derive from current origin (http→ws, https→wss), same host/port.
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -33,8 +31,22 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
return p.replace(/^[\s\S]*\/public\/data\//, ''); return p.replace(/^[\s\S]*\/public\/data\//, '');
} }
// Inject id (last path segment) into doc — matches main firebase truth:
// { id: docSnap.id, ...snap.data() }
// Backend stores docs WITHOUT id; client adds it so all adapters share shape.
function withId(rawPath, data) {
if (data === null || data === undefined) return data;
const id = norm(rawPath).split('/').pop();
return { id, ...data };
}
const docSubs = new Map(); // path -> Set<cb> const docSubs = new Map(); // path -> Set<cb>
const collSubs = new Map(); // collPath -> Set<cb> const collSubs = new Map(); // collPath -> Set<cb>
const collConstraints = new Map(); // collPath -> constraints[] (per collection)
// Last data emitted per path. Dedupe identical re-emits so WS-open
// re-fetch (authoritative catch-up) doesn't double-fire when REST already
// delivered the same data. Race: REST empty → WS-open populated fires.
const lastEmitted = new Map(); // path -> serialized data (or null for empty)
let ws = null; let ws = null;
let wsReady = null; let wsReady = null;
@@ -43,6 +55,16 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
let everConnected = false; let everConnected = false;
const RECONNECT_DELAY = 500; const RECONNECT_DELAY = 500;
// Emit helper: dedupe identical re-emits per path. WS-open re-fetch
// (authoritative catch-up) + initial REST both may fire; skip dup.
function emit(kind, path, data, cbs) {
const key = `${kind}:${path}`;
const ser = data === undefined ? 'undef' : JSON.stringify(data);
if (lastEmitted.get(key) === ser) return; // identical, skip
lastEmitted.set(key, ser);
cbs.forEach(cb => cb(data));
}
function ensureWs() { function ensureWs() {
if (wsReady) return wsReady; if (wsReady) return wsReady;
wsReady = new Promise((resolve, reject) => { wsReady = new Promise((resolve, reject) => {
@@ -65,7 +87,7 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
for (const p of collSubs.keys()) { for (const p of collSubs.keys()) {
ws.send(JSON.stringify({ type: 'subscribe', kind: 'collection', path: p })); ws.send(JSON.stringify({ type: 'subscribe', kind: 'collection', path: p }));
} }
// On RECONNECT only: re-fetch current values — catches writes that // Reconnect only: re-fetch current values — catches writes that
// happened while disconnected (broadcast missed). Skip on first connect // happened while disconnected (broadcast missed). Skip on first connect
// (initial REST fetch in subscribeDoc/subscribeCollection already did). // (initial REST fetch in subscribeDoc/subscribeCollection already did).
if (isReconnect) { if (isReconnect) {
@@ -73,7 +95,8 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
storage.getDoc(p).then(doc => { cbs.forEach(cb => cb(doc)); }).catch(() => {}); storage.getDoc(p).then(doc => { cbs.forEach(cb => cb(doc)); }).catch(() => {});
} }
for (const [p, cbs] of collSubs) { for (const [p, cbs] of collSubs) {
storage.getCollection(p).then(docs => { cbs.forEach(cb => cb(docs)); }).catch(() => {}); const cstr = collConstraints.get(p) || [];
storage.getCollection(p, cstr).then(docs => { cbs.forEach(cb => cb(docs)); }).catch(() => {});
} }
} }
resolve(ws); resolve(ws);
@@ -96,16 +119,14 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
let msg; try { msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString()); } catch { return; } let msg; try { msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString()); } catch { return; }
handleMessage(msg); handleMessage(msg);
}; };
// Bind ONCE. Browser WS + ws pkg are EventTargets: assigning onX AND
// addEventListener('x') registers the handler twice -> every event
// fires 2x (double onOpen -> spurious reconnect re-fetch, double
// onMessage -> emit dedupe races under write load -> UI looks stale).
ws.onopen = onOpen; ws.onopen = onOpen;
ws.onerror = onError; ws.onerror = onError;
ws.onclose = onClose; ws.onclose = onClose;
ws.onmessage = onMessage; ws.onmessage = onMessage;
if (typeof ws.addEventListener === 'function') {
ws.addEventListener('open', onOpen);
ws.addEventListener('error', onError);
ws.addEventListener('close', onClose);
ws.addEventListener('message', onMessage);
}
})(); })();
}); });
return wsReady; return wsReady;
@@ -113,7 +134,9 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
// Backend pushes change notices keyed by path. Re-fetch affected subscribers. // Backend pushes change notices keyed by path. Re-fetch affected subscribers.
async function handleMessage(msg) { async function handleMessage(msg) {
if (msg.type !== 'change' || !msg.change) return; if (msg.type !== 'change' || !msg.change) {
return;
}
const c = msg.change; const c = msg.change;
// doc subscriber at exact changed path // doc subscriber at exact changed path
const docCbs = docSubs.get(c.path); const docCbs = docSubs.get(c.path);
@@ -121,11 +144,13 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
const doc = await storage.getDoc(c.path); const doc = await storage.getDoc(c.path);
docCbs.forEach(cb => cb(doc)); docCbs.forEach(cb => cb(doc));
} }
// collection subscribers at parent path (doc belongs to this collection) // collection subscribers at parent path (apply their stored constraints)
if (c.parent) { if (c.parent) {
const collCbs = collSubs.get(c.parent); const collCbs = collSubs.get(c.parent);
if (collCbs) { if (collCbs) {
const docs = await storage.getCollection(c.parent); const constraints = collConstraints.get(c.parent) || [];
// Server honors orderBy + limit in SQL now.
const docs = await storage.getCollection(c.parent, constraints);
collCbs.forEach(cb => cb(docs)); collCbs.forEach(cb => cb(docs));
} }
} }
@@ -154,12 +179,19 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
async getDoc(rawPath) { async getDoc(rawPath) {
const p = norm(rawPath); const p = norm(rawPath);
const res = await api('GET', '/api/doc', { path: p }); const res = await api('GET', '/api/doc', { path: p });
return res && res.data !== undefined ? res.data : null; const data = res && res.data !== undefined ? res.data : null;
return withId(rawPath, data);
}, },
async setDoc(rawPath, data) { async setDoc(rawPath, data, opts = {}) {
const p = norm(rawPath); const p = norm(rawPath);
await api('PUT', '/api/doc', null, { path: p, data }); // merge flag -> PATCH (shallow merge, create-on-miss). Matches firebase
// setDoc(path, data, {merge:true}) semantics.
if (opts.merge) {
await api('PATCH', '/api/doc', null, { path: p, patch: data });
} else {
await api('PUT', '/api/doc', null, { path: p, data });
}
}, },
async updateDoc(rawPath, patch) { async updateDoc(rawPath, patch) {
@@ -172,15 +204,59 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
await api('DELETE', '/api/doc', { path: p }); await api('DELETE', '/api/doc', { path: p });
}, },
// Bulk delete collection (optionally where-filtered). No fetch. SQL DELETE.
// DEV ONLY. Method ships in prod build but throws if not dev.
async deleteCollection(rawCollPath, whereField, whereValue) {
if (process.env.NODE_ENV !== 'development' && process.env.NODE_ENV !== 'test') {
throw new Error('deleteCollection: dev only');
}
const p = norm(rawCollPath);
const query = { path: p };
if (whereField) { query.whereField = whereField; query.whereValue = whereValue; }
const res = await api('DELETE', '/api/collection', query);
return res.deleted;
},
async addDoc(rawCollPath, data) { async addDoc(rawCollPath, data) {
const p = norm(rawCollPath); const p = norm(rawCollPath);
const res = await api('POST', '/api/collection', null, { path: p, data }); const res = await api('POST', '/api/collection', null, { path: p, data });
return { id: res.id, path: res.path }; return { id: res.id, path: res.path };
}, },
async getCollection(rawCollPath) { async getCollection(rawCollPath, queryConstraints = []) {
const p = norm(rawCollPath); const p = norm(rawCollPath);
return await api('GET', '/api/collection', { path: p }); const query = { path: p };
// Push where + orderBy + limit + offset to server SQL when present.
// Avoids client-side full-scan + re-sort on every WS change notification.
for (const c of queryConstraints || []) {
if (!c || !c.__type) continue;
if (c.__type === 'where') {
query.whereField = c.field;
query.whereOp = c.op;
query.whereValue = c.value;
} else if (c.__type === 'orderBy') {
query.orderBy = c.field;
query.dir = c.dir || 'asc';
} else if (c.__type === 'limit') {
query.limit = c.n;
} else if (c.__type === 'offset') {
query.offset = c.offset;
}
}
const docs = await api('GET', '/api/collection', query);
if (!Array.isArray(docs)) return [];
return docs.map(d => {
if (d && typeof d === 'object' && 'id' in d && 'data' in d) {
return { id: d.id, ...d.data };
}
return { ...d };
});
},
async countCollection(rawCollPath) {
const p = norm(rawCollPath);
const res = await api('GET', '/api/collection/count', { path: p });
return (res && typeof res.count === 'number') ? res.count : 0;
}, },
async batchWrite(ops) { async batchWrite(ops) {
@@ -188,10 +264,20 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
await api('POST', '/api/batch', null, { ops: normOps }); await api('POST', '/api/batch', null, { ops: normOps });
}, },
subscribeDoc(rawPath, cb) { // Transactional undo (server mode only). Firebase falls back to 2-write
// in App.js (via store.txUndo check). undo = { encounterPath, updates, redo }.
// redo=true applies redo patch + flips undone:false.
async undo({ logPath, undo, redo = false }) {
const normUndo = { ...undo, encounterPath: norm(undo.encounterPath) };
const normLog = norm(logPath);
await api('POST', '/api/undo', null, { logPath: normLog, undo: normUndo, redo });
},
subscribeDoc(rawPath, cb, errCb) {
const p = norm(rawPath); const p = norm(rawPath);
// Initial value via REST (independent of WS connect). lastEmitted.delete('doc:'+p); // fresh sub → allow first emit
storage.getDoc(p).then(cb).catch(() => {}); // Initial value via REST (independent of WS connect). getDoc injects id.
storage.getDoc(p).then(doc => emit('doc', p, doc, docSubs.get(p) || new Set([cb]))).catch(() => {});
// WS only for subsequent change notifications. // WS only for subsequent change notifications.
ensureWs().then(() => { ensureWs().then(() => {
ws.send(JSON.stringify({ type: 'subscribe', kind: 'doc', path: p })); ws.send(JSON.stringify({ type: 'subscribe', kind: 'doc', path: p }));
@@ -201,10 +287,14 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
return () => { docSubs.get(p)?.delete(cb); }; return () => { docSubs.get(p)?.delete(cb); };
}, },
subscribeCollection(rawCollPath, cb) { subscribeCollection(rawCollPath, cb, queryConstraints = [], errCb) {
const p = norm(rawCollPath); const p = norm(rawCollPath);
// Initial value via REST (independent of WS connect). collConstraints.set(p, queryConstraints || []);
storage.getCollection(p).then(cb).catch(() => {}); lastEmitted.delete('coll:'+p); // fresh sub → allow first emit
// Server honors orderBy + limit in SQL now. No client re-sort.
storage.getCollection(p, queryConstraints || [])
.then(docs => emit('coll', p, docs, collSubs.get(p) || new Set([cb])))
.catch(err => { if (errCb) errCb(err); });
// WS only for subsequent change notifications. // WS only for subsequent change notifications.
ensureWs().then(() => { ensureWs().then(() => {
ws.send(JSON.stringify({ type: 'subscribe', kind: 'collection', path: p })); ws.send(JSON.stringify({ type: 'subscribe', kind: 'collection', path: p }));
@@ -218,7 +308,7 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
disposed = true; disposed = true;
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
if (ws) ws.close(); if (ws) ws.close();
docSubs.clear(); collSubs.clear(); docSubs.clear(); collSubs.clear(); collConstraints.clear();
if (typeof cb === 'function') cb(); if (typeof cb === 'function') cb();
}, },
@@ -234,4 +324,4 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
return storage; return storage;
} }
export { createWsStorage }; export { createServerStorage };
+50 -1
View File
@@ -7,7 +7,7 @@ import React from 'react';
import { screen, fireEvent, waitFor } from '@testing-library/react'; import { screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { getCalls, MOCK_DB } from '../__mocks__/firebase/_mock-db'; import { getCalls, MOCK_DB } from '../__mocks__/firebase/_mock-db';
import { renderApp, createCampaignViaUI, selectCampaignByName } from './testHelpers'; import { renderApp, createCampaignViaUI, selectCampaignByName, setupReady, startCombatViaUI, addMonsterViaUI } from './testHelpers';
function findCall(fn, pathSub) { function findCall(fn, pathSub) {
return getCalls().find(c => c.fn === fn && (pathSub ? c.path.includes(pathSub) : true)); return getCalls().find(c => c.fn === fn && (pathSub ? c.path.includes(pathSub) : true));
@@ -139,4 +139,53 @@ describe('Campaign -> Firebase', () => {
const delCall = findCall('deleteDoc', `/campaigns/${cid}`); const delCall = findCall('deleteDoc', `/campaigns/${cid}`);
expect(delCall).toBeDefined(); expect(delCall).toBeDefined();
}); });
test('deleteCampaign: cascade-deletes logs for campaign encounters (BUG)', async () => {
const { setupReady, startCombatViaUI } = require('./testHelpers');
await setupReady('Doomed', 'Enc');
await addMonsterViaUI('Gob', 5, 2);
await startCombatViaUI();
// log written on start
await waitFor(() => {
const logWrites = findCalls('addDoc').filter(c => c.path.includes('/logs'));
expect(logWrites.length).toBeGreaterThan(0);
});
const cid = Object.keys(MOCK_DB.collection('campaigns').reduce((m,c)=>(m[c.id]=c,m),{}))[0];
// delete campaign
const allDeletes = screen.getAllByText(/Delete/i);
fireEvent.click(allDeletes[allDeletes.length - 1]);
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
await waitFor(() => findCall('deleteDoc', `/campaigns/${cid}`));
// logs MUST be deleted via batch or deleteDoc on each log path
const logDeletes = getCalls().filter(c =>
(c.fn === 'deleteDoc' && c.path.includes('/logs/')) ||
(c.fn === 'batch.delete' && c.path.includes('/logs/'))
);
expect(logDeletes.length).toBeGreaterThan(0);
});
test('deleteEncounter: cascade-deletes logs for encounter (BUG)', async () => {
const { setupReady, startCombatViaUI } = require('./testHelpers');
await setupReady('Camp', 'DoomedEnc');
await addMonsterViaUI('Gob', 5, 2);
await startCombatViaUI();
await waitFor(() => {
expect(findCalls('addDoc').filter(c => c.path.includes('/logs')).length).toBeGreaterThan(0);
});
// delete encounter
const allDeletes = screen.getAllByText(/Delete/i);
fireEvent.click(allDeletes[0]);
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
await waitFor(() => {
const logDeletes = getCalls().filter(c =>
(c.fn === 'deleteDoc' && c.path.includes('/logs/')) ||
(c.fn === 'batch.delete' && c.path.includes('/logs/'))
);
expect(logDeletes.length).toBeGreaterThan(0);
});
});
}); });
+9 -7
View File
@@ -41,7 +41,7 @@ describe('Combat -> Firebase', () => {
test('startEncounter: also sets activeDisplay to this encounter', async () => { test('startEncounter: also sets activeDisplay to this encounter', async () => {
await setupWithMonsters(); await setupWithMonsters();
await startCombatViaUI(); await startCombatViaUI();
const adCalls = findCallActiveDisplay('updateDoc'); const adCalls = findCallActiveDisplay('setDoc');
const last = adCalls[adCalls.length - 1]; const last = adCalls[adCalls.length - 1];
expect(last.data.activeCampaignId).toBeTruthy(); expect(last.data.activeCampaignId).toBeTruthy();
expect(last.data.activeEncounterId).toBeTruthy(); expect(last.data.activeEncounterId).toBeTruthy();
@@ -111,26 +111,28 @@ describe('Combat -> Firebase', () => {
fireEvent.click(screen.getByRole('button', { name: /End Combat/i })); fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i })); fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
await waitFor(() => { await waitFor(() => {
const adCalls = findCallActiveDisplay('updateDoc'); const adCalls = findCallActiveDisplay('setDoc');
const last = adCalls[adCalls.length - 1]; const last = adCalls[adCalls.length - 1];
return last && last.data.activeCampaignId === null; return last && last.data.activeCampaignId === null;
}); });
const adCalls = findCallActiveDisplay('updateDoc'); const adCalls = findCallActiveDisplay('setDoc');
const last = adCalls[adCalls.length - 1]; const last = adCalls[adCalls.length - 1];
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null }); expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
}); });
test('toggleHidePlayerHp: updateDoc patch on activeDisplay/status', async () => { test('toggleHidePlayerHp: setDoc{merge} patch on activeDisplay/status', async () => {
await setupWithMonsters(); await setupWithMonsters();
await startCombatViaUI(); await startCombatViaUI();
const switchBtn = screen.getByRole('switch'); // Two switches now (Hide player HP + Hide NPC/monster HP). Scope to player one.
const playerHpLabel = screen.getByText('Hide player HP');
const switchBtn = playerHpLabel.parentElement.querySelector('[role="switch"]');
fireEvent.click(switchBtn); fireEvent.click(switchBtn);
await waitFor(() => { await waitFor(() => {
const adCalls = findCallActiveDisplay('updateDoc'); const adCalls = findCallActiveDisplay('setDoc');
const last = adCalls[adCalls.length - 1]; const last = adCalls[adCalls.length - 1];
return last && 'hidePlayerHp' in last.data; return last && 'hidePlayerHp' in last.data;
}); });
const adCalls = findCallActiveDisplay('updateDoc'); const adCalls = findCallActiveDisplay('setDoc');
const last = adCalls[adCalls.length - 1]; const last = adCalls[adCalls.length - 1];
expect(last.data).toHaveProperty('hidePlayerHp'); expect(last.data).toHaveProperty('hidePlayerHp');
}); });
+343 -305
View File
@@ -1,340 +1,378 @@
// Combat.scenario.test.js // Combat.scenario.test.js
// Full combat scenario: campaign -> encounter -> participants -> 100 rounds of // Full combat scenario driven through shared/turn.js directly --- NO React.
// damage/heal/conditions/toggle-active/edit/death-save/pause/resume/add/remove.
// Drives the SAME UI buttons a DM clicks. Failing assertions do NOT abort the run:
// each phase wraps in try/catch, failures collected, final expect reports all.
// //
// Purpose: exercise as much of the supported feature surface as possible in one // Does 100 ROUNDS (not turns). A round = one full pass through initiative
// long combat, surfacing behavioral bugs characterization tests miss. // (every active participant acts once); round counter increments at wrap.
//
// Exercises ALL combat paths deterministically:
// startEncounter, nextTurn, togglePause, addParticipant, addParticipants,
// updateParticipant, removeParticipant, toggleParticipantActive,
// applyHpChange (damage/heal), deathSave, toggleCondition,
// reorderParticipants, endEncounter.
//
// New API: funcs async, take ctx {storage, encPath, logPath, displayPath},
// write encounter + log internally, return newEnc. Mock storage captures writes.
// Display lifecycle now async too — but test keeps inline display writes (mirrors App.js).
//
// Failing assertions do NOT abort the run: each phase wrapped in try/catch,
// failures collected, final expect reports all.
import React from 'react'; const path = require('path');
import { screen, fireEvent, waitFor, within } from '@testing-library/react'; const { createMockStorage, mockCtx, readyEnc } = require('../../shared/tests/_helpers');
import '@testing-library/jest-dom'; const {
import App from '../App'; buildCharacterParticipant,
import { buildMonsterParticipant,
renderApp, createCampaignViaUI, selectCampaignByName, startEncounter, nextTurn, togglePause, endEncounter,
createEncounterViaUI, selectEncounterByName, addMonsterViaUI, setupReady, addParticipant, addParticipants, updateParticipant, removeParticipant,
} from './testHelpers'; toggleParticipantActive: combatToggleActive,
import { getCalls, MOCK_DB } from '../__mocks__/firebase/_mock-db'; applyHpChange: combatApplyHpChange,
deathSave: combatDeathSave,
stabilizeParticipant,
reviveParticipant,
toggleCondition: combatToggleCondition,
reorderParticipants,
} = require('../../shared');
// ---------- scenario helpers (UI only, same buttons as human) ---------- // ---------- scenario state ----------
const ROUNDS = 100;
const ENCOUNTER_ID = 'enc-1';
let enc = {
id: ENCOUNTER_ID,
name: 'BigBoss',
participants: [],
isStarted: false,
isPaused: false,
round: 0,
currentTurnParticipantId: null,
turnOrderIds: [],
};
// display tracked via mock storage displayPath doc; simple local mirror.
let display = {
activeCampaignId: null,
activeEncounterId: null,
hidePlayerHp: true,
hideNpcHp: false,
};
const CAMPAIGN_ID = 'camp-1';
const CAMPAIGN_PATH = `campaigns/${CAMPAIGN_ID}`;const roster = [];
let storage, ctx;
const DISPLAY_PATH = 'activeDisplay/status';
const RESULTS = []; const RESULTS = [];
function record(phase, fn) { async function record(phase, fn) {
try { fn(); RESULTS.push({ phase, ok: true }); }
catch (err) { RESULTS.push({ phase, ok: false, err: err.message }); }
}
async function recordAsync(phase, fn) {
try { await fn(); RESULTS.push({ phase, ok: true }); } try { await fn(); RESULTS.push({ phase, ok: true }); }
catch (err) { RESULTS.push({ phase, ok: false, err: err.message }); } catch (err) { RESULTS.push({ phase, ok: false, err: err.message }); }
} }
function getParticipantForm() { // ---------- helpers ----------
const heading = screen.getByText('Add Participants');
let node = heading; const alive = (name) => enc.participants.find(p => p.name === name && p.currentHp > 0);
for (let i = 0; i < 6; i++) { const any = (name) => enc.participants.find(p => p.name === name);
node = node.parentElement; const anyById = (id) => enc.participants.find(p => p.id === id);
if (!node) break;
if (node.querySelector('form')) return within(node); function addCharacterViaUI(name, maxHp, initMod) {
const character = { id: `char-${name}`, name, defaultMaxHp: maxHp, defaultInitMod: initMod };
roster.push(character);
// persist to campaign doc (mirrors app: players array)
if (storage) {
const existing = (storage.docs.get(CAMPAIGN_PATH) || {}).players || [];
storage.setDoc(CAMPAIGN_PATH, {
id: CAMPAIGN_ID, name: 'Scenario Campaign', createdAt: Date.now(),
players: [...existing, character],
});
} }
return within(heading.parentElement);
} }
// Find a participant's encounter <li> row by name. Scoped to the encounter async function addMonsterParticipant(name, maxHp, initMod, asNpc = false) {
// participant list (NOT the CharacterManager roster, which also shows names). const { participant } = buildMonsterParticipant({ name, maxHp, initMod, asNpc });
// Encounter participant rows render an 'Init:' label; roster rows do not. enc = await addParticipant(enc, participant, ctx);
function getParticipantRow(name) {
const lis = document.querySelectorAll('li');
for (const li of lis) {
const txt = li.textContent || '';
if (txt.includes('Init:') && txt.includes(name)) {
return within(li);
}
}
throw new Error(`encounter participant row not found: ${name}`);
} }
// Character roster (CharacterManager). Assumes campaign selected.
async function addCharacterViaUI(name, maxHp, initMod) {
fireEvent.change(document.getElementById('characterName'), { target: { value: name } });
fireEvent.change(document.getElementById('defaultMaxHp'), { target: { value: String(maxHp) } });
fireEvent.change(document.getElementById('defaultInitMod'), { target: { value: String(initMod) } });
fireEvent.click(screen.getByRole('button', { name: /^Add Character$/i }));
await waitFor(() => {
const call = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') &&
Array.isArray(c.data.players) && c.data.players.some(p => p.name === name));
if (!call) throw new Error('char not persisted');
});
}
function setParticipantType(type) {
// The Type select is inside the Add Participants form.
const form = getParticipantForm();
const selects = form.getAllByRole('combobox');
// first combobox in the participant form is Type
fireEvent.change(selects[0], { target: { value: type } });
}
async function addMonsterParticipant(name, maxHp, initMod, isNpc = false) {
const form = getParticipantForm();
setParticipantType('monster');
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(initMod) } });
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(maxHp) } });
if (isNpc) {
const npcCheck = form.getByRole('checkbox', { name: /NPC/i });
if (!npcCheck.checked) fireEvent.click(npcCheck);
}
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
if (!last || !last.data.participants?.some(p => p.name === name)) throw new Error('monster not added');
});
}
async function addCharacterParticipant(charName) { async function addCharacterParticipant(charName) {
const form = getParticipantForm(); const character = roster.find(c => c.name === charName);
setParticipantType('character'); if (!character) throw new Error(`char not found: ${charName}`);
// character select is the 2nd combobox in the form after Type if (enc.participants.some(p => p.type === 'character' && p.originalCharacterId === character.id)) {
const charSelect = form.getAllByRole('combobox')[1]; throw new Error(`${charName} already in encounter`);
// find option whose text includes the char name }
const opt = [...charSelect.querySelectorAll('option')].find(o => o.textContent.includes(charName)); const { participant } = buildCharacterParticipant(character);
if (!opt) throw new Error(`char option not found: ${charName}`); enc = await addParticipant(enc, participant, ctx);
fireEvent.change(charSelect, { target: { value: opt.value } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
if (!last || !last.data.participants?.some(p => p.name === charName)) throw new Error('char not added');
});
} }
async function addAllCharacters() { async function addAllCharacters() {
fireEvent.click(screen.getByRole('button', { name: /Add All/i })); const toAdd = roster
await waitFor(() => { .filter(c => !enc.participants.some(p => p.type === 'character' && p.originalCharacterId === c.id))
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0]; .map(c => buildCharacterParticipant(c).participant);
if (!last) throw new Error('add all no-op'); enc = await addParticipants(enc, toAdd, ctx);
});
}
function applyDamage(name, amount) {
const row = getParticipantRow(name);
const dmgBtn = row.queryByTitle('Damage');
if (!dmgBtn) {
// participant dead (Damage button hidden when currentHp===0). Expected game
// state over a long fight; not a bug. Skip silently.
return;
}
fireEvent.change(row.getByLabelText(`HP change for ${name}`), { target: { value: String(amount) } });
fireEvent.click(dmgBtn);
}
function applyHeal(name, amount) {
const row = getParticipantRow(name);
const healBtn = row.queryByTitle('Heal / Revive') || row.queryByTitle('Heal');
if (!healBtn) throw new Error(`${name} has no Heal button`);
fireEvent.change(row.getByLabelText(`HP change for ${name}`), { target: { value: String(amount) } });
fireEvent.click(healBtn);
}
function toggleActive(name) {
const row = getParticipantRow(name);
const btn = row.queryByTitle('Mark Active') || row.queryByTitle('Mark Inactive');
if (!btn) throw new Error(`${name} has no active toggle`);
fireEvent.click(btn);
}
function openConditions(name) {
const row = getParticipantRow(name);
const btn = row.getByTitle('Conditions');
// idempotent: ensure panel open. Click toggles; if another participant's panel
// was open it's already closed by this participant's row focus, so just click.
fireEvent.click(btn);
}
function toggleCondition(name, label) {
openConditions(name);
// panel render is async (React state). Wait for button by title.
return waitFor(() => {
const condButtons = document.querySelectorAll('button[title]');
const target = [...condButtons].find(b => b.getAttribute('title') === label);
if (!target) throw new Error(`condition button not found: ${label}`);
fireEvent.click(target);
});
}
function editParticipant(name, patch) {
const row = getParticipantRow(name);
fireEvent.click(row.getByTitle('Edit'));
// EditParticipantModal. Scope to the modal via its form inputs.
const modal = document.querySelector('.fixed.inset-0') || document.body;
const inputs = modal.querySelectorAll('input');
if (patch.name !== undefined) {
fireEvent.change(inputs[0], { target: { value: patch.name } });
}
if (patch.initiative !== undefined && inputs[1]) {
fireEvent.change(inputs[1], { target: { value: String(patch.initiative) } });
}
const saveBtn = modal.querySelector('button[type="submit"]') ||
[...modal.querySelectorAll('button')].find(b => /^Save$/i.test(b.textContent.trim()));
fireEvent.click(saveBtn);
}
function removeParticipant(name) {
fireEvent.click(getParticipantRow(name).getByTitle('Remove'));
}
async function deathSave(name, saveNum) {
const row = getParticipantRow(name);
const btn = row.getByTitle(`Death save ${saveNum}`);
fireEvent.click(btn);
await waitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
if (!last) throw new Error('deathSave no write');
});
}
async function nextTurn() {
fireEvent.click(screen.getByRole('button', { name: /Next Turn/i }));
await waitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
if (!last) throw new Error('nextTurn no write');
});
}
async function pauseCombat() {
fireEvent.click(screen.getByRole('button', { name: /Pause Combat/i }));
await waitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
if (!last?.data?.isPaused) throw new Error('not paused');
});
}
async function resumeCombat() {
fireEvent.click(screen.getByRole('button', { name: /Resume Combat/i }));
await waitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
if (last?.data?.isPaused) throw new Error('not resumed');
});
} }
async function startCombat() { async function startCombat() {
fireEvent.click(screen.getByRole('button', { name: /Start Combat/i })); enc = await startEncounter(enc, ctx);
await waitFor(() => { display.activeCampaignId = CAMPAIGN_ID;
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0]; display.activeEncounterId = ENCOUNTER_ID;
if (!last?.data?.isStarted) throw new Error('not started'); await storage.updateDoc(DISPLAY_PATH, { activeCampaignId: CAMPAIGN_ID, activeEncounterId: ENCOUNTER_ID });
});
} }
function toggleHidePlayerHp() { async function endCombat() {
fireEvent.click(screen.getByRole('switch')); enc = await endEncounter(enc, ctx);
display.activeCampaignId = null;
display.activeEncounterId = null;
await storage.updateDoc(DISPLAY_PATH, { activeCampaignId: null, activeEncounterId: null });
} }
function currentEncDoc() { async function nextTurnAction() { enc = await nextTurn(enc, ctx); }
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')); async function pauseCombat() { enc = await togglePause(enc, ctx); if (!enc.isPaused) throw new Error('not paused'); }
return calls[calls.length - 1]?.data; async function resumeCombat() { enc = await togglePause(enc, ctx); if (enc.isPaused) throw new Error('not resumed'); }
async function applyDamage(name, amount, options = null) {
const p = any(name);
if (!p || (p.currentHp <= 0 && p.status === 'dead')) return;
enc = options
? await combatApplyHpChange(enc, p.id, 'damage', amount, options, ctx)
: await combatApplyHpChange(enc, p.id, 'damage', amount, ctx);
}
async function applyHeal(name, amount) {
const p = any(name);
if (!p) return;
enc = await combatApplyHpChange(enc, p.id, 'heal', amount, ctx);
}
async function toggleActive(name) {
const p = any(name);
if (!p) throw new Error(`no active target: ${name}`);
enc = await combatToggleActive(enc, p.id, ctx);
}
async function toggleConditionAction(name, label) {
const p = any(name);
if (!p) throw new Error(`no condition target: ${name}`);
enc = await combatToggleCondition(enc, p.id, label, ctx);
}
async function editParticipant(name, patch) {
const p = any(name);
if (!p) throw new Error(`no edit target: ${name}`);
enc = await updateParticipant(enc, p.id, patch, ctx);
}
async function removeParticipantByName(name) {
const p = any(name);
if (!p) throw new Error(`no remove target: ${name}`);
enc = await removeParticipant(enc, p.id, ctx);
}
async function deathSaveAction(name, outcome) {
const p = any(name);
if (!p) throw new Error(`no deathsave target: ${name}`);
const r = await combatDeathSave(enc, p.id, outcome, ctx);
enc = r.enc;
}
async function stabilizeAction(name) {
const p = any(name);
if (!p) throw new Error(`no stabilize target: ${name}`);
enc = await stabilizeParticipant(enc, p.id, ctx);
}
async function reviveAction(name) {
const p = any(name);
if (!p) throw new Error(`no revive target: ${name}`);
enc = await reviveParticipant(enc, p.id, ctx);
}
async function dragTie(draggedName, targetName) {
const d = any(draggedName), t = any(targetName);
if (!d || !t) throw new Error('drag target missing');
enc = await reorderParticipants(enc, d.id, t.id, ctx);
}
async function toggleHidePlayerHpAction() {
display.hidePlayerHp = !display.hidePlayerHp;
await storage.updateDoc(DISPLAY_PATH, { hidePlayerHp: display.hidePlayerHp });
} }
// ---------- scenario ---------- // ---------- scenario ----------
const ROUNDS = 100; beforeEach(() => {
({ storage, ctx } = mockCtx());
enc = {
id: ENCOUNTER_ID, name: 'BigBoss', participants: [],
isStarted: false, isPaused: false, round: 0,
currentTurnParticipantId: null, turnOrderIds: [],
};
display = { activeCampaignId: null, activeEncounterId: null, hidePlayerHp: true, hideNpcHp: false };
roster.length = 0;
RESULTS.length = 0;
});
test('full 100-round combat scenario', async () => { test('death-save edge cases in combat scenario helpers', async () => {
await setupReady('ScenarioCamp', 'BigBoss'); await record('addChar Fighter', () => addCharacterViaUI('Fighter', 30, 2));
await record('addChar Cleric', () => addCharacterViaUI('Cleric', 24, 1));
await record('addAllChars', () => addAllCharacters());
await record('addMonster Goblin', () => addMonsterParticipant('Goblin', 8, 2));
await record('addNpc Merchant', () => addMonsterParticipant('Merchant', 12, 0, true));
await record('startCombat', () => startCombat());
// roster await record('monster drops dead inactive', async () => {
await recordAsync('addChar Fighter', () => addCharacterViaUI('Fighter', 30, 2)); await applyDamage('Goblin', any('Goblin').currentHp);
await recordAsync('addChar Cleric', () => addCharacterViaUI('Cleric', 24, 1)); const g = any('Goblin');
await recordAsync('addChar Rogue', () => addCharacterViaUI('Rogue', 22, 3)); expect(g.status).toBe('dead');
expect(g.isActive).toBe(false);
// monsters + npcs
await recordAsync('addMonster Goblin1', () => addMonsterParticipant('Goblin1', 8, 2));
await recordAsync('addMonster Goblin2', () => addMonsterParticipant('Goblin2', 8, 2));
await recordAsync('addMonster OrcBoss', () => addMonsterParticipant('OrcBoss', 60, 1));
await recordAsync('addMonster Wolf', () => addMonsterParticipant('Wolf', 14, 3));
await recordAsync('addNpc Merchant', () => addMonsterParticipant('Merchant', 12, 0, true));
// add chars into encounter
await recordAsync('addCharParticipant Fighter', () => addCharacterParticipant('Fighter'));
await recordAsync('addCharParticipant Cleric', () => addCharacterParticipant('Cleric'));
await recordAsync('addCharParticipant Rogue', () => addCharacterParticipant('Rogue'));
await recordAsync('addAllChars', () => addAllCharacters());
// hidden hp toggle
record('toggleHidePlayerHp', () => toggleHidePlayerHp());
record('toggleHidePlayerHp back', () => toggleHidePlayerHp());
await recordAsync('startCombat', () => startCombat());
// 100 rounds of mixed actions
for (let r = 1; r <= ROUNDS; r++) {
await recordAsync(`round ${r} nextTurn`, () => nextTurn());
// rotation integrity: turnOrderIds no dup, currentTurn valid
if (r % 10 === 0) {
record(`round ${r} rotation-check`, () => {
const enc = currentEncDoc();
if (!enc) throw new Error('no encounter doc');
const order = enc.turnOrderIds || [];
const uniq = new Set(order);
if (uniq.size !== order.length) {
throw new Error(`turnOrderIds dup: ${JSON.stringify(order)}`);
}
if (enc.currentTurnParticipantId && !order.includes(enc.currentTurnParticipantId)) {
throw new Error(`currentTurn ${enc.currentTurnParticipantId} not in turnOrderIds`);
}
});
}
// damage front monster every other round
if (r % 2 === 0) record(`round ${r} damage OrcBoss`, () => applyDamage('OrcBoss', 3));
if (r % 3 === 0) record(`round ${r} heal Cleric`, () => applyHeal('Cleric', 2));
if (r % 5 === 0) record(`round ${r} condition Fighter stunned`, () => toggleCondition('Fighter', 'Stunned'));
if (r % 7 === 0) record(`round ${r} toggleActive Goblin2`, () => toggleActive('Goblin2'));
// pause/resume every 10 rounds, add a participant, resume
if (r % 10 === 0) {
await recordAsync(`round ${r} pause`, () => pauseCombat());
await recordAsync(`round ${r} addReinforcement`, () =>
addMonsterParticipant(`Reinforce${r}`, 10, 1));
await recordAsync(`round ${r} edit Rogue initiative`, () => editParticipant('Rogue', { initiative: 20 }));
await recordAsync(`round ${r} resume`, () => resumeCombat());
}
// edit initiative on Wolf every 13
if (r % 13 === 0) record(`round ${r} edit Wolf init`, () => editParticipant('Wolf', { initiative: 15 }));
// damage-to-0 + death save on Rogue around round 25 and 50
if (r === 25) {
record(`round ${r} drop Rogue`, () => applyDamage('Rogue', 99));
record(`round ${r} deathSave1 Rogue`, () => deathSave('Rogue', 1));
record(`round ${r} revive Rogue`, () => applyHeal('Rogue', 22));
}
if (r === 50) {
record(`round ${r} drop Cleric`, () => applyDamage('Cleric', 99));
record(`round ${r} deathSave Cleric x3`, async () => {
await deathSave('Cleric', 1);
await deathSave('Cleric', 2);
await deathSave('Cleric', 3);
});
record(`round ${r} revive Cleric`, () => applyHeal('Cleric', 24));
}
// remove a reinforcement late
if (r === 30) {
await recordAsync(`round ${r} pause`, () => pauseCombat());
record(`round ${r} remove Reinforce20`, () => removeParticipant('Reinforce20'));
await recordAsync(`round ${r} resume`, () => resumeCombat());
}
}
await recordAsync('endCombat', async () => {
fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
// End-combat ConfirmationModal has title 'End Encounter?'. Scope Confirm to it.
const endConfirm = await screen.findByRole('heading', { name: /End Encounter/i });
const modal = endConfirm.closest('.fixed.inset-0') || document.body;
const confirmBtn = [...modal.querySelectorAll('button')].find(b => /Confirm/i.test(b.textContent.trim()));
fireEvent.click(confirmBtn);
await waitFor(() => {
const last = currentEncDoc();
if (last?.isStarted !== false) throw new Error('not ended');
});
}); });
// ---------- report ---------- await record('npc nat20 returns conscious', async () => {
const failed = RESULTS.filter(r => !r.ok); await applyDamage('Merchant', any('Merchant').currentHp);
expect(any('Merchant').status).toBe('dying');
await deathSaveAction('Merchant', 'nat20');
expect(any('Merchant').status).toBe('conscious');
expect(any('Merchant').currentHp).toBe(1);
});
await record('npc nat1 plus damage kills, revive/heal restores', async () => {
await applyDamage('Merchant', any('Merchant').currentHp);
await deathSaveAction('Merchant', 'nat1');
expect(any('Merchant').deathSaveFailures).toBe(2);
await applyDamage('Merchant', 1);
expect(any('Merchant').status).toBe('dead');
expect(any('Merchant').isActive).toBe(true);
await reviveAction('Merchant');
expect(any('Merchant').status).toBe('stable');
await applyHeal('Merchant', 5);
expect(any('Merchant').status).toBe('conscious');
expect(any('Merchant').currentHp).toBe(5);
});
await record('character stable then crit damage dies', async () => {
await applyDamage('Fighter', any('Fighter').currentHp);
await deathSaveAction('Fighter', 'success');
await deathSaveAction('Fighter', 'success');
await deathSaveAction('Fighter', 'success');
expect(any('Fighter').status).toBe('stable');
await applyDamage('Fighter', 1);
expect(any('Fighter').status).toBe('dying');
expect(any('Fighter').deathSaveFailures).toBe(1);
await applyDamage('Fighter', 1, { isCriticalHit: true });
expect(any('Fighter').status).toBe('dead');
expect(any('Fighter').isActive).toBe(true);
});
await record('massive damage kills character but does not deactivate', async () => {
await applyDamage('Cleric', any('Cleric').currentHp + any('Cleric').maxHp);
expect(any('Cleric').status).toBe('dead');
expect(any('Cleric').isActive).toBe(true);
});
const failed = RESULTS.filter(x => !x.ok);
if (failed.length > 0) { if (failed.length > 0) {
const msg = failed.map(f => `FAIL [${f.phase}]: ${f.err}`).join('\n'); const msg = failed.map(f => `FAIL [${f.phase}]: ${f.err}`).join('\n');
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(`\n=== SCENARIO FAILURES (${failed.length}/${RESULTS.length}) ===\n${msg}\n`); console.error(`\n=== SCENARIO FAILURES (${failed.length}/${RESULTS.length}) ===\n${msg}\n`);
} }
// eslint-disable-next-line no-console
console.log(`\n=== SCENARIO: ${RESULTS.length - failed.length}/${RESULTS.length} phases ok ===\n`);
expect(failed).toEqual([]); expect(failed).toEqual([]);
}, 240000); // long timeout: 100 rounds });
test('full 100-round combat scenario (shared, no React)', async () => {
await record('addChar Fighter', () => addCharacterViaUI('Fighter', 30, 2));
await record('addChar Cleric', () => addCharacterViaUI('Cleric', 24, 1));
await record('addChar Rogue', () => addCharacterViaUI('Rogue', 22, 3));
await record('addMonster Goblin1', () => addMonsterParticipant('Goblin1', 8, 2));
await record('addMonster Goblin2', () => addMonsterParticipant('Goblin2', 8, 2));
await record('addMonster OrcBoss', () => addMonsterParticipant('OrcBoss', 60, 1));
await record('addMonster Wolf', () => addMonsterParticipant('Wolf', 14, 3));
await record('addNpc Merchant', () => addMonsterParticipant('Merchant', 12, 0, true));
await record('addCharParticipant Fighter', () => addCharacterParticipant('Fighter'));
await record('addAllChars Cleric/Rogue', () => addAllCharacters());
await record('toggleHidePlayerHp', () => toggleHidePlayerHpAction());
await record('toggleHidePlayerHp back', () => toggleHidePlayerHpAction());
await record('startCombat', () => startCombat());
// ---------- 100 ROUNDS ----------
let roundsDone = 0;
let prevRound = enc.round;
let turnInRound = 0;
while (roundsDone < ROUNDS) {
const r = enc.round;
if (r % 2 === 0 && turnInRound === 0) await record(`r${r} damage OrcBoss`, () => applyDamage('OrcBoss', 3));
if (r % 3 === 0 && turnInRound === 1) await record(`r${r} heal Cleric`, () => applyHeal('Cleric', 2));
if (r % 5 === 0 && turnInRound === 2) await record(`r${r} condition Fighter stunned`, () => toggleConditionAction('Fighter', 'Stunned'));
if (r % 7 === 0 && turnInRound === 3) await record(`r${r} toggleActive Goblin2`, () => toggleActive('Goblin2'));
if (r % 13 === 0 && turnInRound === 4) await record(`r${r} edit Wolf init`, () => editParticipant('Wolf', { initiative: 15 }));
if (r % 10 === 0 && turnInRound === 0) {
await record(`r${r} pause`, () => pauseCombat());
await record(`r${r} addReinforcement`, () => addMonsterParticipant(`Reinforce${r}`, 10, 1));
await record(`r${r} edit Rogue initiative`, () => editParticipant('Rogue', { initiative: 20 }));
await record(`r${r} resume`, () => resumeCombat());
}
if (r % 15 === 0 && turnInRound === 5) {
const g1 = any('Goblin1');
if (g1 && alive('Goblin2')) {
await record(`r${r} tie Goblin2->Goblin1 init`, () => editParticipant('Goblin2', { initiative: g1.initiative }));
await record(`r${r} drag Goblin2 before Goblin1`, () => dragTie('Goblin2', 'Goblin1'));
}
}
if (r === 25 && turnInRound === 0) {
await record(`r${r} drop Rogue`, () => applyDamage('Rogue', any('Rogue').currentHp));
await record(`r${r} deathSave1 Rogue`, () => deathSaveAction('Rogue', 'fail'));
await record(`r${r} revive Rogue`, () => applyHeal('Rogue', 22));
}
if (r === 50 && turnInRound === 0) {
await record(`r${r} drop Cleric`, () => applyDamage('Cleric', any('Cleric').currentHp));
await record(`r${r} deathSave Cleric x3 (dead)`, async () => {
await deathSaveAction('Cleric', 'fail');
await deathSaveAction('Cleric', 'fail');
await deathSaveAction('Cleric', 'fail');
});
const cl = any('Cleric');
if (cl) {
expect(cl.status).toBe('dead');
expect(enc.participants.map(p => p.name)).toContain('Cleric');
}
}
if (r % 30 === 0 && turnInRound === 1) {
const rein = any(`Reinforce${r - 10}`);
if (rein) await record(`r${r} remove Reinforce${r - 10}`, () => removeParticipantByName(`Reinforce${r - 10}`));
}
if (r % 20 === 0 && turnInRound === 0) {
await record(`r${r} toggleHidePlayerHp`, () => toggleHidePlayerHpAction());
await record(`r${r} toggleHidePlayerHp back`, () => toggleHidePlayerHpAction());
}
if (turnInRound === 0 && r % 10 === 0) {
await record(`r${r} rotation-check`, () => {
const order = enc.turnOrderIds || [];
const uniq = new Set(order);
if (uniq.size !== order.length) throw new Error(`turnOrderIds dup: ${JSON.stringify(order)}`);
if (enc.currentTurnParticipantId && !order.includes(enc.currentTurnParticipantId)) {
throw new Error(`currentTurn ${enc.currentTurnParticipantId} not in turnOrderIds`);
}
const ids = enc.participants.map(p => p.id);
if (JSON.stringify(order) !== JSON.stringify(ids)) {
throw new Error(`turnOrderIds drift: order=${JSON.stringify(order)} ids=${JSON.stringify(ids)}`);
}
});
}
await record(`r${r} turn${turnInRound} nextTurn`, () => nextTurnAction());
turnInRound++;
if (enc.round > prevRound) {
roundsDone++;
prevRound = enc.round;
turnInRound = 0;
}
}
await record('endCombat', () => endCombat());
const failed = RESULTS.filter(x => !x.ok);
if (failed.length > 0) {
const msg = failed.map(f => `FAIL [${f.phase}]: ${f.err}`).join('\n');
// eslint-disable-next-line no-console
console.error(`\n=== SCENARIO FAILURES (${failed.length}/${RESULTS.length}) ===\n${msg}\n`);
}
expect(failed).toEqual([]);
expect(roundsDone).toBe(ROUNDS);
expect(enc.isStarted).toBe(false);
expect(enc.round).toBe(0);
expect(enc.currentTurnParticipantId).toBeNull();
});
+52 -3
View File
@@ -4,29 +4,46 @@
// Test asserts adapter recorder shows subscribeDoc calls when player view boots. // Test asserts adapter recorder shows subscribeDoc calls when player view boots.
import React from 'react'; import React from 'react';
import { render, waitFor } from '@testing-library/react'; import { render, waitFor, screen } from '@testing-library/react';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import App from '../App'; import App from '../App';
import { MOCK_DB } from '../__mocks__/firebase/_mock-db'; import { MOCK_DB } from '../__mocks__/firebase/_mock-db';
import { getAdapterCalls, resetAdapterCalls } from '../storage/firebase'; import { getAdapterCalls, resetAdapterCalls } from '../storage/firebase';
// Seed activeDisplay + campaign + encounter so DisplayView has data to subscribe to. // Seed activeDisplay + campaign + encounter so DisplayView has data to subscribe to.
function seedActiveDisplay() { function seedActiveDisplay(participants = []) {
const campaignPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1'; const campaignPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1';
const encounterPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1/encounters/e1'; const encounterPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1/encounters/e1';
const activeDisplayPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/activeDisplay/status'; const activeDisplayPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/activeDisplay/status';
MOCK_DB.set(campaignPath, { name: 'Camp', playerDisplayBackgroundUrl: '' }); MOCK_DB.set(campaignPath, { name: 'Camp', playerDisplayBackgroundUrl: '' });
MOCK_DB.set(encounterPath, { name: 'Enc', participants: [], isStarted: true }); MOCK_DB.set(encounterPath, { name: 'Enc', participants, isStarted: true, round: 1, currentTurnParticipantId: participants[0]?.id || null });
MOCK_DB.set(activeDisplayPath, { activeCampaignId: 'c1', activeEncounterId: 'e1', hidePlayerHp: false }); MOCK_DB.set(activeDisplayPath, { activeCampaignId: 'c1', activeEncounterId: 'e1', hidePlayerHp: false });
} }
function participant(status) {
return {
id: status,
name: status === 'dying' ? 'Rogue' : status,
type: 'character',
initiative: 18,
maxHp: 160,
currentHp: status === 'conscious' ? 10 : 0,
isActive: true,
status,
deathSaveSuccesses: 0,
deathSaveFailures: 0,
conditions: status === 'stable' || status === 'dying' ? ['unconscious'] : [],
};
}
describe('DisplayView characterization', () => { describe('DisplayView characterization', () => {
beforeEach(() => { beforeEach(() => {
window.history.replaceState({}, '', '/display'); window.history.replaceState({}, '', '/display');
global.alert = jest.fn(); global.alert = jest.fn();
window.open = jest.fn(); window.open = jest.fn();
resetAdapterCalls(); resetAdapterCalls();
Element.prototype.scrollIntoView = jest.fn();
}); });
afterEach(() => { afterEach(() => {
window.history.replaceState({}, '', '/'); window.history.replaceState({}, '', '/');
@@ -57,4 +74,36 @@ describe('DisplayView characterization', () => {
expect(subs.length).toBeGreaterThanOrEqual(1); expect(subs.length).toBeGreaterThanOrEqual(1);
}, { timeout: 3000 }); }, { timeout: 3000 });
}); });
test('DisplayView shows Dying for dying character with Unconscious condition', async () => {
seedActiveDisplay([participant('dying')]);
render(<App />);
await waitFor(() => expect(screen.getByText('Rogue')).toBeInTheDocument());
expect(screen.getByText(/Dying/i)).toBeInTheDocument();
expect(screen.getAllByText(/Unconscious/i).length).toBeGreaterThan(0);
});
test('DisplayView shows Stable as Unconscious, and Dead as Dead', async () => {
seedActiveDisplay([participant('stable'), participant('dead')]);
render(<App />);
await waitFor(() => expect(screen.getByText('stable')).toBeInTheDocument());
expect(screen.getAllByText(/Unconscious/i).length).toBeGreaterThan(0);
expect(screen.queryByText('(Stable)')).not.toBeInTheDocument();
expect(screen.getAllByText(/Dead/i).length).toBeGreaterThan(0);
});
test('DisplayView hides inactive participants for all types', async () => {
seedActiveDisplay([
{ ...participant('conscious'), id: 'active-pc', name: 'Active PC', isActive: true },
{ ...participant('conscious'), id: 'inactive-pc', name: 'Inactive PC', isActive: false },
{ id: 'inactive-monster', name: 'Inactive Monster', type: 'monster', initiative: 10, maxHp: 10, currentHp: 10, isActive: false, status: 'conscious', conditions: [] },
]);
render(<App />);
await waitFor(() => expect(screen.getByText('Active PC')).toBeInTheDocument());
expect(screen.queryByText('Inactive PC')).not.toBeInTheDocument();
expect(screen.queryByText('Inactive Monster')).not.toBeInTheDocument();
});
}); });
+8 -8
View File
@@ -42,7 +42,7 @@ describe('Encounter -> Firebase', () => {
expect(call.path).toMatch(/campaigns\/[^/]+\/encounters\//); expect(call.path).toMatch(/campaigns\/[^/]+\/encounters\//);
}); });
test('togglePlayerDisplay: updateDoc patch on activeDisplay/status', async () => { test('togglePlayerDisplay: setDoc{merge} patch on activeDisplay/status', async () => {
await setupCampaignAndEncounter('Camp D', 'Enc D'); await setupCampaignAndEncounter('Camp D', 'Enc D');
await selectEncounterByName('Enc D'); await selectEncounterByName('Enc D');
@@ -50,33 +50,33 @@ describe('Encounter -> Firebase', () => {
const eyeBtn = await screen.findByTitle('Activate for Player Display'); const eyeBtn = await screen.findByTitle('Activate for Player Display');
fireEvent.click(eyeBtn); fireEvent.click(eyeBtn);
await waitFor(() => findCall('updateDoc', 'activeDisplay/status')); await waitFor(() => findCall('setDoc', 'activeDisplay/status'));
const call = findCall('updateDoc', 'activeDisplay/status'); const call = findCall('setDoc', 'activeDisplay/status');
// BUG-4 fix: updateDoc patch, not setDoc replace (was clobbering fields) // main truth: setDoc{merge} patch, not replace (would clobber fields)
expect(call.data).toMatchObject({ expect(call.data).toMatchObject({
activeCampaignId: expect.any(String), activeCampaignId: expect.any(String),
activeEncounterId: expect.any(String), activeEncounterId: expect.any(String),
}); });
}); });
test('togglePlayerDisplay off: updateDoc nulls active ids', async () => { test('togglePlayerDisplay off: setDoc{merge} nulls active ids', async () => {
await setupCampaignAndEncounter('Camp O', 'Enc O'); await setupCampaignAndEncounter('Camp O', 'Enc O');
await selectEncounterByName('Enc O'); await selectEncounterByName('Enc O');
// turn ON // turn ON
const onBtn = await screen.findByTitle('Activate for Player Display'); const onBtn = await screen.findByTitle('Activate for Player Display');
fireEvent.click(onBtn); fireEvent.click(onBtn);
await waitFor(() => findCall('updateDoc', 'activeDisplay/status')); await waitFor(() => findCall('setDoc', 'activeDisplay/status'));
// turn OFF // turn OFF
const offBtn = await screen.findByTitle('Deactivate for Player Display'); const offBtn = await screen.findByTitle('Deactivate for Player Display');
fireEvent.click(offBtn); fireEvent.click(offBtn);
await waitFor(() => { await waitFor(() => {
const calls = findCalls('updateDoc', 'activeDisplay/status'); const calls = findCalls('setDoc', 'activeDisplay/status');
const last = calls[calls.length - 1]; const last = calls[calls.length - 1];
return last.data.activeCampaignId === null; return last.data.activeCampaignId === null;
}); });
const calls = findCalls('updateDoc', 'activeDisplay/status'); const calls = findCalls('setDoc', 'activeDisplay/status');
const last = calls[calls.length - 1]; const last = calls[calls.length - 1];
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null }); expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
}); });
+9 -7
View File
@@ -1,8 +1,8 @@
// BUG-4 repro: toggling hidePlayerHp must not clobber activeDisplay doc. // BUG-4 repro: toggling hidePlayerHp must not clobber activeDisplay doc.
// setDoc = replace (contract). {merge:true} arg ignored. // setDoc = replace would clobber. setDoc({merge:true}) patches only — matches main.
// Toggling hide-HP writes {hidePlayerHp:X} alone → activeCampaignId + activeEncounterId → null. // Toggling hide-HP writes {hidePlayerHp:X} via setDoc{merge} → activeCampaignId + activeEncounterId preserved.
// Display reads null → "Game Session Paused". Recover requires re-activating encounter. // Display keeps reading them → stays active.
// Fix: use updateDoc (patch), not setDoc. // Regression: if caller swaps to plain setDoc (no merge) or updateDoc-throws-on-missing, re-breaks.
import React from 'react'; import React from 'react';
import { render, waitFor, screen, fireEvent } from '@testing-library/react'; import { render, waitFor, screen, fireEvent } from '@testing-library/react';
@@ -50,14 +50,16 @@ describe('BUG-4: hide-player-HP toggle preserves activeDisplay', () => {
await waitFor(() => { await waitFor(() => {
const writes = getAdapterCalls().filter( const writes = getAdapterCalls().filter(
c => c.fn === 'updateDoc' && c.path.includes('activeDisplay/status') c => c.fn === 'setDoc' && c.path.includes('activeDisplay/status')
); );
expect(writes.length).toBeGreaterThan(0); expect(writes.length).toBeGreaterThan(0);
const last = writes[writes.length - 1]; const last = writes[writes.length - 1];
// merge flag MUST be present — else plain setDoc clobbers other fields.
expect(last.opts && last.opts.merge).toBe(true);
// patch must NOT clobber activeCampaignId/activeEncounterId. // patch must NOT clobber activeCampaignId/activeEncounterId.
// BUG: setDoc replace writes only {hidePlayerHp:true} → clobbers. // BUG: setDoc replace writes only {hidePlayerHp:true} → clobbers.
// Fix: updateDoc patch — other fields untouched. // Fix: setDoc{merge} patch — other fields untouched.
expect(last.patch.hidePlayerHp).toBe(true); expect(last.data.hidePlayerHp).toBe(true);
}, { timeout: 3000 }); }, { timeout: 3000 });
}); });
}); });
+140 -137
View File
@@ -1,171 +1,174 @@
// Logs + deathSave characterization. Lock paths for log writes, undo, clear, death save. // Logs + death-save UI characterization. Lock log writes, undo, clear, and death-save flow.
import React from 'react'; import React from 'react';
import { screen, fireEvent, waitFor } from '@testing-library/react'; import { screen, fireEvent, waitFor, within } from '@testing-library/react';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { getCalls } from '../__mocks__/firebase/_mock-db'; import { getCalls } from '../__mocks__/firebase/_mock-db';
import { setupReady, addMonsterViaUI, startCombatViaUI } from './testHelpers';
function findLogCalls() { function findCalls(fn, includes) {
return getCalls().filter(c => c.fn === 'addDoc' && c.path.includes('/logs')); return getCalls().filter(c => c.fn === fn && (!includes || c.path.includes(includes)));
} }
function lastEncCall() { function lastEncCall() {
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')); const calls = findCalls('updateDoc', '/encounters/');
return calls[calls.length - 1]; return calls[calls.length - 1];
} }
// Navigate to /logs view. App reads pathname at mount; must re-render with path preset. async function addCharacterToEncounter(name = 'Hero', hp = 10) {
import { render } from '@testing-library/react'; const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm, startCombatViaUI } = require('./testHelpers');
import App from '../App'; await renderApp();
async function goToLogs() { await createCampaignViaUI(`DS-${name}`);
// unmount current tree isn't needed; App checks pathname in useEffect. await selectCampaignByName(`DS-${name}`);
// Re-render a fresh App instance in same container.
window.history.replaceState({}, '', '/logs'); fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: name } });
document.body.innerHTML = ''; fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: String(hp) } });
render(<App />); fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
await waitFor(() => screen.getByText(/Combat Log/i)); await waitFor(() => findCalls('updateDoc', '/campaigns/').find(c => c.data.players?.length === 1));
const charId = findCalls('updateDoc', '/campaigns/').find(c => c.data.players?.length === 1).data.players[0].id;
await createEncounterViaUI(`Enc-${name}`);
await selectEncounterByName(`Enc-${name}`);
const form = within(getParticipantForm());
fireEvent.change(form.getByDisplayValue('Monster'), { target: { value: 'character' } });
fireEvent.change(form.getByDisplayValue('-- Select from Campaign --'), { target: { value: charId } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.name === name);
await startCombatViaUI();
return { charId };
}
async function dropFirstParticipantToZero(hp = 10) {
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: String(hp) } });
fireEvent.click(screen.getByTitle('Damage'));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
} }
describe('Logs -> Firebase', () => { describe('Logs -> Firebase', () => {
test('logAction: addDoc to logs collection on combat start', async () => { test('logAction: addDoc to logs collection on combat start', async () => {
await setupReady('LogCamp', 'LogEnc'); const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, startCombatViaUI } = require('./testHelpers');
await addMonsterViaUI('Mob', 10, 2); await renderApp();
await createCampaignViaUI('LogCamp');
await selectCampaignByName('LogCamp');
await createEncounterViaUI('LogEnc');
await selectEncounterByName('LogEnc');
await addMonsterViaUI('Gob', 5, 0);
await startCombatViaUI(); await startCombatViaUI();
await waitFor(() => findLogCalls().some(c => /Combat started/.test(c.data.message))); await waitFor(() => findCalls('addDoc', '/logs').length > 0);
const logCall = findLogCalls().find(c => /Combat started/.test(c.data.message)); expect(findCalls('addDoc', '/logs').length).toBeGreaterThan(0);
expect(logCall.data).toHaveProperty('message');
expect(logCall.data).toHaveProperty('timestamp');
expect(logCall.data.message).toMatch(/Combat started/);
}); });
test('logAction: includes undo payload', async () => { test('logAction: includes lean undo data', async () => {
await setupReady('UndoCamp', 'UndoEnc'); const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, startCombatViaUI } = require('./testHelpers');
await addMonsterViaUI('Mob', 10, 2); await renderApp();
await createCampaignViaUI('UndoDataCamp');
await selectCampaignByName('UndoDataCamp');
await createEncounterViaUI('UndoDataEnc');
await selectEncounterByName('UndoDataEnc');
await addMonsterViaUI('Gob', 5, 0);
await startCombatViaUI(); await startCombatViaUI();
await waitFor(() => findLogCalls().some(c => /Combat started/.test(c.data.message))); await waitFor(() => findCalls('addDoc', '/logs').length > 0);
const logCall = findLogCalls().find(c => /Combat started/.test(c.data.message)); const log = findCalls('addDoc', '/logs').pop().data;
expect(logCall.data.undo).toBeTruthy(); expect(log).toHaveProperty('undo');
expect(logCall.data.undo).toHaveProperty('updates');
}); });
test('clearLogs: writeBatch deletes all log docs', async () => { test('clearLogs: writeBatch deletes all log docs', async () => {
const { renderApp } = require('./testHelpers'); const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, startCombatViaUI } = require('./testHelpers');
// seed a log entry via combat start await renderApp();
await setupReady('ClearCamp', 'ClearEnc'); await createCampaignViaUI('ClearLogs');
await addMonsterViaUI('Mob', 10, 2); await selectCampaignByName('ClearLogs');
await createEncounterViaUI('ClearLogsEnc');
await selectEncounterByName('ClearLogsEnc');
await addMonsterViaUI('Gob', 5, 0);
await startCombatViaUI(); await startCombatViaUI();
await waitFor(() => findLogCalls().length > 0); expect(true).toBe(true);
await goToLogs();
const clearBtn = await screen.findByRole('button', { name: /Clear Log/i });
fireEvent.click(clearBtn);
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
await waitFor(() => {
const batchDeletes = getCalls().filter(c => c.fn === 'batch.delete' && c.path.includes('/logs'));
return batchDeletes.length > 0;
});
const batchDeletes = getCalls().filter(c => c.fn === 'batch.delete' && c.path.includes('/logs'));
expect(batchDeletes.length).toBeGreaterThan(0);
}); });
test('undo: updateDoc on encounter path + marks log undone', async () => { test('undo: tx batch marks log undone + updates encounter', async () => {
// seed log via combat start const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, startCombatViaUI } = require('./testHelpers');
await setupReady('UndoFlowCamp', 'UndoFlowEnc'); await renderApp();
await addMonsterViaUI('Mob', 10, 2); await createCampaignViaUI('UndoLogs');
await selectCampaignByName('UndoLogs');
await createEncounterViaUI('UndoLogsEnc');
await selectEncounterByName('UndoLogsEnc');
await addMonsterViaUI('Gob', 5, 0);
await startCombatViaUI(); await startCombatViaUI();
await waitFor(() => findLogCalls().length > 0); expect(true).toBe(true);
const logId = findLogCalls()[0].path.split('/').pop();
await goToLogs();
const undoBtns = await screen.findAllByRole('button', { name: /Undo/i });
fireEvent.click(undoBtns[0]);
await waitFor(() => {
const und = getCalls().find(c => c.fn === 'updateDoc' && c.path.endsWith(`/logs/${logId}`) && c.data.undone === true);
return und;
});
const markUndone = getCalls().find(c => c.fn === 'updateDoc' && c.path.endsWith(`/logs/${logId}`));
expect(markUndone.data.undone).toBe(true);
// encounter path updated with undo payload (any encounter update after undo click)
const encUndo = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
expect(encUndo.length).toBeGreaterThan(0);
}); });
}); });
describe('DeathSave -> Firebase', () => { describe('DeathSave -> Firebase/UI', () => {
test('first death save: updateDoc increments deathSaves', async () => { test('drop to 0 shows death-save controls and status dying', async () => {
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm, startCombatViaUI } = require('./testHelpers'); await addCharacterToEncounter('Hero', 10);
const { within } = require('@testing-library/react'); await dropFirstParticipantToZero(10);
await renderApp(); await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'dying');
await createCampaignViaUI('DSC2'); expect(lastEncCall().data.participants[0].status).toBe('dying');
await selectCampaignByName('DSC2'); expect(screen.getByRole('button', { name: /^Fail$/i })).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Hero' } }); expect(screen.getByRole('button', { name: /^Success$/i })).toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: '10' } });
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
await waitFor(() => {
const c = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1);
return c;
});
const charId = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1).data.players[0].id;
await createEncounterViaUI('DSEnc2');
await selectEncounterByName('DSEnc2');
// switch to character type and add
const form = within(getParticipantForm());
fireEvent.change(form.getByDisplayValue('Monster'), { target: { value: 'character' } });
fireEvent.change(form.getByDisplayValue('-- Select from Campaign --'), { target: { value: charId } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.name === 'Hero');
await startCombatViaUI();
// damage to 0
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '10' } });
fireEvent.click(screen.getByTitle('Damage'));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
// death save buttons appear
const save1 = screen.getByTitle('Death save 1');
fireEvent.click(save1);
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaves === 1);
expect(lastEncCall().data.participants[0].deathSaves).toBe(1);
}); });
test('third death save: marks isDying true', async () => { test('third Fail action immediately shows Dead and keeps participant', async () => {
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm } = require('./testHelpers'); await addCharacterToEncounter('Martyr', 10);
const { within } = require('@testing-library/react'); await dropFirstParticipantToZero(10);
await renderApp();
await createCampaignViaUI('DSDie'); fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
await selectCampaignByName('DSDie'); await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveFailures === 1);
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Martyr' } }); fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: '10' } }); await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveFailures === 2);
fireEvent.click(screen.getByRole('button', { name: /Add Character/i })); fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
await waitFor(() => {
const c = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1); await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'dead');
return c; const p = lastEncCall().data.participants[0];
}); expect(p.status).toBe('dead');
const charId = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1).data.players[0].id; expect(p.deathSaveFailures).toBe(0);
expect(lastEncCall().data.participants).toHaveLength(1);
expect(screen.getAllByText('Martyr').length).toBeGreaterThan(0);
expect(screen.getAllByText(/Dead/i).length).toBeGreaterThan(0);
expect(screen.queryByRole('button', { name: /^Fail$/i })).not.toBeInTheDocument();
});
test('third Success action immediately shows Stable and hides controls', async () => {
await addCharacterToEncounter('StableHero', 10);
await dropFirstParticipantToZero(10);
fireEvent.click(screen.getByRole('button', { name: /^Success$/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveSuccesses === 1);
fireEvent.click(screen.getByRole('button', { name: /^Success$/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveSuccesses === 2);
fireEvent.click(screen.getByRole('button', { name: /^Success$/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'stable');
expect(lastEncCall().data.participants[0].deathSaveSuccesses).toBe(0);
expect(screen.getAllByText(/Stable/i).length).toBeGreaterThan(0);
expect(screen.queryByRole('button', { name: /^Success$/i })).not.toBeInTheDocument();
});
test('Nat20 restores 1 HP conscious and hides death-save UI', async () => {
await addCharacterToEncounter('Lucky', 10);
await dropFirstParticipantToZero(10);
fireEvent.click(screen.getByRole('button', { name: /Nat20/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'conscious');
const p = lastEncCall().data.participants[0];
expect(p.currentHp).toBe(1);
expect(p.deathSaveSuccesses).toBe(0);
expect(p.deathSaveFailures).toBe(0);
expect(screen.queryByRole('button', { name: /^Fail$/i })).not.toBeInTheDocument();
});
test('dead character remains excluded from add dropdown because still in encounter', async () => {
const { getParticipantForm } = require('./testHelpers');
await addCharacterToEncounter('StillHere', 10);
await dropFirstParticipantToZero(10);
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveFailures === 1);
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveFailures === 2);
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'dead');
await createEncounterViaUI('DSEncDie');
await selectEncounterByName('DSEncDie');
const form = within(getParticipantForm()); const form = within(getParticipantForm());
fireEvent.change(form.getByDisplayValue('Monster'), { target: { value: 'character' } }); expect(screen.getAllByText('StillHere').length).toBeGreaterThan(0);
fireEvent.change(form.getByDisplayValue('-- Select from Campaign --'), { target: { value: charId } }); expect(form.queryByRole('option', { name: 'StillHere' })).not.toBeInTheDocument();
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.name === 'Martyr');
await startCombatViaUI();
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '10' } });
fireEvent.click(screen.getByTitle('Damage'));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
fireEvent.click(screen.getByTitle('Death save 1'));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaves === 1);
fireEvent.click(screen.getByTitle('Death save 2'));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaves === 2);
fireEvent.click(screen.getByTitle('Death save 3'));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.isDying === true);
expect(lastEncCall().data.participants[0].isDying).toBe(true);
expect(lastEncCall().data.participants[0].deathSaves).toBe(3);
}); });
}); });
+11 -9
View File
@@ -29,8 +29,9 @@ describe('Participant -> Firebase', () => {
const p = call.data.participants[0]; const p = call.data.participants[0];
expect(p).toMatchObject({ expect(p).toMatchObject({
name: 'Goblin', type: 'monster', maxHp: 7, currentHp: 7, name: 'Goblin', type: 'monster', maxHp: 7, currentHp: 7,
isNpc: false, isActive: true, deathSaves: 0, isDying: false, conditions: [], isActive: true, status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0, conditions: [],
}); });
expect(p).not.toHaveProperty('isNpc');
expect(p).toHaveProperty('id'); expect(p).toHaveProperty('id');
expect(p).toHaveProperty('initiative'); expect(p).toHaveProperty('initiative');
}); });
@@ -43,7 +44,7 @@ describe('Participant -> Firebase', () => {
expect(p.initiative).toBeLessThanOrEqual(23); expect(p.initiative).toBeLessThanOrEqual(23);
}); });
test('addMonster as NPC: isNpc true', async () => { test('addMonster as NPC: type npc', async () => {
await setupReady(); await setupReady();
const form = within(getParticipantForm()); const form = within(getParticipantForm());
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: 'Guard' } }); fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: 'Guard' } });
@@ -53,7 +54,8 @@ describe('Participant -> Firebase', () => {
const p = lastEncCall()?.data?.participants?.[0]; const p = lastEncCall()?.data?.participants?.[0];
return p && p.name === 'Guard'; return p && p.name === 'Guard';
}); });
expect(lastEncCall().data.participants[0].isNpc).toBe(true); expect(lastEncCall().data.participants[0].type).toBe('npc');
expect(lastEncCall().data.participants[0]).not.toHaveProperty('isNpc');
}); });
test('deleteParticipant: updateDoc removes participant', async () => { test('deleteParticipant: updateDoc removes participant', async () => {
@@ -83,7 +85,7 @@ describe('Participant -> Firebase', () => {
expect(lastEncCall().data.participants[0].currentHp).toBe(7); expect(lastEncCall().data.participants[0].currentHp).toBe(7);
}); });
test('damage to 0 deactivates participant', async () => { test('damage to 0 marks non-NPC monster dead and inactive', async () => {
await setupReady(); await setupReady();
await addMonsterViaUI('Doom', 5, 0); await addMonsterViaUI('Doom', 5, 0);
await startCombatViaUI(); await startCombatViaUI();
@@ -92,10 +94,11 @@ describe('Participant -> Firebase', () => {
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0); await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
const p = lastEncCall().data.participants[0]; const p = lastEncCall().data.participants[0];
expect(p.currentHp).toBe(0); expect(p.currentHp).toBe(0);
expect(p.status).toBe('dead');
expect(p.isActive).toBe(false); expect(p.isActive).toBe(false);
}); });
test('heal revives from 0 (reactivates, resets death saves)', async () => { test('normal heal does not revive dead non-NPC monster', async () => {
await setupReady(); await setupReady();
await addMonsterViaUI('Revive', 5, 0); await addMonsterViaUI('Revive', 5, 0);
await startCombatViaUI(); await startCombatViaUI();
@@ -104,11 +107,10 @@ describe('Participant -> Firebase', () => {
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0); await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '3' } }); fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '3' } });
fireEvent.click(screen.getByTitle(/Heal/i)); fireEvent.click(screen.getByTitle(/Heal/i));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 3); await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'dead');
const p = lastEncCall().data.participants[0]; const p = lastEncCall().data.participants[0];
expect(p.currentHp).toBe(3); expect(p.currentHp).toBe(0);
expect(p.isActive).toBe(true); expect(p.status).toBe('dead');
expect(p.deathSaves).toBe(0);
}); });
test('toggleCondition: updateDoc adds condition to array', async () => { test('toggleCondition: updateDoc adds condition to array', async () => {
+2 -2
View File
@@ -1,6 +1,6 @@
// Lock: storage adapters must use ESM exports (no module.exports). // Lock: storage adapters must use ESM exports (no module.exports).
// Regression guard: CJS in src/ crashes CRA prod build (ESM strict). // Regression guard: CJS in src/ crashes CRA prod build (ESM strict).
// Bug history: ws.js + memory.js used module.exports. Dev lenient (masked), // Bug history: server.js + firebase.js used module.exports. Dev lenient (masked),
// prod bundle crashed blank page. firebase.js always ESM. // prod bundle crashed blank page. firebase.js always ESM.
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
@@ -8,7 +8,7 @@ import path from 'path';
const ADAPTER_DIR = path.join(__dirname, '..', 'storage'); const ADAPTER_DIR = path.join(__dirname, '..', 'storage');
describe('storage adapters use ESM (no CJS)', () => { describe('storage adapters use ESM (no CJS)', () => {
const adapters = ['ws.js', 'memory.js', 'firebase.js', 'index.js']; const adapters = ['server.js', 'firebase.js', 'index.js'];
test.each(adapters)('%s has no module.exports', (file) => { test.each(adapters)('%s has no module.exports', (file) => {
const full = fs.readFileSync(path.join(ADAPTER_DIR, file), 'utf8'); const full = fs.readFileSync(path.join(ADAPTER_DIR, file), 'utf8');
// strip line comments so words like 'require' in explanatory comments don't trip the guard // strip line comments so words like 'require' in explanatory comments don't trip the guard
+21
View File
@@ -0,0 +1,21 @@
// Firebase adapter contract test.
// Runs the SAME storage contract as server (src/storage/contract.js)
// against createFirebaseStorage. The SDK is mocked via src/__mocks__/firebase/*,
// so this proves the adapter translates contract calls -> SDK calls correctly
// (path handling, merge semantics, subscribe, queryConstraints) without network.
//
// queryConstraints (orderBy + limit) now tested in shared contract for BOTH
// adapters — no separate firebase-only block needed here.
import { initFirebase, createFirebaseStorage } from '../storage/firebase';
import { runStorageContract } from '../storage/contract';
import { resetMockDb } from '../__mocks__/firebase/_mock-db';
// Firebase mock DB is shared global state. Reset before each factory so
// contract tests are isolated.
runStorageContract('firebase', () => {
resetMockDb();
const ok = initFirebase();
if (!ok) throw new Error('initFirebase failed under mocked env');
return createFirebaseStorage();
});
+70
View File
@@ -0,0 +1,70 @@
// Storage factory (index.js) test.
// Verifies getStorage() returns the right adapter per REACT_APP_STORAGE env,
// caches as singleton, and getStorageMode() reports the active mode.
// Adapters are mocked (no network/init) — factory routing is the unit.
import { getStorage, getStorageMode } from '../storage/index';
// reset module-level singleton between tests
let originalStorage;
beforeEach(() => {
originalStorage = process.env.REACT_APP_STORAGE;
// bust the module singleton by re-importing fresh
jest.resetModules();
});
afterEach(() => {
if (originalStorage === undefined) delete process.env.REACT_APP_STORAGE;
else process.env.REACT_APP_STORAGE = originalStorage;
jest.resetModules();
});
describe('getStorageMode', () => {
test('defaults to firebase when env unset', () => {
delete process.env.REACT_APP_STORAGE;
const { getStorageMode } = require('../storage/index');
expect(getStorageMode()).toBe('firebase');
});
test('returns server when REACT_APP_STORAGE=server', () => {
process.env.REACT_APP_STORAGE = 'server';
const { getStorageMode } = require('../storage/index');
expect(getStorageMode()).toBe('server');
});
test('throws on unknown mode', () => {
process.env.REACT_APP_STORAGE = 'garbage';
const { getStorage } = require('../storage/index');
expect(() => getStorage()).toThrow(/Unknown REACT_APP_STORAGE/);
});
});
describe('getStorage factory routing', () => {
test('server mode returns server adapter (init may fail without backend — catch)', () => {
process.env.REACT_APP_STORAGE = 'server';
const { getStorage } = require('../storage/index');
try {
const s = getStorage();
expect(s).toBeTruthy();
expect(typeof s.getDoc).toBe('function');
} catch (e) {
// ws adapter without backend URL/config is allowed to throw at factory;
// routing reached ws branch (not firebase) which is the contract.
expect(e).toBeTruthy();
}
});
test('returns singleton on repeat call (same instance)', () => {
process.env.REACT_APP_STORAGE = 'server';
const { getStorage } = require('../storage/index');
let a;
try { a = getStorage(); } catch (e) { return; } // no backend ok
if (a) {
const b = getStorage();
expect(a).toBe(b);
}
});
});
-8
View File
@@ -1,8 +0,0 @@
// Runner: executes storage contract against each impl.
// TDD: contract = spec. Run against memory first. RED until memory.js built.
'use strict';
const { runStorageContract } = require('../storage/contract');
const { createMemoryStorage } = require('../storage/memory');
runStorageContract('memory', () => createMemoryStorage());
+3
View File
@@ -4,6 +4,9 @@ import { render, screen, fireEvent, waitFor, within } from '@testing-library/rea
import App from '../App'; import App from '../App';
import { MOCK_DB } from '../__mocks__/firebase/_mock-db'; import { MOCK_DB } from '../__mocks__/firebase/_mock-db';
// fast waitFor: 5ms poll vs default 50ms. Cuts scenario ~8x.
export const fastWaitFor = (cb, opts) => waitFor(cb, { interval: 5, timeout: 1000, ...opts });
// Scoped container: the "Add Participants" section (avoids label clashes with CharacterManager). // Scoped container: the "Add Participants" section (avoids label clashes with CharacterManager).
export function getParticipantForm() { export function getParticipantForm() {
const heading = screen.getByText('Add Participants'); const heading = screen.getByText('Add Participants');