Commit Graph
20 Commits
Author SHA1 Message Date
david raistrick c2bb5cca29 Generic (non-5e) ruleset mode: negative HP, down status, mark dead/revive
New campaign/encounter ruleset toggle: 5e (default, unchanged) vs generic.

Generic mode:
- No death saves (deathSave/stabilize throw)
- Negative HP allowed (no clamp at 0)
- <=0 HP = status 'down' (no pips, no unconscious auto-condition)
- Monster death = dead + inactive (same as 5e)
- markDead button: DM sets dead manually (both modes)
- revive: dead->conscious (generic), dead->stable (5e)

5e mode: zero behavior change.

shared/turn.js:
- applyHpChangeGeneric: negative HP, down status, no death-save logic
- markDead: status dead, monster auto-inactive
- reviveParticipant: ruleset-aware (generic=conscious, 5e=stable)
- deathSave/stabilize: throw in generic
- expandUndo: mark_dead case added

UI (src/App.js):
- CreateCampaignForm + CreateEncounterForm: ruleset radio toggle
- handleCreateCampaign/handleCreateEncounter: store ruleset field
- EncounterManager: fetch campaignDoc for default ruleset inheritance
- DM participant: down/dead labels, Mark Dead button (generic), Revive both
- death-save pips/buttons gated 5e-only
- DisplayView: down label, generic status derivation

Tests: 15 generic cases (turn.generic.test.js). 186 shared total.
2026-07-07 13:36:16 -04:00
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 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 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 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 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 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 3ea67019d2 refactor: App.js 3 sites to shared 1-list contract
delete/toggle/hp sites used OLD computeTurnOrderAfterRemoval/Addition
contract (return turnOrderIds). New 1-list contract: helpers return
advance-only + insertAt; list sync via syncTurnOrder at call site.

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

shared exports syncTurnOrder. Build green.
2026-07-01 16:05:18 -04:00
david raistrick 5d3a0607ef refactor: 1-list turn order model (turnOrderIds === participants.map(id))
Single source of truth. No re-sort after startEncounter. Drag overrides
initiative (cross-init drag allowed, DM choice). Display === rotation by
construction — same array.

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

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

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

FE App.js still has duplicate turn funcs + sortParticipantsByInitiative
display render (step 4: delete dups, render participants[] directly).
2026-07-01 16:00:00 -04:00
david raistrick 494327ff17 fix(BUG-5): unify turn-advance core (DRY), 500 rounds skip-free
Extract shared nextActiveAfter() advance core. Both nextTurn and
computeTurnOrderAfterRemoval delegate to it — single source of truth,
eliminates drift risk where one path changes and the other doesn't.

Previously two separate advance implementations computed the same
target, but any future edit to one would silently desync deact-current
advance from normal nextTurn advance.

Replay (scripts/replay-combat.js):
- Move turn-line print before mutations (event order = reality)
- Emit [pointer X→Y] lines when a mutation advances currentTurnParticipantId
- Emit [pointer X→Y wrap] when round bumps (removal-wrap case)
- Skip pointer emission for nextTurn (label=null) — already logged via turn line

Parser (scripts/analyze-turns.js):
- Parse [pointer X→Y wrap] events
- Credit pointer-target as acted (deact-current advance = turn pointer)
- Wrap pointer credits NEXT round (not current) — fixes cross-round false skip
- Drop currentRemoved special-case — pointer lines make skip check precise

Tests:
- shared/tests/turn.dry.test.js: 3 tests lock deact-current advance ==
  nextTurn advance (mid-round, inactive-skipper, wrap+round-bump). RED
  catches future drift.

Results: 500-round replay now 0 real skips, 0 double-acts (was 5+3).
Shared suite: 79 green + 1 RED (BUG-6 reorder, intentional).
2026-07-01 14:22:02 -04:00
david raistrick c72b88f8bb WIP turn.js: computeTurnOrderAfterRemoval bumps round on wrap
Removal of current participant when no active after → advance to order[0]
+ bump round. Without bump, nextTurn replays whole round (BUG-5 pattern).

Parser 500-40b: 24 skips/1 double (was 46/64). Down not zero. Remaining
skips = replay async stale read (getDoc between turns), not turn.js.
2026-07-01 12:00:23 -04:00
david raistrick 0473eacc1d WIP: BUG-5 slot-array fix + FEAT-1 dead-not-skipped + skip parser
WORK IN PROGRESS — fix not complete. analyze-turns.js on 500-round
replay still finds 46 real skips + 64 double-acts.

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

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

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

TODO: FEAT-1 marked done, FEAT-2 added (upgrade app logs parseable).
2026-07-01 11:42:43 -04:00
david raistrick d35a730e12 fix(turn): BUG-2 addParticipant rejects duplicate id
Root cause: addParticipant appended participant to participants[] without
checking id uniqueness. Two participants with same id in array. On
togglePause resume, turnOrderIds rebuilt via sort → dup id appears twice.
nextTurn then stuck repeating that id (rotation breaks).

This was the enabling step for BUG-1's full corruption (audit chain):
  pause blocks advance → totalTurns frozen → addParticipant re-adds
  same r${totalTurns} id → resume dup → nextTurn stuck.

Fix: throw on duplicate id in addParticipant. Caller must use fresh id
(crypto.randomUUID in App, replay already does).

Evidence:
- Test: 'addParticipant rejects duplicate id' (was test.skip, now live).
- Pre-fix: 1 RED (Received function did not throw).
- Post-fix: 50 green (shared), 23 green (server), 62 green (FE).
- Reachability in normal app: low (App uses crypto.randomUUID) but no
  guard existed before. Defensive + unblocks BUG-1 isolation.

No other behavior changed.
2026-06-29 16:25:39 -04:00
david raistrick e06adaa081 M1: shared turn logic + characterization tests (39 green)
- npm workspaces: shared/, server/
- shared/turn.js: port turn logic verbatim from App.js (bugs preserved)
- 39 characterization tests lock current behavior
- gitignore: sqlite data, logs
2026-06-28 16:57:43 -04:00