diff --git a/DEATH_SAVES_PLAN.md b/DEATH_SAVES_PLAN.md new file mode 100644 index 0000000..1b39cca --- /dev/null +++ b/DEATH_SAVES_PLAN.md @@ -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 \ No newline at end of file diff --git a/TODO.md b/TODO.md index c6b0a89..ea91014 100644 --- a/TODO.md +++ b/TODO.md @@ -4,6 +4,75 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md. ## Open + + +### dm list - keep active particpant in view (scroll) +not sure good way to do this + + + +### 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 ` 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 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. @@ -28,6 +97,9 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md. - 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 @@ -76,12 +148,13 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md. case-insensitive. maxLength 40. Combat + replay tests prove arbitrary string ids survive round-trip. -### Death saves: D&D 5e model (DONE) -- Separate success/fail tracking. deathSave(enc, id, type, n), - type='success'|'fail'. Fields: deathSaves, deathFails, isStable, isDying. - 3 success=stable, 3 fail=dead. Returns {patch, log, status, isDying}. - Old single-counter broken (successes missing). Old data lost (feature - never worked in prod). UI: green ✓ + red ✕ rows, Stabilized/Dying badges. +### Death saves: D&D 5e status model (DONE) +- `status` is source of truth: conscious, dying, stable, dead. +- Characters and NPCs use death saves; monsters skip death saves and become dead/inactive at 0 HP. +- Death-save actions: Success, Fail, Nat1, Nat20, Stabilize. +- Revive: dead → 0 HP, stable/unconscious, active. +- Dead characters/NPCs stay in encounter/initiative until DM removes or marks inactive. +- Player display hides inactive participants; DM display keeps them visible. ### FEAT-3: initiative first-class entry (DONE) - Initiative field at add-char, add-monster, edit participant. @@ -155,9 +228,9 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md. ### BUG-18: stale comments reference deleted memory adapter - FIXED. -### FEAT-1: Dead participants stay in turn order -- DONE --- applyHpChange no longer flips isActive on death. Dead stay in - rotation, nextTurn visits them, PCs get death-save turn. +### 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. diff --git a/docs/DEATH_SAVES.md b/docs/DEATH_SAVES.md new file mode 100644 index 0000000..8fa7030 --- /dev/null +++ b/docs/DEATH_SAVES.md @@ -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) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 675c5c6..b26deef 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -174,7 +174,7 @@ Runs pure turn.js combat, audits 9 invariant classes per round: 1. rotation integrity (skip/dupe) 2. HP bounds (0 ≤ hp ≤ max, no NaN) -3. isActive consistency (dead = inactive) +3. isActive consistency (inactive skipped; monster death auto-inactive; character/NPC death DM-controlled) 4. turnOrder no dup ids 5. turnOrder ids all active 6. currentTurn valid + active diff --git a/docs/ENCOUNTER_BUILDER.md b/docs/ENCOUNTER_BUILDER.md index 6eb5014..701e228 100644 --- a/docs/ENCOUNTER_BUILDER.md +++ b/docs/ENCOUNTER_BUILDER.md @@ -53,16 +53,16 @@ Object in `encounter.participants[]`: |---|---|---| | `id` | string | `generateId()` | | `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 | | `initiative` | int | rolled once at add (`rollD20() + mod`). Stored value, not re-derived. | | `maxHp` | int | | -| `currentHp` | int | 0 = dead/dying | -| `isNpc` | bool | monster flagged NPC (display color, no death saves) | +| `currentHp` | int | 0 = dead/dying/stable by `status` | | `conditions` | array | condition ids from `CONDITIONS` list | | `isActive` | bool | in turn rotation? false = skipped by nextTurn | -| `deathSaves` | int | PC only, 0-3 fails | -| `isDying` | bool | death animation flag (player display) | +| `status` | `'conscious'` \| `'dying'` \| `'stable'` \| `'dead'` | death-save source of truth | +| `deathSaveSuccesses` | int | character/NPC only, 0-3 successes | +| `deathSaveFailures` | int | character/NPC only, 0-3 failures | ## Build flow (UI) @@ -99,7 +99,7 @@ ParticipantManager section. Two paths: - **Monster Name** (`placeholder: "e.g., Dire Wolf"`) - **Init Mod** (`MONSTER_DEFAULT_INIT_MOD` = 2) - **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** - Initiative auto-rolled: `rollD20() + mod` @@ -113,13 +113,13 @@ ParticipantManager section. Two paths: Participant object added: ```js { 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) 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) @@ -135,8 +135,9 @@ InitiativeControls panel (sticky, right side). ### Next Turn - **Next Turn** button (disabled if paused) - Advances to next active participant in `turnOrderIds` -- Wraps at end → `round += 1`, re-sorts active by initiative at round start -- Dead (`isActive:false`) skipped, stay in rotation +- Wraps at end → `round += 1` +- No re-sort after start; 1-list order remains source of truth +- Inactive (`isActive:false`) participants are skipped, but stay in slot ### Pause / Resume - **Pause Combat** → `isPaused=true`, Next Turn disabled @@ -147,11 +148,19 @@ InitiativeControls panel (sticky, right side). Per-participant input + buttons: - Number input - **Damage** (HeartCrack icon) — `currentHp = max(0, hp - amt)` -- **Heal** (Heart icon) — `currentHp = min(maxHp, hp + amt)` -- Death: hp→0 sets `isActive:false`, PC gets `deathSaves` tracking +- **Heal** (Heart icon) — `currentHp = min(maxHp, hp + amt)` for living/dying/stable participants; normal heal does not affect dead participants +- Character/NPC at 0 HP → `status:'dying'`, death-save controls, unconscious condition +- Monster at 0 HP → `status:'dead'`, `isActive:false` +- Massive damage (`damage >= currentHp + maxHp`) → dead -### Death saves (PC only, at 0 HP) -3 buttons. Click marks fail. 3 fails = dead. Reset on revive/heal. +### Death saves (character/NPC only, at 0 HP) +Action buttons: Success, Fail, Nat1, Nat20, Stabilize. +- Success: +1 success; 3 successes → stable/unconscious at 0 HP +- Fail: +1 failure; 3 failures → dead +- Nat1: +2 failures +- Nat20: 1 HP, conscious, counters reset +- Stabilize: stable/unconscious at 0 HP, counters reset +- Revive: dead → 0 HP, stable/unconscious, active ### Conditions - Click participant → expand conditions picker (all 22 from `CONDITIONS`) @@ -170,7 +179,7 @@ What it shows: - Round + current turn participant - All participants in `participants[]` order (drag order, NOT init-sorted — BUG-15 fix) - 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). @@ -181,11 +190,11 @@ Key architecture. `turnOrderIds === participants.map(p => p.id)` always. Single - **Display** = `participants[]` order (AdminView + DisplayView, no re-sort) - **Turn rotation** = `turnOrderIds` (mirrors participants[]) - **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 - **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 @@ -202,7 +211,8 @@ logs/{logId} action log entry - Initiative rolled ONCE at add time. Stored. Edit via EditParticipantModal to override. - Pause before big roster changes (adds/removes). Resume re-syncs cleanly. - Campaign chars = templates. Edit campaign char doesn't touch encounter participants (already added). -- Dead monsters 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. See `docs/GLOSSARY.md` for domain terms, `TODO.md` for known bugs. diff --git a/docs/INITIATIVE_ORDERING.md b/docs/INITIATIVE_ORDERING.md index 77b65d1..4f93214 100644 --- a/docs/INITIATIVE_ORDERING.md +++ b/docs/INITIATIVE_ORDERING.md @@ -28,7 +28,7 @@ One list: `participants[]`. | **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. Skip in rotation only. | +| **Toggle active** | No position change. Does **not** advance current turn. Skip on next explicit turn advance only. | ## Slotting Rules @@ -44,6 +44,20 @@ One list: `participants[]`. - 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. diff --git a/docs/LOG_REDESIGN_PLAN.md b/docs/LOG_REDESIGN_PLAN.md new file mode 100644 index 0000000..f6a0a17 --- /dev/null +++ b/docs/LOG_REDESIGN_PLAN.md @@ -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: // 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 diff --git a/docs/TESTING.md b/docs/TESTING.md index 65a76da..df5ca2d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -118,7 +118,7 @@ node tests/audit/audit-rotation.js Runs pure turn.js combat. Audits 9 invariant classes per round: 1. rotation integrity (skip/dupe) 2. HP bounds (0 ≤ hp ≤ max, no NaN) -3. isActive consistency (dead = inactive) +3. isActive consistency (inactive skipped; monster death auto-inactive; character/NPC death DM-controlled) 4. turnOrder no dup ids 5. turnOrder ids all active 6. currentTurn valid + active diff --git a/package.json b/package.json index 865e033..59d077b 100644 --- a/package.json +++ b/package.json @@ -21,13 +21,15 @@ }, "scripts": { "start": "react-scripts start", - "build": "react-scripts build", + "build": "CI=true react-scripts build", "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", "server:dev": "npm run dev --workspace server", "server:test": "npm test --workspace server", "shared:test": "npm test --workspace shared", - "test:all": "CI=true react-scripts test --watchAll=false && npm run shared:test && npm run server:test" + "test:all": "./scripts/run-tests.sh" }, "eslintConfig": { "extends": [ diff --git a/scripts/analyze-turns.js b/scripts/analyze-turns.js index 64d233d..dcc3ec1 100644 --- a/scripts/analyze-turns.js +++ b/scripts/analyze-turns.js @@ -1,315 +1,436 @@ // scripts/analyze-turns.js -// Ingest replay-combat.js stdout (or any text matching its format), reconstruct -// rounds, report real skips + double-acts. Deterministic — no eyeballing. +// Invariant checker for combat rotation. Source-agnostic. // -// Usage: -// node scripts/analyze-turns.js [path] # analyze a saved log file -// node scripts/replay-combat.js 100 100 | node scripts/analyze-turns.js -// cat /tmp/replay.log | node scripts/analyze-turns.js +// Input (autodetect): +// .jsonl file — replay-combat trace: per-step {step,ts,type,call:{fn,args}, +// pre,post}. pre/post = backend read-back snapshots. +// .json array — downloaded log OR exported events. {ts,type,...,snapshot}. +// snapshot = what turn.js logged (lighter: no participants[]). +// .log file — replay stdout: extract trace path from 'trace written:' line. +// stdin — either jsonl or json. +// no arg = usage. User must specify path. // -// Skip = participant active for WHOLE round (never deactivated/removed mid-round -// before their slot, never added mid-round) but never appeared as a turn actor. -// Double-act = same participant takes 2+ turns in one round. +// INVARIANTS (define correctness; no prediction): +// 1. round monotonically ascends; +1 only on pointer wrap (last→first active). +// No backward, no double-increment, no skip. +// 2. pointer advances forward in turnOrderIds (mod wrap), skipping inactive. +// Never backward, never stationary except on pause/non-rotation mutations. +// 3. no double-act: in one rotation cycle each active participant becomes +// current ≤1 time. +// 4. no real skip: participant active for full cycle, never removed/deactivated, +// but never became current = skipped. +// 5. order stable across non-reorder mutations. turnOrderIds shift without +// add/remove/reorder = display divergence. +// 6. slot order (initiative desc, tie-break stable) maintained except after +// explicit reorder. Replay-trace only (needs participants[].initiative). // -// FEAT-2 (structured turn snapshot in app logs) will let this ingest live app -// logs too, not just replay stdout. Format-agnostic core lives in parseReplay(). +// Exit 0 clean, 1 issues found. 'use strict'; const fs = require('fs'); -// ---------- parsing ---------- - -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 ---------- +// ---------- input ---------- function readInput() { const arg = process.argv[2]; - if (arg) return fs.readFileSync(arg, 'utf8'); - // stdin - return fs.readFileSync(0, 'utf8'); -} -function main() { - const text = readInput(); - const lines = text.split('\n'); - const events = lines.map(parseLine).filter(Boolean); - const rounds = reconstruct(events); - 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; + // No arg + no stdin = usage. + if (!arg && process.stdin.isTTY) { + console.error('Usage: node scripts/analyze-turns.js '); + console.error(' cat events | node scripts/analyze-turns.js'); + process.exit(2); } - for (const rep of problemRounds) { - console.log(`R${rep.round}: turns=${rep.turnCount} unique=${rep.uniqueActors}`); - if (rep.realSkips.length) console.log(` REAL SKIPS: ${rep.realSkips.join(', ')}`); - if (rep.doubleActs.length) console.log(` DOUBLE-ACTS: ${rep.doubleActs.map(d => `${d.name}(${d.count}x)`).join(', ')}`); - console.log(` sequence: ${rep.turns.join(' -> ')}`); - } - - // 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 (!arg) { + const stdin = fs.readFileSync(0, 'utf8'); + if (!stdin.trim()) { + console.error('Usage: node scripts/analyze-turns.js '); + console.error(' cat events | node scripts/analyze-turns.js'); + process.exit(2); } - if (shifts.length > 10) console.log(` ... +${shifts.length - 10} more`); + return stdin; } - console.log(`\n=== ${reports.length} rounds analyzed ===`); - console.log(`real skips: ${totalSkips}`); - console.log(`double-acts: ${totalDoubles}`); - console.log(`order shifts: ${shifts.length}`); - const clean = totalSkips === 0 && totalDoubles === 0 && shifts.length === 0; - console.log(clean ? 'CLEAN — no rotation bugs' : 'ISSUES FOUND'); + // Replay stdout .log: extract trace path from `trace written:` line. + if (/\.log$/i.test(arg)) { + const logText = fs.readFileSync(arg, 'utf8'); + const m = logText.match(/trace written: \d+ steps -> (.+)$/m); + if (m) { + const tracePath = m[1].trim(); + if (fs.existsSync(tracePath)) { + console.error(`[analyze] trace: ${tracePath}`); + return fs.readFileSync(tracePath, 'utf8'); + } + console.error(`[analyze] trace path from .log not found: ${tracePath}`); + process.exit(2); + } + console.error(`[analyze] no 'trace written:' line in ${arg}`); + process.exit(2); + } - 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); diff --git a/scripts/cap.sh b/scripts/cap.sh new file mode 100755 index 0000000..f15e33f --- /dev/null +++ b/scripts/cap.sh @@ -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 [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" "$@" diff --git a/scripts/combat.js b/scripts/combat.js new file mode 100644 index 0000000..23db2b6 --- /dev/null +++ b/scripts/combat.js @@ -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 [-v] +// combat verify +// 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 [-v]'); + console.error(' --out 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 '); + 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 [-v]'); + console.error(' combat verify '); + 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); + } +} diff --git a/scripts/combat/replay.js b/scripts/combat/replay.js new file mode 100644 index 0000000..02c892a --- /dev/null +++ b/scripts/combat/replay.js @@ -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(); +}; diff --git a/scripts/combat/verify.js b/scripts/combat/verify.js new file mode 100644 index 0000000..d217268 --- /dev/null +++ b/scripts/combat/verify.js @@ -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; +}; diff --git a/scripts/dev-start.sh b/scripts/dev-start.sh index 7c14975..a4d2577 100755 --- a/scripts/dev-start.sh +++ b/scripts/dev-start.sh @@ -18,7 +18,7 @@ done # backend: better-sqlite3, :4001 if ! lsof -ti :4001 >/dev/null 2>&1; then echo "starting backend :4001..." - DB_PATH=$(pwd)/data/tracker.sqlite PORT=4001 \ + DB_PATH=$(pwd)/data/tracker.sqlite PORT=4001 ALLOW_DEV_ENDPOINTS=1 \ nohup npm run server:dev > tmp/server.log 2>&1 & echo $! > tmp/server.pid else diff --git a/scripts/replay-combat.js b/scripts/replay-combat.js index 6e14c89..d397170 100644 --- a/scripts/replay-combat.js +++ b/scripts/replay-combat.js @@ -1,379 +1,372 @@ // 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 // (subscribed via WS) live-updates as combat progresses. // Uses shared/turn.js for all turn logic (same model as the UI). // -// Coverage goals (rotate across rounds): -// - nextTurn (every turn) -// - applyHpChange damage + heal (varying magnitude) -// - toggleCondition (all CONDITIONS at least once) -// - 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) +// BLIND CALLER: this tool does NOT know the correct turn order. It hammers +// nextTurn + mutations and trusts turn.js. An SEPARATE analyze pass inspects +// what actually happened (invariants on backend read-back). Do not encode +// expected-order logic here — that's circular. // -// Run: node scripts/replay-combat.js [rounds] [delayMs] -// rounds default 100, delayMs default 200 - -'use strict'; +// DRIVEN BY REAL enc.round (backend state), not a fake loop counter. Round +// wraps when nextTurn's pointer advances last→first active. +// +// TRACE: writes JSONL to tmp/replay-{stamp}.jsonl — one record per call: +// { step, ts, type, call:{fn,args}, pre, post } +// pre/post = backend getDoc snapshots (independent of func return value). +// This is ground truth. analyze-turns.js consumes it. The func return value +// is "what turn.js claims"; read-back is "what the backend stored". Mismatch +// = write bug. +// +// Coverage (rotates by step counter): +// applyHpChange damage, toggleCondition, updateParticipant, deathSave, +// toggleParticipantActive, pause/add/resume reinforcements, +// removeParticipant dead, reorderParticipants same-init, nextTurn. +// +// Run: node scripts/replay-combat.js [rounds] [delayMs] --out [-v|--verbose] +// rounds default 20, delayMs default 200 +// --out = REQUIRED. Trace path you control. +// -v / --verbose = log every action per turn +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); const shared = require('../shared'); const { buildCharacterParticipant, buildMonsterParticipant, - startEncounter, nextTurn, togglePause, + startEncounter, nextTurn, togglePause, endEncounter, addParticipant, updateParticipant, removeParticipant, toggleParticipantActive, applyHpChange, deathSave, - toggleCondition, reorderParticipants, endEncounter, + 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 ROUNDS = parseInt(process.argv[2], 10) || 100; -const DELAY = parseInt(process.argv[3], 10) || 200; +// args: [rounds] [delayMs] --out [-v|--verbose] +// --out 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 [-v]'); + console.error(' --out REQUIRED.'); + process.exit(2); +} +const positional = args.filter(a => !a.startsWith('-')); +const ROUNDS = parseInt(positional[0], 10) || 20; +const DELAY = parseInt(positional[1], 10) || 200; +const MAX_STEPS = ROUNDS * 30; // safety cap on total calls const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default'; const PUB = `artifacts/${APP_ID}/public/data`; -// Mirror App.js getPath. Adapter takes these; norm() strips prefix. 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 sleep = (ms) => new Promise(r => setTimeout(r, ms)); - -// Use the ADAPTER as the contract boundary (same as App). No raw REST. 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)]; -// Mirror App.js CONDITIONS so we exercise all of them. -const CONDITIONS = [ - 'alchemist_fire', 'bardic_inspiration', 'blinded', 'charmed', 'deafened', - 'exhaustion', 'frightened', 'grappled', 'grazed', 'incapacitated', - 'invisible', 'paralyzed', 'petrified', 'poisoned', 'prone', 'restrained', - 'sapped', 'shield', 'slowed', 'stunned', 'unconscious', 'vexed', -]; -// Custom (freeform) condition ids — DM-added strings. toggleCondition must -// accept ANY string (UI custom-condition contract). Exercise both paths. -const CUSTOM_CONDITIONS = ['hexed', 'rager', 'marked_for_death', '🛡️blessed']; - -async function patch(encounterPath, enc, result, label) { - if (!result || !result.patch) { if (label) console.log(` (${label}: no-op)`); return enc; } - await storage.updateDoc(encounterPath, result.patch); - 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 buildRoster() { + return [ + { name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 }, + { name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 }, + { name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 }, + ]; +} +function buildMonsters() { + return [ + { name: 'Goblin1', maxHp: 30, initMod: 2 }, + { name: 'Goblin2', maxHp: 30, initMod: 2 }, + { name: 'OrcBoss', maxHp: 120, initMod: 1 }, + { name: 'Wolf', maxHp: 40, initMod: 3 }, + { name: 'Merchant', maxHp: 30, initMod: 0, isNpc: true }, + ]; } -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() { - 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 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 activeDisplayPath = getPath.activeDisplay(); + const ctx = { storage, encPath: encounterPath, logPath: getPath.logs(), displayPath: activeDisplayPath }; - // start - let enc = await storage.getDoc(encounterPath); - enc = await patch(encounterPath, enc, startEncounter(enc), 'startEncounter'); - console.log(`combat started: round ${enc.round}, first=${firstActiveName(enc)}`); - await sleep(DELAY); + let tracePath = OUT_PATH; + // ensure parent dir exists + const traceDir = path.dirname(tracePath); + if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true }); + const jsonl = fs.createWriteStream(tracePath, { flags: 'w' }); + + let stepN = 0; + let prevEnc = null; // cached post = next step's pre (no backend read-back). + // callStep: pre = cached prevEnc (or fresh read on first) → run → post = + // runner return (trusted, like old fast impl) → emit JSONL. No per-step + // backend getDoc. Funcs return newEnc already. + async function callStep(fn, args, runner) { + stepN++; + const encBefore = prevEnc !== null ? prevEnc : await storage.getDoc(encounterPath); + const pre = snapshot(encBefore); + let result, threw = null; + try { result = await runner(encBefore); } + catch (e) { threw = e.message; } + // func returns newEnc (post). Trust it — matches old fast behavior. + // Setup funcs (setDoc/getDoc) return undefined → keep encBefore. + const encAfter = threw ? encBefore : (result !== undefined ? result : encBefore); + prevEnc = encAfter; + const post = snapshot(encAfter); + jsonl.write(JSON.stringify({ + step: stepN, ts: Date.now(), type: fn, call: { fn, args }, + pre, post, error: threw, + }) + '\n'); + return { enc: encAfter, result, threw }; + } + + console.log(`replay-combat: ${ROUNDS} rounds, ${DELAY}ms/step, backend=${BACKEND}${VERBOSE ? ' [verbose]' : ''}`); + + // --- setup: campaign + encounter docs (also traced, type 'setup') --- + await callStep('setup_campaign', { campaignId }, async () => + storage.setDoc(getPath.campaign(campaignId), { + id: campaignId, name: `Replay Campaign ${runStamp}`, createdAt: Date.now(), + }) + ); + await callStep('setup_encounter', { encounterId }, async () => + storage.setDoc(encounterPath, { + id: encounterId, + name: `Big Boss Replay ${runStamp}`, + campaignId, + participants: [], + isStarted: false, isPaused: false, + round: 0, currentTurnParticipantId: null, turnOrderIds: [], + createdAt: Date.now(), + }) + ); + + // add roster + monsters + for (const ch of buildRoster()) { + const { participant } = buildCharacterParticipant(ch); + await callStep('addParticipant', { name: participant.name }, (enc) => + addParticipant(enc, participant, ctx) + ); + } + for (const m of buildMonsters()) { + const { participant } = buildMonsterParticipant(m); + await callStep('addParticipant', { name: participant.name }, (enc) => + addParticipant(enc, participant, ctx) + ); + } + + // start combat + let enc = (await storage.getDoc(encounterPath)); + const startRes = await callStep('startEncounter', {}, (e) => startEncounter(e, ctx)); + enc = startRes.enc; + await storage.updateDoc(activeDisplayPath, { activeCampaignId: campaignId, activeEncounterId: encounterId }); + console.log(`combat started: round ${enc.round}, first=${nameOf(enc, enc.currentTurnParticipantId)}`); let totalTurns = 0; - const condQueue = [...CONDITIONS, ...CUSTOM_CONDITIONS].sort(() => Math.random() - 0.5); - let reinforcementsAdded = 0; - let lastPaused = false; - let lastReorder = 0; + let lastRound = enc.round; - for (let roundN = 1; roundN <= ROUNDS; roundN++) { - console.log(`--- round ${roundN} starting ---`); - // advance initiative until round counter ticks (full cycle done). - const cap = (enc.participants.length + 2) * 2; - let guard = 0; - 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). + // --- main loop: drive by real enc.round --- + while (enc.isStarted && enc.round <= ROUNDS && stepN < MAX_STEPS) { + const actor = (enc.participants || []).find(p => p.id === enc.currentTurnParticipantId); + totalTurns++; + if (VERBOSE && actor) console.log(` [r${enc.round}] ${actor.name}'s turn`); - // 9. resume if paused: must happen BEFORE nextTurn or it throws. - if (lastPaused) { - enc = await patch(encounterPath, enc, togglePause(enc), 'resume'); - lastPaused = false; + if (actor) { + // random damage from actor to random living target + const living = enc.participants.filter(p => p.currentHp > 0 && p.id !== actor.id); + if (living.length > 0) { + const tgt = pick(living); + const dmg = 1 + Math.floor(Math.random() * 5); + if (VERBOSE) console.log(` damage ${tgt.name} -${dmg}`); + const res = await callStep('applyHpChange', + { target: tgt.name, changeType: 'damage', amount: dmg }, + (e) => applyHpChange(e, tgt.id, 'damage', dmg, ctx)); + enc = res.enc; } - let t; - try { t = nextTurn(enc); } catch (e) { console.log(` nextTurn err: ${e.message}`); break; } - enc = await patch(encounterPath, enc, t, null); - totalTurns++; - const actorName = firstActiveName(enc); - const actor = currentParticipant(enc); - - // Dump turn line with order AND initiative (DM drag may reorder without - // 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})`); - } - } + // random condition + if (totalTurns % 5 === 0) { + const cond = ALL_CONDITIONS[condIdx++ % ALL_CONDITIONS.length]; + if (VERBOSE) console.log(` condition ${actor.name} +${cond}`); + const res = await callStep('toggleCondition', + { participant: actor.name, condition: cond }, + (e) => toggleCondition(e, actor.id, cond, ctx)); + enc = res.enc; } - // 2. heal: Cleric (when active) heals lowest-HP ally every other turn. - if (actor && actor.name === 'Cleric' && totalTurns % 2 === 0) { - const wounded = enc.participants - .filter(p => p.currentHp > 0 && p.currentHp < p.maxHp && p.isActive !== false) - .sort((a, b) => (a.currentHp / a.maxHp) - (b.currentHp / b.maxHp)); - if (wounded.length > 0) { - const tgt = wounded[0]; - 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})`); - } - } + // random edit (notes/init unchanged) + if (totalTurns % 11 === 0) { + if (VERBOSE) console.log(` update ${actor.name} notes`); + const res = await callStep('updateParticipant', + { participant: actor.name, fields: ['notes'] }, + (e) => updateParticipant(e, actor.id, { notes: `edited r${enc.round}` }, ctx)); + enc = res.enc; } - // 3. conditions: toggle a queued condition off some participant each turn. - if (condQueue.length > 0) { - const cond = condQueue[0]; - const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false); - if (living.length > 0) { - const tgt = pick(living); - try { - 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, ...CUSTOM_CONDITIONS]); - try { - const c = toggleCondition(enc, tgt.id, cond); - enc = await patch(encounterPath, enc, c, `condition ${cond} on ${tgt.name}`); - } catch (e) { /* ignore */ } - } + // deathSave: PC at 0 HP + if (actor.currentHp <= 0 && !actor.isNpc) { + if (VERBOSE) console.log(` deathSave ${actor.name} +1 success`); + const res = await callStep('deathSave', + { participant: actor.name, type: 'success', n: 1 }, + (e) => deathSave(e, actor.id, 'success', 1, ctx)); + enc = res.enc; } - // 4. toggleParticipantActive: randomly mark someone inactive, or reactivate. + // toggleActive every 9 turns if (totalTurns % 9 === 0) { const living = enc.participants.filter(p => p.currentHp > 0); if (living.length > 0) { const tgt = pick(living); - try { - const r = toggleParticipantActive(enc, tgt.id); - enc = await patch(encounterPath, enc, r, `${tgt.isActive === false ? 'reactivate' : 'deactivate'} ${tgt.name}`); - } catch (e) { /* ignore */ } + if (VERBOSE) console.log(` toggleActive ${tgt.name}`); + const res = await callStep('toggleParticipantActive', + { participant: tgt.name }, + (e) => toggleParticipantActive(e, tgt.id, ctx)); + enc = res.enc; } } - // 5. deathSave: when a PC is at 0 HP on their turn, attempt a save. - if (actor && actor.currentHp <= 0 && !actor.isNpc && actor.name !== actor.name.startsWith('Monster')) { - try { - const ds = deathSave(enc, actor.id, 1); - enc = await patch(encounterPath, enc, ds, `deathSave ${actor.name} (+1 success)`); - } catch (e) { /* ignore */ } + // reinforcements every 20 turns (pause/add/resume) + if (totalTurns % 20 === 0 && enc.isPaused === false) { + if (VERBOSE) console.log(` reinforcements: pause`); + let res = await callStep('togglePause', { to: 'paused' }, (e) => togglePause(e, ctx)); + enc = res.enc; + const r = buildMonsterParticipant({ name: `Reinforce${totalTurns}`, maxHp: 20, initMod: 1 }); + if (VERBOSE) console.log(` add ${r.participant.name}`); + res = await callStep('addParticipant', + { name: r.participant.name, reinforcement: true }, + (e) => addParticipant(e, r.participant, ctx)); + enc = res.enc; + if (VERBOSE) console.log(` resume`); + res = await callStep('togglePause', { to: 'resumed' }, (e) => togglePause(e, ctx)); + enc = res.enc; } - // 6. removeParticipant: dead monsters hauled off (every ~5 turns). - if (totalTurns % 5 === 0) { - const dead = enc.participants.find(p => (p.isDying || p.currentHp <= 0) && (p.isNpc || p.name.startsWith('Goblin') || p.name === 'OrcBoss' || p.name === 'Wolf')); + // remove dead monster every 13 turns + if (totalTurns % 13 === 0) { + const dead = enc.participants.find(p => p.currentHp <= 0 && p.type === 'monster'); if (dead) { - try { - const r = removeParticipant(enc, dead.id); - enc = await patch(encounterPath, enc, r, `remove dead ${dead.name}`); - } catch (e) { /* ignore */ } + if (VERBOSE) console.log(` remove ${dead.name}`); + const res = await callStep('removeParticipant', + { participant: dead.name, dead: true }, + (e) => removeParticipant(e, dead.id, ctx)); + enc = res.enc; } } - // 7. addParticipant (reinforcements): every 10 turns a new monster joins. - if (totalTurns % 10 === 0 && reinforcementsAdded < 4) { - const spec = pick([ - { name: `Reinforce${reinforcementsAdded + 1}`, maxHp: 120, initMod: 1 }, - { name: `Summon${reinforcementsAdded + 1}`, maxHp: 80, initMod: 4 }, - ]); - try { - const built = buildMonsterParticipant(spec).participant; - const r = addParticipant(enc, built); - enc = await patch(encounterPath, enc, r, `add ${spec.name}`); - reinforcementsAdded++; - } 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 */ } + // drag same-init every 17 turns + if (totalTurns % 17 === 0) { + const order = enc.participants || []; + if (order.length >= 2) { + const a = order[0], b = order.find(p => p.initiative === a.initiative && p.id !== a.id); + if (b) { + if (VERBOSE) console.log(` reorder ${b.name} -> ${a.name}`); + const res = await callStep('reorderParticipants', + { dragged: b.name, target: a.name }, + (e) => reorderParticipants(e, b.id, a.id, ctx)); + enc = res.enc; + } } } - - // 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; } - const alive = enc.participants.filter(p => p.currentHp > 0).length; - // revive dead: heal to full + reactivate. Sustains combat for 100 rounds - // and exercises toggleActive reactivate + heal-from-zero path. - const dead = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false); - for (const d of dead) { - try { + if (VERBOSE) console.log(` -> nextTurn`); + const advRes = await callStep('nextTurn', {}, (e) => nextTurn(e, ctx)); + enc = advRes.enc; + if (advRes.threw) { console.log(` nextTurn err: ${advRes.threw}`); break; } + + // round wrap: revive dead so combat sustains to full ROUNDS count + if (enc.isStarted && enc.round > lastRound) { + if (enc.round > ROUNDS) break; // round cap reached, stop before printing/acting + console.log(`--- round ${enc.round} ---`); + lastRound = enc.round; + const dead = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false); + for (const d of dead) { if (d.isActive === false) { - 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); - enc = await patch(encounterPath, enc, h, `revive-heal ${d.name} →${d.maxHp}`); - } catch (e) { console.log(` revive ${d.name} err: ${e.message}`); } + if (VERBOSE) console.log(` heal ${d.name} +${d.maxHp}`); + const res = await callStep('applyHpChange', + { target: d.name, changeType: 'heal', amount: d.maxHp, revive: true }, + (e) => applyHpChange(e, d.id, 'heal', d.maxHp, ctx)); + enc = res.enc; + } } + + if (!enc.isStarted) { console.log('combat auto-ended'); break; } } - console.log(`replay: ${totalTurns} 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); - 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 }); + + 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'); } -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); }); diff --git a/scripts/replay-from-logs.js b/scripts/replay-from-logs.js new file mode 100644 index 0000000..5d621c2 --- /dev/null +++ b/scripts/replay-from-logs.js @@ -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 +// node scripts/replay-from-logs.js --encounter # 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 [--encounter ] [--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); }); diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh new file mode 100755 index 0000000..46c585e --- /dev/null +++ b/scripts/run-tests.sh @@ -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 ===" diff --git a/server/db.js b/server/db.js index ed9662e..989bdf5 100644 --- a/server/db.js +++ b/server/db.js @@ -27,6 +27,10 @@ CREATE TABLE IF NOT EXISTS docs ( ); 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) { @@ -52,7 +56,6 @@ function makeStore(db, broadcast) { ON CONFLICT(path) DO UPDATE SET data = @data, updated_at = @ts `); 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) { const row = stmtGet.get(p); @@ -79,8 +82,54 @@ function makeStore(db, broadcast) { if (broadcast) broadcast({ path: p, parent: parentOf(p), deleted: true }); } - function getCollection(collPath) { - return stmtColl.all(collPath).map(row => ({ id: row.path.split('/').pop(), path: row.path, ...JSON.parse(row.data) })); + function getCollection(collPath, { where, orderBy, dir = 'asc', limit, offset } = {}) { + let sql = 'SELECT path, data FROM docs WHERE parent = ?'; + const params = [collPath]; + if (where) { + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(where.field)) throw new Error(`bad where field: ${where.field}`); + if (where.op !== '==') throw new Error(`unsupported where op: ${where.op}`); + sql += ` AND json_extract(data, '$.${where.field}') = ?`; + params.push(where.value); + } + if (orderBy) { + // whitelist field name to avoid injection (alphanumeric + underscore only) + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(orderBy)) throw new Error(`bad orderBy field: ${orderBy}`); + sql += ` ORDER BY CAST(json_extract(data, '$.${orderBy}') AS NUMERIC) ${dir === 'desc' ? 'DESC' : 'ASC'}`; + } else { + sql += ' ORDER BY path ASC'; + } + if (limit && Number.isInteger(+limit) && +limit > 0) { + sql += ' LIMIT ?'; + params.push(+limit); + if (offset && Number.isInteger(+offset) && +offset > 0) { + sql += ' OFFSET ?'; + params.push(+offset); + } + } + return db.prepare(sql).all(...params).map(row => ({ id: row.path.split('/').pop(), path: row.path, ...JSON.parse(row.data) })); + } + + function countCollection(collPath) { + return db.prepare('SELECT COUNT(*) AS n FROM docs WHERE parent = ?').get(collPath).n; + } + + // Bulk delete whole collection or by where-filter. No fetch. SQL knows paths. + // DEV ONLY — guarded at HTTP layer; db fn itself unguarded (server-internal trust). + function deleteCollection(collPath, { where } = {}) { + let sql = 'DELETE FROM docs WHERE parent = ?'; + const params = [collPath]; + const changed = []; + if (where) { + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(where.field)) throw new Error(`bad where field: ${where.field}`); + if (where.op !== '==') throw new Error(`unsupported where op: ${where.op}`); + sql += ` AND json_extract(data, '$.${where.field}') = ?`; + params.push(where.value); + } + // collect paths+parents for broadcast before delete + const rows = db.prepare('SELECT path, parent FROM docs WHERE parent = ?' + (where ? ` AND json_extract(data, '$.${where.field}') = ?` : '')).all(...params); + const info = db.prepare(sql).run(...params); + if (broadcast) rows.forEach(r => broadcast({ path: r.path, parent: r.parent, deleted: true })); + return info.changes; } function batchWrite(ops) { @@ -104,7 +153,24 @@ function makeStore(db, broadcast) { 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 }; diff --git a/server/index.js b/server/index.js index 8f7bf43..cb9c8c7 100644 --- a/server/index.js +++ b/server/index.js @@ -11,7 +11,7 @@ const crypto = require('crypto'); const { WebSocketServer } = require('ws'); 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 app = express(); app.use(cors({ origin: corsOrigin || '*' })); @@ -64,9 +64,21 @@ function createServer({ dbPath, port, corsOrigin } = {}) { // GET /api/collection?path=campaigns/abc/encounters app.get('/api/collection', (req, res) => { + const { path: p, whereField, whereOp, whereValue, orderBy, dir, limit, offset } = req.query; + if (!p) return res.status(400).json({ error: 'path required' }); + const opts = {}; + if (whereField) opts.where = { field: whereField, op: whereOp || '==', value: whereValue }; + if (orderBy) opts.orderBy = orderBy; + if (dir) opts.dir = dir; + if (limit) opts.limit = limit; + if (offset) opts.offset = offset; + res.json(store.getCollection(p, opts)); + }); + + app.get('/api/collection/count', (req, res) => { const { path: p } = req.query; if (!p) return res.status(400).json({ error: 'path required' }); - res.json(store.getCollection(p)); + res.json({ count: store.countCollection(p) }); }); // PUT /api/doc body: { path, data } (replace) @@ -91,6 +103,21 @@ function createServer({ dbPath, port, corsOrigin } = {}) { res.json({ ok: true }); }); + // DELETE /api/collection?path=...&whereField=...&whereValue=... + // Bulk delete whole collection or filtered. No fetch. SQL DELETE. + // DEV ONLY: requires ALLOW_DEV_ENDPOINTS=1 env on server. + app.delete('/api/collection', (req, res) => { + if (!allowDevEndpoints && process.env.ALLOW_DEV_ENDPOINTS !== '1') { + return res.status(403).json({ error: 'bulk delete disabled (dev only)' }); + } + const { path: p, whereField, whereOp, whereValue } = req.query; + if (!p) return res.status(400).json({ error: 'path required' }); + const opts = {}; + if (whereField) opts.where = { field: whereField, op: whereOp || '==', value: whereValue }; + const deleted = store.deleteCollection(p, opts); + res.json({ ok: true, deleted }); + }); + // POST /api/collection body: { path, data } (addDoc: auto-id under collection) app.post('/api/collection', (req, res) => { const { path: collPath, data } = req.body || {}; @@ -108,6 +135,32 @@ function createServer({ dbPath, port, corsOrigin } = {}) { res.json({ ok: true }); }); + // POST /api/undo body: { logPath, undo } where undo={encounterPath, updates, redo} + // Transactional: apply updates + flip undone in one SQLite tx. + // If `redo` true, applies redo patch instead + flips undone:false. + app.post('/api/undo', (req, res) => { + const { logPath, undo } = req.body || {}; + if (!logPath || !undo || !undo.encounterPath) { + return res.status(400).json({ error: 'logPath + undo.encounterPath required' }); + } + if (!store.transactionalUndo) { + return res.status(501).json({ error: 'transactionalUndo not supported by store' }); + } + try { + const isRedo = !!req.body.redo; + const updates = isRedo ? (undo.redo || undo.updates) : undo.updates; + const result = store.transactionalUndo({ + logPath, + encounterPath: undo.encounterPath, + updates, + undoneFlag: !isRedo, + }); + res.json({ ok: true, ...result }); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + // --- WebSocket: subscribe by path --- const server = http.createServer(app); const wss = new WebSocketServer({ server, path: '/ws' }); diff --git a/server/package.json b/server/package.json index 51ffdcf..787a390 100644 --- a/server/package.json +++ b/server/package.json @@ -7,7 +7,7 @@ "scripts": { "dev": "node --watch index.js", "start": "node index.js", - "test": "jest --forceExit" + "test": "../scripts/cap.sh 30 jest --forceExit" }, "dependencies": { "@ttrpg/shared": "*", diff --git a/server/tests/server-contract.test.js b/server/tests/server-contract.test.js index 31c4c34..1e323c9 100644 --- a/server/tests/server-contract.test.js +++ b/server/tests/server-contract.test.js @@ -16,9 +16,9 @@ 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() { +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 }); + const handle = createServer({ dbPath, port: 0, allowDevEndpoints }); await new Promise((resolve, reject) => { handle.server.on('error', reject); handle.server.listen(0, resolve); @@ -31,4 +31,85 @@ async function makeStorage() { return storage; } -runStorageContract('server (live backend)', makeStorage); +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)); + }); +}); diff --git a/shared/index.js b/shared/index.js index 5cd2ed2..b434378 100644 --- a/shared/index.js +++ b/shared/index.js @@ -1,2 +1,3 @@ // @ttrpg/shared — barrel export. -module.exports = require('./turn.js'); +const turn = require('./turn.js'); +module.exports = { ...turn, logEvent: require('./logEvent.js') }; diff --git a/shared/logEvent.js b/shared/logEvent.js new file mode 100644 index 0000000..b410c0a --- /dev/null +++ b/shared/logEvent.js @@ -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 }; diff --git a/shared/package.json b/shared/package.json index 355f76c..9e8b87a 100644 --- a/shared/package.json +++ b/shared/package.json @@ -5,7 +5,7 @@ "description": "Pure logic shared by client + server + tests. No I/O.", "main": "index.js", "scripts": { - "test": "jest" + "test": "../scripts/cap.sh 30 jest" }, "devDependencies": { "jest": "^29.7.0" diff --git a/shared/tests/REWRITE_SPEC.md b/shared/tests/REWRITE_SPEC.md new file mode 100644 index 0000000..26f12d2 --- /dev/null +++ b/shared/tests/REWRITE_SPEC.md @@ -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. diff --git a/shared/tests/_helpers.js b/shared/tests/_helpers.js new file mode 100644 index 0000000..d373539 --- /dev/null +++ b/shared/tests/_helpers.js @@ -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 }; diff --git a/shared/tests/logEvent.test.js b/shared/tests/logEvent.test.js new file mode 100644 index 0000000..9bff951 --- /dev/null +++ b/shared/tests/logEvent.test.js @@ -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'); + }); +}); diff --git a/shared/tests/static.eslint.test.js b/shared/tests/static.eslint.test.js new file mode 100644 index 0000000..8657f95 --- /dev/null +++ b/shared/tests/static.eslint.test.js @@ -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 --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 +}); diff --git a/shared/tests/static.no-unlogged.test.js b/shared/tests/static.no-unlogged.test.js deleted file mode 100644 index a55aecb..0000000 --- a/shared/tests/static.no-unlogged.test.js +++ /dev/null @@ -1,98 +0,0 @@ -// STATIC GUARD: every mutation logged. Scans shared/turn.js source. -// Invariant: any return with patch != null MUST also have log != null. -// return { patch: {...}, log: {...} } — OK (logged mutation) -// return { patch: null, log: null } — OK (no-op) -// return { patch: {...}, log: null } — FAIL (unlogged mutation = BUG-7 class) -// -// BUG-7 history: reorderParticipants + deathSave + addParticipants + -// updateParticipant all returned log:null with real patches. Per-op test -// missed them because gaps section asserted null as "expected." Static scan -// catches any future regression regardless of test enumeration. - -'use strict'; - -const fs = require('fs'); -const path = require('path'); - -const SRC = fs.readFileSync(path.join(__dirname, '..', 'turn.js'), 'utf8'); - -// Extract function name + body via brace counting. -function collectBody(src, fromIdx) { - let i = fromIdx; - while (i < src.length && src[i] !== '{') i++; - let depth = 0; - const start = i; - for (; i < src.length; i++) { - if (src[i] === '{') depth++; - else if (src[i] === '}') { depth--; if (depth === 0) break; } - } - return src.slice(start, i + 1); -} - -function extractFunctions(src) { - const out = []; - const fnRe = /\bfunction\s+([A-Za-z0-9_$]+)\s*\(/g; - const arrowRe = /(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*[^=;]*?=>/g; - let m; - while ((m = fnRe.exec(src)) !== null) { - out.push({ name: m[1], body: collectBody(src, m.index + m[0].length) }); - } - while ((m = arrowRe.exec(src)) !== null) { - out.push({ name: m[1], body: collectBody(src, m.index + m[0].length) }); - } - return out; -} - -// Split body into return statements at top level of the function. -// Returns array of return-expr strings. -function extractReturns(body) { - const returns = []; - // find `return ` at depth 1 (inside fn body) - let i = 0; - // body starts with `{`. Skip into depth 1. - while (i < body.length && body[i] !== '{') i++; - i++; // past { - let depth = 1; - let start = -1; - for (; i < body.length; i++) { - const ch = body[i]; - if (ch === '{') depth++; - else if (ch === '}') depth--; - if (depth === 1 && body.slice(i, i + 7) === 'return ') { - start = i + 7; - } - if (depth === 1 && ch === ';' && start !== -1) { - returns.push(body.slice(start, i)); - start = -1; - } - } - return returns; -} - -// For a return expr, detect: has `log: null` AND has `patch:` that is NOT null. -// Returns true if suspicious (unlogged mutation). -function isUnloggedMutation(retExpr) { - if (!/log:\s*null/.test(retExpr)) return false; - // patch: null → no-op, OK - if (/patch:\s*null/.test(retExpr)) return false; - // patch: { ... } or patch: someVar → mutation with null log = BAD - if (/patch:/.test(retExpr)) return true; - return false; -} - -describe('STATIC: every mutation logged (logging contract)', () => { - const fns = extractFunctions(SRC); - - test('no function returns patch!=null with log:null', () => { - const offenders = []; - for (const { name, body } of fns) { - const rets = extractReturns(body); - for (const r of rets) { - if (isUnloggedMutation(r)) { - offenders.push(`${name}: return ${r.trim().slice(0, 80)}`); - } - } - } - expect(offenders).toEqual([]); - }); -}); diff --git a/shared/tests/turn.bug10.test.js b/shared/tests/turn.bug10.test.js index 07f0ef3..f27a72c 100644 --- a/shared/tests/turn.bug10.test.js +++ b/shared/tests/turn.bug10.test.js @@ -7,14 +7,16 @@ // // 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 shared = require('@ttrpg/shared'); const { makeParticipant, startEncounter, nextTurn, toggleParticipantActive, -} = shared; +} = require('@ttrpg/shared'); +const { mockCtx } = require('./_helpers'); function p(id, init) { return makeParticipant({ id, name: id, type: 'monster', @@ -24,35 +26,36 @@ function enc(ps) { return { name:'t', participants:ps, isStarted:false, isPaused:false, round:0, currentTurnParticipantId:null, turnOrderIds:[] }; } -const apply = (e, r) => r && r.patch ? { ...e, ...r.patch } : e; describe('BUG-10: deact+reactivate same round', () => { - test('no participant acts twice in a round after deact+reactivate', () => { + 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 = apply(e, startEncounter(e)); // a current, r1 + e = await startEncounter(e, ctx); // a current, r1 const r1 = []; r1.push(e.currentTurnParticipantId); // a acts - e = apply(e, nextTurn(e)); r1.push(e.currentTurnParticipantId); // b acts + e = await nextTurn(e, ctx); r1.push(e.currentTurnParticipantId); // b acts - // b already acted. Deactivate b (NOT current now — b just became current - // via nextTurn, so b IS current). Toggle off advances pointer to c. - e = apply(e, toggleParticipantActive(e, 'b')); // b off - // current advanced to c (b was current) - r1.push(e.currentTurnParticipantId); // c "becomes" active turn - // reactivate b same round - e = apply(e, toggleParticipantActive(e, 'b')); // b on + // 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 = apply(e, nextTurn(e)); + 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 = apply(e, nextTurn(e)); + e = await nextTurn(e, ctx); expect(e.currentTurnParticipantId).toBe('b'); - e = apply(e, nextTurn(e)); + e = await nextTurn(e, ctx); expect(e.currentTurnParticipantId).toBe('c'); // per-round visit count @@ -60,22 +63,23 @@ describe('BUG-10: deact+reactivate same round', () => { expect(bCountR1).toBe(1); }); - test('deactivate+reactivate non-current who already acted: no re-act', () => { + 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 = apply(e, startEncounter(e)); // a - e = apply(e, nextTurn(e)); // b - e = apply(e, nextTurn(e)); // c (r1 full: a,b,c) + 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 = apply(e, toggleParticipantActive(e, 'a')); // a off, pointer stays c + e = await toggleParticipantActive(e, 'a', ctx); // a off, pointer stays c expect(e.currentTurnParticipantId).toBe('c'); - e = apply(e, toggleParticipantActive(e, 'a')); // a on + 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 = apply(e, nextTurn(e)); + e = await nextTurn(e, ctx); expect(e.round).toBe(2); expect(e.currentTurnParticipantId).toBe('a'); }); diff --git a/shared/tests/turn.bug13.test.js b/shared/tests/turn.bug13.test.js index 6436f44..c90c175 100644 --- a/shared/tests/turn.bug13.test.js +++ b/shared/tests/turn.bug13.test.js @@ -5,14 +5,17 @@ // - 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 shared = require('@ttrpg/shared'); const { makeParticipant, startEncounter, nextTurn, reorderParticipants, -} = shared; +} = require('@ttrpg/shared'); +const { mockCtx } = require('./_helpers'); function p(id, init) { return makeParticipant({ id, name: id, type: 'monster', @@ -22,51 +25,55 @@ function enc(ps) { return { name:'t', participants:ps, isStarted:false, isPaused:false, round:0, currentTurnParticipantId:null, turnOrderIds:[] }; } -const apply = (e, r) => r && r.patch ? { ...e, ...r.patch } : e; describe('BUG-13: cross-pointer reorder blocked during active encounter', () => { - test('dragging not-yet-acted participant ahead of pointer = no-op', () => { + 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 = apply(e, startEncounter(e)); // a (idx0, pointer) - e = apply(e, nextTurn(e)); // b (idx1, pointer) + 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 r = reorderParticipants(e, 'c', 'b'); - expect(r.patch).toBeNull(); + 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', () => { + 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 = apply(e, startEncounter(e)); // a (pointer idx0) - e = apply(e, nextTurn(e)); // b (pointer idx1) + 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 r = reorderParticipants(e, 'a', 'c'); - expect(r.patch).toBeNull(); + 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', () => { + 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 = apply(e, startEncounter(e)); // a (idx0) + e = await startEncounter(e, ctx); // a (idx0) // swap b,c (both idx1,2 — behind pointer). No cross. - const r = reorderParticipants(e, 'c', 'b'); - expect(r.patch).not.toBeNull(); - expect(r.patch.participants.map(p => p.id)).toEqual(['a','c','b','d']); + 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)', () => { + 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 r = reorderParticipants(e, 'c', 'a'); - expect(r.patch).not.toBeNull(); - expect(r.patch.participants.map(p => p.id)).toEqual(['c','a','b']); + 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']); }); }); diff --git a/shared/tests/turn.bug7.test.js b/shared/tests/turn.bug7.test.js index d68fa41..aaa3ba4 100644 --- a/shared/tests/turn.bug7.test.js +++ b/shared/tests/turn.bug7.test.js @@ -1,15 +1,16 @@ // BUG-7: reorderParticipants not logged. -// Every combat op returns { patch, log }. Handler calls logAction(log.message, -// ctx, { updates: log.undo }) → writes entry to logs collection. -// reorderParticipants returns log: null → handler skips logAction → drag -// invisible in combat log + no undo payload (siblings all have one). +// 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: prove log is null (bug). Fix: return { message, undo }. +// 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 shared = require('@ttrpg/shared'); -const { makeParticipant, reorderParticipants } = shared; +const { makeParticipant, reorderParticipants, expandUndo } = require('@ttrpg/shared'); +const { mockCtx } = require('./_helpers'); function p(id, init) { return makeParticipant({ id, name: id, type: 'monster', @@ -21,34 +22,40 @@ function enc(ps) { } describe('BUG-7: reorderParticipants logged', () => { - test('returns log object (not null) with message + undo', () => { + 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)]); - const r = reorderParticipants(e, 'c', 'b'); - expect(r.patch).not.toBeNull(); - expect(r.log).not.toBeNull(); - expect(typeof r.log.message).toBe('string'); - expect(r.log.message.length).toBeGreaterThan(0); - expect(r.log.undo).toBeDefined(); + 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', () => { + 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); - const r = reorderParticipants(e, 'c', 'b'); - const restored = { ...e, ...r.log.undo }; + 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', () => { + test('no-op (same id) returns null log', async () => { + const { storage, ctx } = mockCtx(); const e = enc([p('a', 10), p('b', 10)]); - const r = reorderParticipants(e, 'a', 'a'); - expect(r.log).toBeNull(); + 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', () => { + test('no-op (cross-init blocked) returns null log', async () => { + const { storage, ctx } = mockCtx(); const e = enc([p('a', 10), p('b', 5)]); - const r = reorderParticipants(e, 'a', 'b'); - expect(r.patch).toBeNull(); - expect(r.log).toBeNull(); + const newEnc = await reorderParticipants(e, 'a', 'b', ctx); + expect(newEnc).toBe(e); // same ref = no write + expect(storage.logs()).toHaveLength(0); }); }); diff --git a/shared/tests/turn.characterization.test.js b/shared/tests/turn.characterization.test.js index 83404e2..cf5d32e 100644 --- a/shared/tests/turn.characterization.test.js +++ b/shared/tests/turn.characterization.test.js @@ -1,6 +1,10 @@ // Characterization tests for shared/turn.js. // Lock CURRENT behavior (bugs included). M3 will extend, M4 will fix. // These tests assert what the code does NOW, not what it SHOULD do. +// +// New API: every mutating func is async, takes ctx last, writes encounter + +// log internally, returns newEnc. We assert against newEnc (merged state) or +// the raw persisted patch (storage.calls) for "field not written" checks. const shared = require('@ttrpg/shared'); const { @@ -19,6 +23,7 @@ const { endEncounter, makeParticipant, } = shared; +const { mockCtx } = require('./_helpers'); // Helper: minimal encounter with given participants. function enc(participants = [], extra = {}) { @@ -42,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', () => { test('higher initiative first', () => { const ps = [p('a', 5), p('b', 15), p('c', 10)]; @@ -57,44 +69,51 @@ describe('sortParticipantsByInitiative', () => { }); describe('startEncounter', () => { - test('throws if no participants', () => { - expect(() => startEncounter(enc([]))).toThrow('participants'); + test('throws if no participants', async () => { + 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 })]); - 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 e = enc(ps); - const { patch } = startEncounter(e); - expect(patch.isStarted).toBe(true); - expect(patch.round).toBe(1); - expect(patch.turnOrderIds).toEqual(['b', 'c', 'a']); - expect(patch.currentTurnParticipantId).toBe('b'); + const newEnc = await startEncounter(e, ctx); + expect(newEnc.isStarted).toBe(true); + expect(newEnc.round).toBe(1); + expect(newEnc.turnOrderIds).toEqual(['b', 'c', 'a']); + expect(newEnc.currentTurnParticipantId).toBe('b'); }); - test('inactive stays in turn order slot (1-list model)', () => { + 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 { patch } = startEncounter(enc(ps)); + const newEnc = await startEncounter(enc(ps), ctx); // 1-list: all participants sorted by init (active+inactive), inactive stays in slot - expect(patch.turnOrderIds).toEqual(['b', 'c', 'a']); - expect(patch.currentTurnParticipantId).toBe('c'); // b inactive, skipped + expect(newEnc.turnOrderIds).toEqual(['b', 'c', 'a']); + expect(newEnc.currentTurnParticipantId).toBe('c'); // b inactive, skipped }); }); describe('nextTurn', () => { - test('throws if not started', () => { - expect(() => nextTurn(enc([p('a', 10)], { isStarted: false }))).toThrow(); + test('throws if not started', async () => { + const { ctx } = mockCtx(); + await expect(nextTurn(enc([p('a', 10)], { isStarted: false }), ctx)).rejects.toThrow(); }); - test('throws if paused', () => { - expect(() => nextTurn(enc([p('a', 10)], { isStarted: true, isPaused: true, currentTurnParticipantId: 'a', turnOrderIds: ['a'] }))).toThrow(); + test('throws if paused', async () => { + const { ctx } = mockCtx(); + await expect(nextTurn(enc([p('a', 10)], { isStarted: true, isPaused: true, currentTurnParticipantId: 'a', turnOrderIds: ['a'] }), ctx)).rejects.toThrow(); }); - test('advances to next in order, no round bump', () => { + test('advances to next in order, no round bump', async () => { + const { ctx } = mockCtx(); const ps = [p('a', 5), p('b', 15), p('c', 10)]; const e = enc(ps, { isStarted: true, @@ -102,12 +121,13 @@ describe('nextTurn', () => { currentTurnParticipantId: 'b', turnOrderIds: ['b', 'c', 'a'], }); - const { patch } = nextTurn(e); - expect(patch.currentTurnParticipantId).toBe('c'); - expect(patch.round).toBe(1); + const newEnc = await nextTurn(e, ctx); + expect(newEnc.currentTurnParticipantId).toBe('c'); + 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 e = enc(ps, { isStarted: true, @@ -115,12 +135,13 @@ describe('nextTurn', () => { currentTurnParticipantId: 'a', turnOrderIds: ['b', 'c', 'a'], }); - const { patch } = nextTurn(e); - expect(patch.currentTurnParticipantId).toBe('b'); - expect(patch.round).toBe(2); + const newEnc = await nextTurn(e, ctx); + expect(newEnc.currentTurnParticipantId).toBe('b'); + 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 e = enc(ps, { isStarted: true, @@ -128,185 +149,204 @@ describe('nextTurn', () => { currentTurnParticipantId: 'a', turnOrderIds: ['a'], }); - const { patch } = nextTurn(e); - expect(patch.isStarted).toBe(false); - expect(patch.currentTurnParticipantId).toBe(null); + const newEnc = await nextTurn(e, ctx); + expect(newEnc.isStarted).toBe(false); + expect(newEnc.currentTurnParticipantId).toBe(null); }); }); 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 { patch } = togglePause(e); - expect(patch.isPaused).toBe(true); + const newEnc = await togglePause(e, ctx); + 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 // and caused skips. Order frozen at startEncounter, patched incrementally. + const { ctx } = mockCtx(); const ps = [p('a', 5), p('b', 15)]; const e = enc(ps, { isStarted: true, isPaused: true, turnOrderIds: ['a', 'b'] }); - const { patch } = togglePause(e); - expect(patch.isPaused).toBe(false); - expect(patch.turnOrderIds).toEqual(['a', 'b']); + const newEnc = await togglePause(e, ctx); + expect(newEnc.isPaused).toBe(false); + expect(newEnc.turnOrderIds).toEqual(['a', 'b']); }); }); describe('removeParticipant', () => { - test('removes from participants array', () => { + test('removes from participants array', async () => { + const { ctx } = mockCtx(); const ps = [p('a', 10), p('b', 5)]; - const { patch } = removeParticipant(enc(ps), 'a'); - expect(patch.participants.map(x => x.id)).toEqual(['b']); + const newEnc = await removeParticipant(enc(ps), 'a', ctx); + 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 { patch } = removeParticipant(enc(ps), 'a'); - expect(patch.turnOrderIds).toBeUndefined(); + await removeParticipant(enc(ps), 'a', ctx); + 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 e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'b' }); - const { patch } = removeParticipant(e, 'a'); - expect(patch.turnOrderIds).toEqual(['b']); + const newEnc = await removeParticipant(e, 'a', ctx); + 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 e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b', 'c'], currentTurnParticipantId: 'a' }); - const { patch } = removeParticipant(e, 'a'); - expect(patch.currentTurnParticipantId).toBe('b'); + const newEnc = await removeParticipant(e, 'a', ctx); + expect(newEnc.currentTurnParticipantId).toBe('b'); }); }); describe('toggleParticipantActive', () => { - test('deactivates participant', () => { + test('deactivates participant', async () => { + const { ctx } = mockCtx(); const ps = [p('a', 10, { isActive: true })]; - const { patch } = toggleParticipantActive(enc(ps), 'a'); - expect(patch.participants[0].isActive).toBe(false); + const newEnc = await toggleParticipantActive(enc(ps), 'a', ctx); + 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 e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' }); - const { patch } = toggleParticipantActive(e, 'a'); - expect(patch.currentTurnParticipantId).toBe('b'); + const e = enc(ps, { isStarted: true, round: 1, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' }); + const newEnc = await toggleParticipantActive(e, 'a', ctx); + expect(newEnc.currentTurnParticipantId).toBe('a'); + expect(newEnc.round).toBe(1); + expect(newEnc.participants.find(p => p.id === 'a').isActive).toBe(false); }); - test('started: reactivating inserts by initiative', () => { + test('started: reactivating inserts by initiative', async () => { // BUG-5 fix: reactivated participant slots by initiative (not appended // to end). Preserves correct rotation order. + const { ctx } = mockCtx(); const ps = [p('a', 10, { isActive: false }), p('b', 5)]; const e = enc(ps, { isStarted: true, turnOrderIds: ['b'], currentTurnParticipantId: 'b' }); - const { patch } = toggleParticipantActive(e, 'a'); + const newEnc = await toggleParticipantActive(e, 'a', ctx); // a init=10 > b init=5 → a slots before b - expect(patch.turnOrderIds).toEqual(['a', 'b']); + expect(newEnc.turnOrderIds).toEqual(['a', 'b']); }); }); 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 { patch } = applyHpChange(enc(ps), 'a', 'damage', 5); - expect(patch.participants[0].currentHp).toBe(10); + const newEnc = await applyHpChange(enc(ps), 'a', 'damage', 5, ctx); + expect(newEnc.participants[0].currentHp).toBe(10); }); - test('damage to 0 deactivates + keeps turn order (unified)', () => { - // Unified: death flips isActive=false (removed from active rotation). - // turnOrderIds unchanged (no turn-order patch on death). - const ps = [p('a', 10, { currentHp: 3 }), p('b', 5)]; + test('damage to 0 marks dying, keeps active, keeps turn order', async () => { + const { storage, ctx } = mockCtx(); + const ps = [p('a', 10, { type: 'character', currentHp: 3 }), p('b', 5)]; const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' }); - const { patch } = applyHpChange(e, 'a', 'damage', 5); - expect(patch.participants[0].currentHp).toBe(0); - expect(patch.participants[0].isActive).toBe(false); - expect(patch.turnOrderIds).toBeUndefined(); - expect(patch.currentTurnParticipantId).toBeUndefined(); + const newEnc = await applyHpChange(e, 'a', 'damage', 5, ctx); + expect(newEnc.participants[0].currentHp).toBe(0); + expect(newEnc.participants[0].status).toBe('dying'); + expect(newEnc.participants[0].isActive).not.toBe(false); + expect(lastPatch(storage).turnOrderIds).toBeUndefined(); + expect(lastPatch(storage).currentTurnParticipantId).toBeUndefined(); }); - test('heal above 0 reactivates + resets death saves (unified)', () => { - // Unified: revive from 0 flips isActive=true, deathSaves reset. - const ps = [p('a', 10, { currentHp: 0, isActive: false, deathSaves: 2 })]; - const { patch } = applyHpChange(enc(ps), 'a', 'heal', 5); - expect(patch.participants[0].currentHp).toBe(5); - expect(patch.participants[0].isActive).toBe(true); - expect(patch.participants[0].deathSaves).toBe(0); + test('heal from dying makes conscious and resets death-save counters', async () => { + const { ctx } = mockCtx(); + const ps = [p('a', 10, { currentHp: 0, status: 'dying', deathSaveSuccesses: 2, deathSaveFailures: 1 })]; + const newEnc = await applyHpChange(enc(ps), 'a', 'heal', 5, ctx); + expect(newEnc.participants[0].currentHp).toBe(5); + expect(newEnc.participants[0].status).toBe('conscious'); + expect(newEnc.participants[0].deathSaveSuccesses).toBe(0); + expect(newEnc.participants[0].deathSaveFailures).toBe(0); }); - test('heal clamps to maxHp', () => { + test('heal clamps to maxHp', async () => { + const { ctx } = mockCtx(); const ps = [p('a', 10, { currentHp: 18, maxHp: 20 })]; - const { patch } = applyHpChange(enc(ps), 'a', 'heal', 10); - expect(patch.participants[0].currentHp).toBe(20); + const newEnc = await applyHpChange(enc(ps), 'a', 'heal', 10, ctx); + expect(newEnc.participants[0].currentHp).toBe(20); }); - test('zero amount = no-op', () => { - const ps = [p('a', 10, { currentHp: 10 })]; - const { patch } = applyHpChange(enc(ps), 'a', 'damage', 0); - expect(patch).toBe(null); + test('zero amount = no-op', async () => { + const { storage, ctx } = mockCtx(); + const e = enc([p('a', 10, { currentHp: 10 })]); + const newEnc = await applyHpChange(e, 'a', 'damage', 0, ctx); + expect(newEnc).toBe(e); // same ref = no write + expect(storage.calls.filter(c => c.fn === 'updateDoc')).toHaveLength(0); }); }); describe('deathSave', () => { - test('increments fails', () => { - const ps = [p('a', 10, { currentHp: 0, deathFails: 0 })]; - const { patch } = deathSave(enc(ps), 'a', 'fail', 1); - expect(patch.participants[0].deathFails).toBe(1); + test('fail increments failure counter while dying', async () => { + const { ctx } = mockCtx(); + const ps = [p('a', 10, { currentHp: 0, status: 'dying', deathSaveFailures: 0, deathSaveSuccesses: 0 })]; + const { enc: newEnc, status } = await deathSave(enc(ps), 'a', 'fail', ctx); + expect(status).toBe('dying'); + expect(newEnc.participants[0].deathSaveFailures).toBe(1); }); - test('clicking same fail decrements (toggle)', () => { - const ps = [p('a', 10, { currentHp: 0, deathFails: 2 })]; - const { patch } = deathSave(enc(ps), 'a', 'fail', 2); - expect(patch.participants[0].deathFails).toBe(1); - }); - - test('third fail sets isDying', () => { - const ps = [p('a', 10, { currentHp: 0, deathFails: 2 })]; - const result = deathSave(enc(ps), 'a', 'fail', 3); - expect(result.patch.participants[0].deathFails).toBe(3); - expect(result.patch.participants[0].isDying).toBe(true); - expect(result.isDying).toBe(true); + test('third fail makes dead and resets counters', async () => { + const { ctx } = mockCtx(); + const ps = [p('a', 10, { currentHp: 0, status: 'dying', deathSaveFailures: 2, deathSaveSuccesses: 0 })]; + const { enc: newEnc, status } = await deathSave(enc(ps), 'a', 'fail', ctx); + expect(status).toBe('dead'); + expect(newEnc.participants[0].status).toBe('dead'); + expect(newEnc.participants[0].deathSaveFailures).toBe(0); }); }); describe('toggleCondition', () => { - test('adds condition', () => { + test('adds condition', async () => { + const { ctx } = mockCtx(); const ps = [p('a', 10, { conditions: [] })]; - const { patch } = toggleCondition(enc(ps), 'a', 'poisoned'); - expect(patch.participants[0].conditions).toEqual(['poisoned']); + const newEnc = await toggleCondition(enc(ps), 'a', 'poisoned', ctx); + 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 { patch } = toggleCondition(enc(ps), 'a', 'poisoned'); - expect(patch.participants[0].conditions).toEqual(['blinded']); + const newEnc = await toggleCondition(enc(ps), 'a', 'poisoned', ctx); + expect(newEnc.participants[0].conditions).toEqual(['blinded']); }); }); describe('reorderParticipants', () => { - test('drag before target (same-init tie)', () => { + 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 { patch } = reorderParticipants(enc(ps), 'a', 'c'); - // drag a before c: remove a → [b,c], insert before c → [b,a,c] - expect(patch.participants.map(x => x.id)).toEqual(['b', 'a', 'c']); + const newEnc = await reorderParticipants(enc(ps), 'a', 'c', ctx); + // drag a downward onto c: remove a → [b,c], insert after c → [b,c,a] + expect(newEnc.participants.map(x => x.id)).toEqual(['b', 'c', 'a']); }); - test('cross-init drag blocked (no-op)', () => { - const ps = [p('a', 10), p('b', 5)]; - const { patch } = reorderParticipants(enc(ps), 'a', 'b'); - expect(patch).toBeNull(); + test('cross-init drag blocked (no-op)', async () => { + const { storage, ctx } = mockCtx(); + const e = enc([p('a', 10), p('b', 5)]); + const newEnc = await reorderParticipants(e, 'a', 'b', ctx); + expect(newEnc).toBe(e); // same ref = no write + expect(storage.calls.filter(c => c.fn === 'updateDoc')).toHaveLength(0); }); }); describe('endEncounter', () => { - test('resets all combat state', () => { + test('resets all combat state', async () => { + const { ctx } = mockCtx(); const e = enc([p('a', 10)], { isStarted: true, round: 5, currentTurnParticipantId: 'a', turnOrderIds: ['a'], }); - const { patch } = endEncounter(e); - expect(patch.isStarted).toBe(false); - expect(patch.round).toBe(0); - expect(patch.currentTurnParticipantId).toBe(null); - expect(patch.turnOrderIds).toEqual([]); + const newEnc = await endEncounter(e, ctx); + expect(newEnc.isStarted).toBe(false); + expect(newEnc.round).toBe(0); + expect(newEnc.currentTurnParticipantId).toBe(null); + expect(newEnc.turnOrderIds).toEqual([]); }); }); @@ -326,19 +366,21 @@ describe('computeTurnOrderAfterRemoval', () => { }); describe('addParticipant', () => { - test('appends participant', () => { + test('appends participant', async () => { + const { ctx } = mockCtx(); const np = p('z', 7); - const { patch } = addParticipant(enc([p('a', 10)]), np); - expect(patch.participants.map(x => x.id)).toEqual(['a', 'z']); + const newEnc = await addParticipant(enc([p('a', 10)]), np, ctx); + expect(newEnc.participants.map(x => x.id)).toEqual(['a', 'z']); }); - test('rejects duplicate id (skip-bug root cause)', () => { + test('rejects duplicate id (skip-bug root cause)', async () => { // Two participants with same id → togglePause resume rebuilds order with // dup id twice → nextTurn gets stuck repeating that id forever. // Audit found this in 100-round replay (addParticipant fired while paused // because nextTurn threw, loop spun, same totalTurns %10 → re-added). + const { ctx } = mockCtx(); const existing = p('x', 5); const dup = makeParticipant({ id: 'x', name: 'x2', type: 'monster', initiative: 10, maxHp: 100, currentHp: 100 }); - expect(() => addParticipant(enc([p('a', 10), existing]), dup)).toThrow(); + await expect(addParticipant(enc([p('a', 10), existing]), dup, ctx)).rejects.toThrow(); }); }); diff --git a/shared/tests/turn.combat.test.js b/shared/tests/turn.combat.test.js index 80cbf28..d640930 100644 --- a/shared/tests/turn.combat.test.js +++ b/shared/tests/turn.combat.test.js @@ -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 // was supposed to be. Deterministic (seeded RNG). RED on current code = BUG-5. // @@ -6,8 +6,11 @@ // damage, heal (cleric), conditions, toggleActive, deathSave, // removeParticipant, addParticipant, updateParticipant, pause/resume, // reorderParticipants, revive-between-rounds. +// +// Rewritten for async turn.js API: funcs are awaited, take ctx, write own logs. const shared = require('@ttrpg/shared'); +const { mockCtx } = require('./_helpers'); const { makeParticipant, buildCharacterParticipant, buildMonsterParticipant, startEncounter, nextTurn, togglePause, @@ -53,7 +56,7 @@ function setupEncounter() { buildMonsterParticipant({ name:'Goblin2', maxHp:100, initMod:2 }).participant, buildMonsterParticipant({ name:'OrcBoss', maxHp:500, initMod:1 }).participant, buildMonsterParticipant({ name:'Wolf', maxHp:120, initMod:3 }).participant, - buildMonsterParticipant({ name:'Merchant', maxHp:150, initMod:0, isNpc:true }).participant, + buildMonsterParticipant({ name:'Merchant', maxHp:150, initMod:0, asNpc:true }).participant, ]; // give deterministic ids to monsters for assertions const idMap = { Goblin1:'m1', Goblin2:'m2', OrcBoss:'m3', Wolf:'m4', Merchant:'n1' }; @@ -70,22 +73,17 @@ function currentParticipant(e) { 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)', () => { jest.setTimeout(30000); const ROUNDS = 100; 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 let e = setupEncounter(); - e = apply(e, startEncounter(e)); + e = await startEncounter(e, ctx); let totalTurns = 0; let lastPaused = false; @@ -102,15 +100,15 @@ describe('combat integrity (100 rounds, full op coverage)', () => { while (e.round === startRound && guard < cap) { // 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 - let t; - try { t = nextTurn(e); } catch (err) { + try { + e = await nextTurn(e, ctx); + } catch (err) { violations.push({ round: roundN, type: 'nextTurn-throws', msg: err.message }); break; } - e = apply(e, t); totalTurns++; // only count if turn belongs to THIS round (no wrap) if (e.round === startRound) seenThisRound.push(e.currentTurnParticipantId); @@ -120,58 +118,58 @@ describe('combat integrity (100 rounds, full op coverage)', () => { // 1. damage if (actor) { 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) { const tgt = pick(foes); 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) if (actor && actor.name === 'Cleric' && totalTurns % 2 === 0) { 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)); if (wounded.length > 0) { const tgt = wounded[0]; 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 if (condQueue.length > 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) { 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(); } } } 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) { const tgt = pick(living); 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 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) { 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 - if (actor && actor.currentHp <= 0 && !actor.isNpc) { - try { e = apply(e, deathSave(e, actor.id, 'fail', 1)); } catch (err) {} + if (actor && actor.status === 'dying' && actor.type !== 'npc') { + try { const r = await deathSave(e, actor.id, 'fail', ctx); e = r.enc; } catch (err) {} } - // 6. removeParticipant + // 6. removeParticipant (monsters/NPC only; dead PCs stay until DM removes manually) 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')); - if (dead) { try { e = apply(e, removeParticipant(e, dead.id)); } catch (err) {} } + const dead = e.participants.find(part => (part.status === 'dead' || part.currentHp <= 0) && (part.type === 'npc' || part.name.startsWith('Goblin') || part.name === 'OrcBoss' || part.name === 'Wolf')); + if (dead) { try { e = await removeParticipant(e, dead.id, ctx); } catch (err) {} } } // 7. addParticipant if (totalTurns % 10 === 0 && reinforcementsAdded < 4) { @@ -180,27 +178,27 @@ describe('combat integrity (100 rounds, full op coverage)', () => { { name:`Summon${reinforcementsAdded+1}`, maxHp:80, initMod:4 }, ]); 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 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) { 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 - 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) 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) { const tgt = living[0]; const newInit = (tgt.initiative || 0) + 1; try { - const reordered = [...e.participants].map(p => p.id === tgt.id ? { ...p, initiative: newInit } : p); - e = apply(e, reorderParticipants(e, reordered)); + const reordered = [...e.participants].map(part => part.id === tgt.id ? { ...part, initiative: newInit } : part); + e = await reorderParticipants(e, reordered); // array as draggedId throws → swallowed lastReorder = totalTurns; } catch (err) {} } @@ -215,20 +213,19 @@ describe('combat integrity (100 rounds, full op coverage)', () => { const uniq = new Set(seenThisRound); if (uniq.size !== seenThisRound.length) { 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 const orderUniq = new Set(e.turnOrderIds); if (orderUniq.size !== e.turnOrderIds.length) { violations.push({ round: roundN, type: 'turnOrder-dup-id', order: e.turnOrderIds }); } - // currentTurn valid + 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) { - 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' }); - else if (ct.isActive === false && e.isStarted) { - violations.push({ round: roundN, type: 'currentTurn-inactive', id: ct.id }); - } } // HP bounds for (const part of e.participants) { @@ -247,11 +244,11 @@ describe('combat integrity (100 rounds, full op coverage)', () => { // queue drains them through toggleCondition which proves acceptance) // 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) { try { - if (d.isActive === false) e = apply(e, toggleParticipantActive(e, d.id)); - e = apply(e, applyHpChange(e, d.id, 'heal', d.maxHp)); + if (d.isActive === false) e = await toggleParticipantActive(e, d.id, ctx); + e = await applyHpChange(e, d.id, 'heal', d.maxHp, ctx); } catch (err) {} } } @@ -270,25 +267,26 @@ describe('combat integrity (100 rounds, full op coverage)', () => { // Custom (freeform) condition strings: UI contract. // toggleCondition must accept ANY string, not just built-ins. - test('custom condition strings survive add/remove round-trip', () => { + test('custom condition strings survive add/remove round-trip', async () => { + const { ctx } = mockCtx(); let e = setupEncounter(); - e = apply(e, startEncounter(e)); + e = await startEncounter(e, ctx); const tgt = e.participants[0].id; // add custom (not in built-ins) - e = apply(e, toggleCondition(e, tgt, 'hexed')); - let p = e.participants.find(x => x.id === tgt); - expect(p.conditions).toContain('hexed'); + 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 = apply(e, toggleCondition(e, tgt, '🛡️blessed')); - p = e.participants.find(x => x.id === tgt); - expect(p.conditions).toContain('🛡️blessed'); + e = await toggleCondition(e, tgt, '🛡️blessed', ctx); + part = e.participants.find(x => x.id === tgt); + expect(part.conditions).toContain('🛡️blessed'); // remove - e = apply(e, toggleCondition(e, tgt, 'hexed')); - p = e.participants.find(x => x.id === tgt); - expect(p.conditions).not.toContain('hexed'); - expect(p.conditions).toContain('🛡️blessed'); + 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'); }); }); diff --git a/shared/tests/turn.dead-skip.test.js b/shared/tests/turn.dead-skip.test.js index 670bc35..368270f 100644 --- a/shared/tests/turn.dead-skip.test.js +++ b/shared/tests/turn.dead-skip.test.js @@ -1,24 +1,17 @@ -// Unified behavior (App main): death flips isActive=false, dead participant -// removed from active rotation, skipped by nextTurn. deathSave is a manual -// DM action (button), not tied to rotation. turnOrderIds unchanged on death -// (only isActive flag flips). Revive (heal from 0) reactivates. -// -// Previous "M4: dead stays in rotation" concept reversed by unification. +'use strict'; const shared = require('@ttrpg/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 = {}) { return makeParticipant({ - id, name: id, type: 'character', - initiative: init, maxHp: 100, currentHp: 100, + id, + name: id, + type: 'character', + initiative: init, + maxHp: 100, + currentHp: 100, ...extra, }); } @@ -26,65 +19,67 @@ function enc(ps) { return { name:'t', participants:ps, isStarted:false, isPaused:false, round:0, currentTurnParticipantId:null, turnOrderIds:[] }; } +const byId = (e, id) => e.participants.find(p => p.id === id); -describe('unified: death deactivates, dead skipped in rotation', () => { - test('dead PC not removed from turnOrderIds (stays in list, inactive)', () => { - const ps = [pc('a', 20), pc('b', 15), pc('c', 10)]; - let e = enc(ps); - e = { ...e, ...startEncounter(e).patch }; +describe('death state: participant remains in initiative and encounter', () => { + test('dropping to 0 keeps PC active, in participants, and in turnOrderIds', async () => { + const { ctx } = mockCtx(); + let e = enc([pc('a', 20), pc('b', 15), pc('c', 10)]); + e = await startEncounter(e, ctx); const orderBefore = e.turnOrderIds.slice(); - // 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); }); - test('dead PC skipped by nextTurn (isActive=false)', () => { - const ps = [pc('a', 20), pc('b', 15), pc('c', 10)]; - let e = enc(ps); - e = { ...e, ...startEncounter(e).patch }; - // kill b - e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch }; - // advance: a→c (b skipped, inactive) - e = { ...e, ...nextTurn(e).patch }; - expect(e.currentTurnParticipantId).toBe('c'); + test('dead PC remains in participants and turnOrderIds after three failures', async () => { + const { ctx } = mockCtx(); + let e = enc([pc('a', 20), pc('b', 15), pc('c', 10)]); + e = await startEncounter(e, ctx); + const orderBefore = e.turnOrderIds.slice(); + e = await applyHpChange(e, 'b', 'damage', 100, ctx); + + e = (await deathSave(e, 'b', 'fail', ctx)).enc; + e = (await deathSave(e, 'b', 'fail', ctx)).enc; + e = (await deathSave(e, 'b', 'fail', ctx)).enc; + + expect(byId(e, 'b').status).toBe('dead'); + expect(byId(e, 'b').isActive).not.toBe(false); + expect(e.participants.map(p => p.id)).toContain('b'); + expect(e.turnOrderIds).toEqual(orderBefore); }); - test('dead PC deathSave fires on manual call (not via rotation)', () => { - const ps = [pc('a', 20), pc('b', 15)]; - let e = enc(ps); - e = { ...e, ...startEncounter(e).patch }; - // kill b (current = a) - e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch }; - // b is dead: DM can still fire deathSave (manual action) - const r = deathSave(e, 'b', 'fail', 1); - expect(r.patch).toBeTruthy(); - const b = r.patch.participants.find(x => x.id === 'b'); - expect(b.deathFails).toBe(1); + test('nextTurn may still land on dead PC because DM/tool events may matter', async () => { + const { ctx } = mockCtx(); + let e = enc([pc('a', 20), pc('b', 15), pc('c', 10)]); + e = await startEncounter(e, ctx); + e = await applyHpChange(e, 'b', 'damage', 100, ctx); + e = (await deathSave(e, 'b', 'fail', ctx)).enc; + e = (await deathSave(e, 'b', 'fail', ctx)).enc; + e = (await deathSave(e, 'b', 'fail', ctx)).enc; + + e = await nextTurn(e, ctx); + + expect(e.currentTurnParticipantId).toBe('b'); + expect(byId(e, 'b').status).toBe('dead'); }); - // D1 unification: shared now matches App main — death flips isActive=false. - // Dead participant removed from active rotation (skipped by nextTurn). - test('dead PC auto-set isActive=false by applyHpChange', () => { - const ps = [pc('a', 20), pc('b', 15)]; - let e = enc(ps); - e = { ...e, ...startEncounter(e).patch }; - e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch }; - const b = e.participants.find(x => x.id === 'b'); - expect(b.isActive).toBe(false); - }); + test('deathSave rejected/no-op once dead', async () => { + const { ctx } = mockCtx(); + let e = enc([pc('a', 20), pc('b', 15)]); + e = await startEncounter(e, ctx); + e = await applyHpChange(e, 'b', 'damage', 100, ctx); + e = (await deathSave(e, 'b', 'fail', ctx)).enc; + e = (await deathSave(e, 'b', 'fail', ctx)).enc; + e = (await deathSave(e, 'b', 'fail', ctx)).enc; + const before = byId(e, 'b'); - test('revive (heal from 0) reactivates participant', () => { - const ps = [pc('a', 20), pc('b', 15)]; - let e = enc(ps); - e = { ...e, ...startEncounter(e).patch }; - e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch }; - const dead = e.participants.find(x => x.id === 'b'); - expect(dead.isActive).toBe(false); - // heal b back up - e = { ...e, ...applyHpChange(e, 'b', 'heal', 50).patch }; - const revived = e.participants.find(x => x.id === 'b'); - expect(revived.isActive).toBe(true); - expect(revived.currentHp).toBe(50); - expect(revived.deathSaves).toBe(0); + try { e = (await deathSave(e, 'b', 'fail', ctx)).enc; } catch (err) {} + + expect(byId(e, 'b')).toMatchObject(before); }); }); diff --git a/shared/tests/turn.deathsave.test.js b/shared/tests/turn.deathsave.test.js index 7ae1d75..71f6ee1 100644 --- a/shared/tests/turn.deathsave.test.js +++ b/shared/tests/turn.deathsave.test.js @@ -1,90 +1,334 @@ -// Death saves: broken model. Current = single counter, 3=dying. D&D 5e needs -// separate success/fail tracking: -// 3 successes = stable (0hp unconscious, safe until healed) -// 3 failures = dead -// Current tracks "saves" (really fails via red X UI), no successes at all. -// "Stable" state doesn't exist. -// -// RED: prove current can't model 3-success stability. - 'use strict'; -const shared = require('@ttrpg/shared'); -const { makeParticipant, startEncounter, applyHpChange, deathSave } = shared; +const { + makeParticipant, + startEncounter, + applyHpChange, + deathSave, + stabilizeParticipant, + reviveParticipant, +} = require('@ttrpg/shared'); +const { mockCtx } = require('./_helpers'); -function p(id) { - return makeParticipant({ id, name: id, type: 'character', - initiative: 10, maxHp: 100, currentHp: 100 }); +function pc(id, extra = {}) { + return makeParticipant({ + id, + name: id, + type: 'character', + initiative: 10, + maxHp: 100, + currentHp: 100, + ...extra, + }); } -function enc(ps, extra = {}) { - return { name:'t', participants:ps, isStarted:false, isPaused:false, - round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra }; +function monster(id, extra = {}) { + return makeParticipant({ + id, + name: id, + type: 'monster', + initiative: 10, + maxHp: 100, + currentHp: 100, + ...extra, + }); } -const apply = (e, r) => r && r.patch ? { ...e, ...r.patch } : e; -describe('death saves: missing success/stable model', () => { - test('3 successes makes character stable (not dead, not dying)', () => { - // Character at 0hp. Roll 3 successful death saves. - let e = enc([p('a')]); - e = apply(e, startEncounter(e)); - e = apply(e, applyHpChange(e, 'a', 'damage', 100)); // 0hp - expect(e.participants[0].currentHp).toBe(0); +function enc(participants, extra = {}) { + return { + id: 'e1', + name: 'death-save-test', + participants, + isStarted: false, + isPaused: false, + round: 0, + currentTurnParticipantId: null, + turnOrderIds: [], + ...extra, + }; +} - // Roll 3 successful saves. Should mark stable. - e = apply(e, deathSave(e, 'a', 'success', 1)); - e = apply(e, deathSave(e, 'a', 'success', 2)); - const r3 = deathSave(e, 'a', 'success', 3); - e = apply(e, r3); +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 }; +} - // RED: current deathSave signature = (enc, id, saveNumber). No type arg. - // Will throw or misbehave. Want: status field. - expect(r3.status).toBe('stable'); - expect(e.participants[0].isDying).toBe(false); - expect(e.participants[0].isStable).toBe(true); - expect(e.participants[0].deathFails).toBe(0); +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('3 failures makes character dead (isDying for removal)', () => { - let e = enc([p('a')]); - e = apply(e, startEncounter(e)); - e = apply(e, applyHpChange(e, 'a', 'damage', 100)); + 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]; - const r1 = deathSave(e, 'a', 'fail', 1); - e = apply(e, r1); - const r2 = deathSave(e, 'a', 'fail', 2); - e = apply(e, r2); - const r3 = deathSave(e, 'a', 'fail', 3); - e = apply(e, r3); + e = await applyHpChange(e, 'a', 'damage', 100, ctx); - expect(r3.status).toBe('dead'); - expect(e.participants[0].isDying).toBe(true); - expect(e.participants[0].deathFails).toBe(3); + 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('successes and fails tracked independently', () => { - // Mixed: 1 success, 2 fails, then 2 more successes = stable. - let e = enc([p('a')]); - e = apply(e, startEncounter(e)); - e = apply(e, applyHpChange(e, 'a', 'damage', 100)); + 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 = apply(e, deathSave(e, 'a', 'success', 1)); - expect(e.participants[0].deathSaves).toBe(1); - expect(e.participants[0].deathFails).toBe(0); + e = await applyHpChange(e, 'a', 'damage', 1, ctx); - e = apply(e, deathSave(e, 'a', 'fail', 1)); - e = apply(e, deathSave(e, 'a', 'fail', 2)); - expect(e.participants[0].deathSaves).toBe(1); - expect(e.participants[0].deathFails).toBe(2); - - // 2 more successes → total 3 successes → stable - e = apply(e, deathSave(e, 'a', 'success', 2)); - const r = deathSave(e, 'a', 'success', 3); - expect(r.status).toBe('stable'); - expect(e.participants[0].deathFails).toBe(2); // fails unchanged + expect(part(e).status).toBe('dead'); + expect(part(e).isActive).toBe(false); }); - test('deathSave signature: (enc, id, type, n)', () => { - // type = 'success' | 'fail'. n = 1|2|3. - expect(deathSave.length).toBe(4); + 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); }); }); diff --git a/shared/tests/turn.deathsave.undo.test.js b/shared/tests/turn.deathsave.undo.test.js new file mode 100644 index 0000000..7b2a7fb --- /dev/null +++ b/shared/tests/turn.deathsave.undo.test.js @@ -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); + }); +}); diff --git a/shared/tests/turn.dry.test.js b/shared/tests/turn.dry.test.js index 1438dca..80b30ab 100644 --- a/shared/tests/turn.dry.test.js +++ b/shared/tests/turn.dry.test.js @@ -1,11 +1,12 @@ -// DRY guard (BUG-5 fix): nextTurn and computeTurnOrderAfterRemoval share one -// advance core (nextActiveAfter). Both must pick the SAME next-active target -// for identical state. If this goes RED, the two paths drifted. +// Toggle-active semantics guard. +// Design: toggle active is a status edit, NOT a turn action. +// Deactivating current does not advance turn or increment round. DM clicks +// Next Turn explicitly; Next Turn skips inactive participants. 'use strict'; -const shared = require('@ttrpg/shared'); -const { makeParticipant, startEncounter, nextTurn, toggleParticipantActive } = shared; +const { makeParticipant, nextTurn, toggleParticipantActive } = require('@ttrpg/shared'); +const { mockCtx } = require('./_helpers'); function p(id, init, extra = {}) { return makeParticipant({ id, name: id, type: 'monster', @@ -16,37 +17,36 @@ function enc(ps, extra = {}) { round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra }; } -describe('DRY: deact-current advance == nextTurn advance', () => { - test('mid-round: same target (not current)', () => { - // order a,b,c. a current. nextTurn → b. deact a → advance → b. - const e = enc([p('a',10),p('b',5),p('c',3)], { isStarted:true, +describe('toggle-active is not a turn advance', () => { + test('mid-round: deactivating current leaves current unchanged', async () => { + const { ctx } = mockCtx(); + const e = enc([p('a',10),p('b',5),p('c',3)], { isStarted:true, round:1, turnOrderIds:['a','b','c'], currentTurnParticipantId:'a' }); - const nt = nextTurn(e).patch.currentTurnParticipantId; - 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); + const deact = await toggleParticipantActive(e, 'a', ctx); expect(deact.currentTurnParticipantId).toBe('a'); - expect(deact.round).toBe(nt.round); - expect(deact.round).toBe(3); + expect(deact.round).toBe(1); + expect(deact.participants.find(p => p.id === 'a').isActive).toBe(false); + }); + + test('nextTurn after deactivating current skips inactive participant', async () => { + const { ctx } = mockCtx(); + const e = enc([p('a',10),p('b',5),p('c',3)], { isStarted:true, round:1, + turnOrderIds:['a','b','c'], currentTurnParticipantId:'a' }); + const deact = await toggleParticipantActive(e, 'a', ctx); + const next = await nextTurn(deact, ctx); + expect(next.currentTurnParticipantId).toBe('b'); + expect(next.round).toBe(1); + }); + + test('wrap: deactivating current does not increment round; nextTurn does', async () => { + const { ctx } = mockCtx(); + const e = enc([p('a',10),p('b',5),p('c',3)], { isStarted:true, round:2, + turnOrderIds:['a','b','c'], currentTurnParticipantId:'c' }); + const deact = await toggleParticipantActive(e, 'c', ctx); + expect(deact.currentTurnParticipantId).toBe('c'); + expect(deact.round).toBe(2); + const next = await nextTurn(deact, ctx); + expect(next.currentTurnParticipantId).toBe('a'); + expect(next.round).toBe(3); }); }); diff --git a/shared/tests/turn.invariant.test.js b/shared/tests/turn.invariant.test.js index 7a36428..b32b3e6 100644 --- a/shared/tests/turn.invariant.test.js +++ b/shared/tests/turn.invariant.test.js @@ -5,6 +5,10 @@ // // RED now: current code has two lists (sort on display, frozen turnOrderIds), // reorder throws on cross-init. Refactor (1-list model) greens these. +// +// New API: mutating funcs are async, take ctx last, write encounter + log +// internally, return newEnc. Chained calls become `e = await fn(e, ctx)`. +// No-ops (reorder blocked) return the same enc ref. 'use strict'; @@ -15,6 +19,7 @@ const { toggleParticipantActive, togglePause, applyHpChange, reorderParticipants, endEncounter, } = shared; +const { mockCtx } = require('./_helpers'); function p(id, init, extra = {}) { return makeParticipant({ id, name: id, type: 'monster', @@ -24,104 +29,97 @@ function enc(ps, extra = {}) { return { name:'t', participants:ps, isStarted:false, isPaused:false, round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra }; } -const apply = (e, r) => r && r.patch ? { ...e, ...r.patch } : e; - -// 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', () => { - 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)]); - e = apply(e, startEncounter(e)); + e = await startEncounter(e, ctx); expect(e.turnOrderIds).toEqual(['b','c','a']); // 10,5,3 expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id)); }); - test('startEncounter: inactive stays in list slot (skipped by nextTurn)', () => { + test('startEncounter: inactive stays in list slot (skipped by nextTurn)', async () => { + const { ctx } = mockCtx(); let e = enc([p('a',10),p('b',5,{isActive:false}),p('c',3)]); - e = apply(e, startEncounter(e)); + e = await startEncounter(e, ctx); // 1-list: inactive b stays in slot, nextTurn skips it expect(e.turnOrderIds).toEqual(['a','b','c']); expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id)); expect(e.currentTurnParticipantId).toBe('a'); // b inactive, skipped on start }); - test('addParticipant mid-encounter: inserted by init, list synced', () => { + test('addParticipant mid-encounter: inserted by init, list synced', async () => { + const { ctx } = mockCtx(); let e = enc([p('a',10),p('c',3)]); - e = apply(e, startEncounter(e)); // [a,c] - e = apply(e, addParticipant(e, p('b',7))); // insert between a,c + e = await startEncounter(e, ctx); // [a,c] + e = await addParticipant(e, p('b',7), ctx); // insert between a,c expect(e.turnOrderIds).toEqual(['a','b','c']); expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id)); }); - test('addParticipant: list === participants.map(id) after add', () => { + test('addParticipant: list === participants.map(id) after add', async () => { + const { ctx } = mockCtx(); let e = enc([p('a',10)]); - e = apply(e, startEncounter(e)); - e = apply(e, addParticipant(e, p('b',5))); + e = await startEncounter(e, ctx); + e = await addParticipant(e, p('b',5), ctx); expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id)); }); - test('removeParticipant: list synced, order preserved', () => { + test('removeParticipant: list synced, order preserved', async () => { + const { ctx } = mockCtx(); let e = enc([p('a',10),p('b',7),p('c',3)]); - e = apply(e, startEncounter(e)); - e = apply(e, removeParticipant(e, 'b')); + e = await startEncounter(e, ctx); + e = await removeParticipant(e, 'b', ctx); expect(e.turnOrderIds).toEqual(['a','c']); expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id)); }); - test('reorder cross-init: blocked (no-op)', () => { + test('reorder cross-init: blocked (no-op)', async () => { + const { ctx } = mockCtx(); let e = enc([p('a',10),p('b',7),p('c',3)]); - e = apply(e, startEncounter(e)); // [a,b,c] - const r = reorderParticipants(e, 'c', 'a'); // cross-init - expect(r.patch).toBeNull(); + e = await startEncounter(e, ctx); // [a,b,c] + const newEnc = await reorderParticipants(e, 'c', 'a', ctx); // cross-init + expect(newEnc).toBe(e); // same ref = no write expect(e.turnOrderIds).toEqual(['a','b','c']); // unchanged }); - test('reorder same-init: within upcoming side of pointer follows new order', () => { + test('reorder same-init: within upcoming side of pointer follows new order', async () => { + const { ctx } = mockCtx(); let e = enc([p('a',10),p('b',10),p('c',10),p('d',3)]); // a,b,c tie - e = apply(e, startEncounter(e)); // [a,b,c,d], cur=a (idx0) + e = await startEncounter(e, ctx); // [a,b,c,d], cur=a (idx0) // Reorder c before b (both upcoming idx1,2). Within same side = allowed. - e = apply(e, reorderParticipants(e, 'c', 'b')); // [a,c,b,d], cur=a + e = await reorderParticipants(e, 'c', 'b', ctx); // [a,c,b,d], cur=a expect(e.turnOrderIds).toEqual(['a','c','b','d']); // walk: a current, next c, next b, next d, wrap a - e = apply(e, nextTurn(e)); + e = await nextTurn(e, ctx); expect(e.currentTurnParticipantId).toBe('c'); }); - test('toggle inactive: list unchanged (stays in rotation slot)', () => { + test('toggle inactive: list unchanged (stays in rotation slot)', async () => { + const { ctx } = mockCtx(); let e = enc([p('a',10),p('b',7),p('c',3)]); - e = apply(e, startEncounter(e)); // [a,b,c] - e = apply(e, toggleParticipantActive(e, 'b')); // b off + e = await startEncounter(e, ctx); // [a,b,c] + e = await toggleParticipantActive(e, 'b', ctx); // b off expect(e.turnOrderIds).toEqual(['a','b','c']); // b stays in slot }); - test('toggle inactive: nextTurn skips b, visits a→c', () => { + 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 = apply(e, startEncounter(e)); // cur=a - e = apply(e, toggleParticipantActive(e, 'b')); // b inactive - e = apply(e, nextTurn(e)); // skip b → c + 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', () => { + test('hp death + revive: list unchanged', async () => { + const { ctx } = mockCtx(); let e = enc([p('a',10),p('b',7),p('c',3)]); - e = apply(e, startEncounter(e)); - e = apply(e, applyHpChange(e, 'b', 'damage', 100)); // b dies + e = await startEncounter(e, ctx); + e = await applyHpChange(e, 'b', 'damage', 100, ctx); // b dies 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(e.participants.map(x => x.id)); }); diff --git a/shared/tests/turn.logging.test.js b/shared/tests/turn.logging.test.js index 15793af..768d747 100644 --- a/shared/tests/turn.logging.test.js +++ b/shared/tests/turn.logging.test.js @@ -1,203 +1,248 @@ -// Logging contract: every mutating combat op returns { patch, log } where -// log = { message: string, undo: object|null }. No-op (no state change) -// returns { patch: null, log: null }. Display lifecycle ops return { patch } -// only (no log field expected). +// 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. // -// logAction handler does: -// if (log) logAction(log.message, ctx, { updates: log.undo }) -// → null log = skipped = gap in audit trail. +// 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(). // -// Contract = every combat mutation MUST produce a log entry. +// 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, buildMonsterParticipant, + makeParticipant, startEncounter, nextTurn, togglePause, addParticipant, addParticipants, updateParticipant, removeParticipant, toggleParticipantActive, applyHpChange, deathSave, toggleCondition, reorderParticipants, endEncounter, } = shared; -function p(id, init) { +function p(id, init, extra = {}) { return makeParticipant({ id, name: id, type: 'monster', - initiative: init, maxHp: 100, currentHp: 100 }); + 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 }; } -const apply = (e, r) => r && r.patch ? { ...e, ...r.patch } : e; -// Helper: assert a result is a logged mutation (patch + valid log). -function expectLogged(r) { - expect(r.patch).not.toBeNull(); - expect(r.log).not.toBeNull(); - expect(typeof r.log).toBe('object'); - expect(typeof r.log.message).toBe('string'); - expect(r.log.message.length).toBeGreaterThan(0); -} -function expectNoOp(r) { - expect(r.patch).toBeNull(); - expect(r.log).toBeNull(); +// 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 e; + let storage, ctx; beforeEach(() => { - e = enc([p('a', 10), p('b', 7), p('c', 3)]); + ({ storage, ctx } = mockCtx()); }); - test('startEncounter logs', () => { - const r = startEncounter(e); - expectLogged(r); + 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', () => { - const started = apply(e, startEncounter(e)); - const r = nextTurn(started); - expectLogged(r); + 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 logs', () => { - const r = togglePause(apply(e, startEncounter(e))); - expectLogged(r); + 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', () => { - const r = addParticipant(e, p('d', 5)); - expectLogged(r); + 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', () => { - const r = removeParticipant(e, 'b'); - expectLogged(r); + 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', () => { - const r = toggleParticipantActive(e, 'b'); - expectLogged(r); + 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', () => { - const r = applyHpChange(e, 'b', 'damage', 10); - expectLogged(r); + 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', () => { - const r = applyHpChange(e, 'b', 'heal', 5); - expectLogged(r); + 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', () => { - const started = apply(e, startEncounter(e)); - const dying = apply(started, applyHpChange(started, 'b', 'damage', 100)); - const r = deathSave(dying, 'b', 'fail', 1); - expectLogged(r); + test('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', () => { - const r = toggleCondition(e, 'b', 'poisoned'); - expectLogged(r); + 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)', () => { - // same-init tie (both 10) for valid reorder + 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)]); - const r = reorderParticipants(e2, 'x', 'a'); - expectLogged(r); + await reorderParticipants(e2, 'x', 'a', ctx); + expect(storage.logs()).toHaveLength(1); + expectLastLogged(storage); }); - test('endEncounter logs', () => { - const started = apply(e, startEncounter(e)); - const r = endEncounter(started); - expectLogged(r); + 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 e; + let storage, ctx; beforeEach(() => { - e = enc([p('a', 10), p('b', 7), p('c', 3)]); + ({ storage, ctx } = mockCtx()); }); - test('reorder same-id = no-op', () => { - expectNoOp(reorderParticipants(e, 'a', 'a')); + 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', () => { - expectNoOp(reorderParticipants(e, 'a', 'b')); + 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 e; + let storage, ctx; beforeEach(() => { - e = enc([p('a', 10), p('b', 7), p('c', 3)]); + ({ storage, ctx } = mockCtx()); }); - test('startEncounter undo restores pre-combat state', () => { - const r = startEncounter(e); - expect(r.log.undo).toBeDefined(); - expect(r.log.undo.isStarted).toBe(false); + 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', () => { - const started = apply(e, startEncounter(e)); - const r = endEncounter(started); - expect(r.log.undo).toBeDefined(); - expect(r.log.undo.isStarted).toBe(true); + 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', () => { - const r = applyHpChange(e, 'b', 'damage', 10); - expect(r.log.undo).toBeDefined(); - expect(r.log.undo.participants).toBeDefined(); + 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)', () => { + 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 r = reorderParticipants(e2, 'x', 'a'); - expect(r.log.undo).toBeDefined(); - const restored = { ...e2, ...r.log.undo }; + 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 e; + let storage, ctx; beforeEach(() => { - e = enc([p('a', 10), p('b', 7)]); + ({ storage, ctx } = mockCtx()); }); - test('addParticipants logs', () => { - const r = addParticipants(e, [p('c', 3)]); - expectLogged(r); + 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', () => { - const r = updateParticipant(e, 'b', { name: 'B' }); - expectLogged(r); + 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', () => { - const r = updateParticipant(e, 'b', { initiative: 5 }); - expectLogged(r); + 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', () => { + 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 r = addParticipants(e, [p('c', 3)]); - const restored = { ...e, ...r.log.undo }; + 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', () => { + 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 r = updateParticipant(e, 'b', { name: 'B' }); - const restored = { ...e, ...r.log.undo }; + 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); }); }); diff --git a/shared/tests/turn.pause-add.test.js b/shared/tests/turn.pause-add.test.js index 88c4eba..ba56c26 100644 --- a/shared/tests/turn.pause-add.test.js +++ b/shared/tests/turn.pause-add.test.js @@ -7,8 +7,11 @@ // self-inflicted dup (loop spun while paused, re-added same `r${totalTurns}`). // Validates real bug reachable via normal UI flow (DM adds monster while paused, // resumes). +// +// Rewritten for async turn.js API: funcs are awaited, take ctx, write own logs. const shared = require('@ttrpg/shared'); +const { mockCtx } = require('./_helpers'); const { startEncounter, nextTurn, togglePause, addParticipant, makeParticipant } = shared; function p(id, initiative, extra = {}) { @@ -28,24 +31,25 @@ function enc(ps) { } 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)]; let e = enc(ps); - e = { ...e, ...startEncounter(e).patch }; + e = await startEncounter(e, ctx); const baseOrder = e.turnOrderIds.slice(); // [a,b,c] - e = { ...e, ...nextTurn(e).patch }; // current=b - e = { ...e, ...togglePause(e).patch }; // pause + e = await nextTurn(e, ctx); // current=b + e = await togglePause(e, ctx); // pause // add fresh participant x (initiative 25, would sort first) const x = p('x', 25); - e = { ...e, ...addParticipant(e, x).patch }; - e = { ...e, ...togglePause(e).patch }; // resume (rebuilds order) + e = await addParticipant(e, x, ctx); + e = await togglePause(e, ctx); // resume (rebuilds order) // after resume, complete one full round: visit each active participant once const visited = [e.currentTurnParticipantId]; for (let i = 0; i < e.turnOrderIds.length - 1; i++) { - e = { ...e, ...nextTurn(e).patch }; + e = await nextTurn(e, ctx); visited.push(e.currentTurnParticipantId); } const uniq = new Set(visited); @@ -53,24 +57,25 @@ describe('addParticipant + pause/resume rotation corruption', () => { 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)]; let e = enc(ps); - e = { ...e, ...startEncounter(e).patch }; + e = await startEncounter(e, ctx); - e = { ...e, ...nextTurn(e).patch }; // current=b - e = { ...e, ...togglePause(e).patch }; // pause + e = await nextTurn(e, ctx); // current=b + e = await togglePause(e, ctx); // pause // add 3 fresh participants for (const id of ['x', 'y', 'z']) { const np = p(id, 5 + Math.floor(Math.random() * 30)); - e = { ...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]; for (let i = 0; i < e.turnOrderIds.length + 2; i++) { - e = { ...e, ...nextTurn(e).patch }; + e = await nextTurn(e, ctx); visited.push(e.currentTurnParticipantId); } const uniq = new Set(visited); @@ -78,20 +83,21 @@ describe('addParticipant + pause/resume rotation corruption', () => { 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)]; 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); - e = { ...e, ...addParticipant(e, x).patch }; // add while running - e = { ...e, ...togglePause(e).patch }; // pause - e = { ...e, ...togglePause(e).patch }; // resume + e = await addParticipant(e, x, ctx); // add while running + e = await togglePause(e, ctx); // pause + e = await togglePause(e, ctx); // resume const visited = [e.currentTurnParticipantId]; for (let i = 0; i < e.turnOrderIds.length + 2; i++) { - e = { ...e, ...nextTurn(e).patch }; + e = await nextTurn(e, ctx); visited.push(e.currentTurnParticipantId); } const uniq = new Set(visited); diff --git a/shared/tests/turn.remove.test.js b/shared/tests/turn.remove.test.js index 21d0392..75d4bba 100644 --- a/shared/tests/turn.remove.test.js +++ b/shared/tests/turn.remove.test.js @@ -1,7 +1,11 @@ // removeParticipant + computeTurnOrderAfterRemoval edge cases. +// +// New API: mutating funcs are async, take ctx last, write encounter + log +// internally, return newEnc. Chained calls become `e = await fn(e, ctx)`. const shared = require('@ttrpg/shared'); const { makeParticipant, startEncounter, nextTurn, removeParticipant, toggleParticipantActive, applyHpChange } = shared; +const { mockCtx } = require('./_helpers'); function p(id, init, extra = {}) { return makeParticipant({ id, name: id, type: 'monster', @@ -13,51 +17,56 @@ function enc(ps) { } 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)]); - e = { ...e, ...startEncounter(e).patch }; - e = { ...e, ...removeParticipant(e, 'a').patch }; // a was current + e = await startEncounter(e, ctx); + e = await removeParticipant(e, 'a', ctx); // a was current expect(e.currentTurnParticipantId).toBe('b'); }); - test('removing last in order wraps current to first', () => { + test('removing last in order wraps current to first', async () => { + const { ctx } = mockCtx(); let e = enc([p('a',20),p('b',15),p('c',10)]); - e = { ...e, ...startEncounter(e).patch }; - e = { ...e, ...nextTurn(e).patch }; // b - e = { ...e, ...nextTurn(e).patch }; // c (current) - e = { ...e, ...removeParticipant(e, 'c').patch }; + e = await startEncounter(e, ctx); + e = await nextTurn(e, ctx); // b + e = await nextTurn(e, ctx); // c (current) + e = await removeParticipant(e, 'c', ctx); expect(e.currentTurnParticipantId).toBe('a'); }); - test('removing current when all others inactive → no active, isStarted stays (BUG-9 candidate)', () => { + test('removing current when all others inactive → no active, isStarted stays (BUG-9 candidate)', async () => { + const { ctx } = mockCtx(); let e = enc([p('a',20),p('b',15),p('c',10)]); - e = { ...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) - e = { ...e, ...toggleParticipantActive(e, 'b').patch }; - e = { ...e, ...toggleParticipantActive(e, 'c').patch }; + e = await toggleParticipantActive(e, 'b', ctx); + e = await toggleParticipantActive(e, 'c', ctx); // 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 expect(e.turnOrderIds).toEqual(['b', 'c']); expect(e.currentTurnParticipantId).toBeNull(); // isStarted still true but no turn → nextTurn throws (stale state) }); - test('removing non-current keeps currentTurn', () => { + test('removing non-current keeps currentTurn', async () => { + const { ctx } = mockCtx(); let e = enc([p('a',20),p('b',15),p('c',10)]); - e = { ...e, ...startEncounter(e).patch }; - e = { ...e, ...removeParticipant(e, 'b').patch }; + e = await startEncounter(e, ctx); + e = await removeParticipant(e, 'b', ctx); expect(e.currentTurnParticipantId).toBe('a'); expect(e.turnOrderIds).toEqual(['a', 'c']); }); - test('removing current that is dead (HP=0) - BUG-3 overlap', () => { + test('removing current that is dead (HP=0) - BUG-3 overlap', async () => { // Dead participant removed mid-combat. Desired (M4): they STAY in order. // removeParticipant is explicit DM action, distinct from auto-skip. + const { ctx } = mockCtx(); let e = enc([p('a',20),p('b',15),p('c',10)]); - e = { ...e, ...startEncounter(e).patch }; - e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch }; // b dead - e = { ...e, ...removeParticipant(e, 'b').patch }; + e = await startEncounter(e, ctx); + e = await applyHpChange(e, 'b', 'damage', 100, ctx); // b dead + e = await removeParticipant(e, 'b', ctx); expect(e.turnOrderIds).not.toContain('b'); expect(e.participants.find(x => x.id === 'b')).toBeUndefined(); }); diff --git a/shared/tests/turn.reorder.test.js b/shared/tests/turn.reorder.test.js index 933e0fa..6a5aef4 100644 --- a/shared/tests/turn.reorder.test.js +++ b/shared/tests/turn.reorder.test.js @@ -1,9 +1,14 @@ // Characterization for reorderParticipants correct usage. // replay-combat.js calls it with wrong signature (swallowed by try/catch), // so real behavior untested. Lock what it actually does. +// +// New API: reorderParticipants is async, takes ctx last, writes encounter + +// log internally, returns newEnc. A blocked move (cross-init / same id / +// cross-pointer) returns the SAME enc ref (no write). Missing id rejects. const shared = require('@ttrpg/shared'); const { makeParticipant, startEncounter, nextTurn, reorderParticipants } = shared; +const { mockCtx } = require('./_helpers'); function p(id, init, extra = {}) { return makeParticipant({ @@ -18,37 +23,65 @@ function enc(ps) { } describe('reorderParticipants', () => { - test('drag before target (1-list model, pre-combat)', () => { + test('drag upward inserts before target (1-list model, pre-combat)', async () => { + const { ctx } = mockCtx(); const ps = [p('a', 10), p('b', 20), p('c', 20)]; // b,c tie let e = enc(ps); // pre-combat, no pointer - const r = reorderParticipants(e, 'c', 'b'); - // drag c before b: remove c → [a,b], insert before b → [a,c,b] - expect(r.patch.participants.map(p => p.id)).toEqual(['a', 'c', 'b']); + const newEnc = await reorderParticipants(e, 'c', 'b', ctx); + // drag c upward onto b: insert before b → [a,c,b] + expect(newEnc.participants.map(p => p.id)).toEqual(['a', 'c', 'b']); }); - test('cross-init drag blocked (no-op)', () => { + test('drag downward inserts after target (1-list model, pre-combat)', async () => { + const { ctx } = mockCtx(); + const ps = [p('a', 20), p('b', 20), p('c', 10)]; // a,b tie + let e = enc(ps); // pre-combat, no pointer + const newEnc = await reorderParticipants(e, 'a', 'b', ctx); + // drag a downward onto b: insert after b → [b,a,c] + expect(newEnc.participants.map(p => p.id)).toEqual(['b', 'a', 'c']); + }); + + test('cross-init drag blocked (no-op)', async () => { + const { storage, ctx } = mockCtx(); const ps = [p('a', 10), p('b', 20)]; let e = enc(ps); - e = { ...e, ...startEncounter(e).patch }; // [b,a] - const r = reorderParticipants(e, 'a', 'b'); - expect(r.patch).toBeNull(); + e = await startEncounter(e, ctx); // [b,a] + const before = storage.calls.filter(c => c.fn === 'updateDoc').length; + const newEnc = await reorderParticipants(e, 'a', 'b', ctx); + expect(newEnc).toBe(e); // same ref = no write + expect(storage.calls.filter(c => c.fn === 'updateDoc')).toHaveLength(before); // no new write }); - test('throws if id not found', () => { + test('throws if id not found', async () => { + const { ctx } = mockCtx(); const ps = [p('a', 10), p('b', 20)]; let e = enc(ps); - e = { ...e, ...startEncounter(e).patch }; - expect(() => reorderParticipants(e, 'a', 'zzz')).toThrow(); + e = await startEncounter(e, ctx); + 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)]; let e = enc(ps); - e = { ...e, ...startEncounter(e).patch }; // started, but reorder pre-pointer advance + e = await startEncounter(e, ctx); // started, but reorder pre-pointer advance // startEncounter sets current=b (idx0). reorder c before b crosses pointer. // Use pre-combat to test syncTurnOrder. let pre = enc(ps); - pre = { ...pre, ...reorderParticipants(pre, 'c', 'b').patch }; + pre = await reorderParticipants(pre, 'c', 'b', ctx); expect(pre.turnOrderIds).toEqual(['a','c','b']); expect(pre.turnOrderIds).toEqual(pre.participants.map(p => p.id)); }); @@ -56,10 +89,11 @@ describe('reorderParticipants', () => { // BUG-6 candidate: reorder should affect turnOrderIds so mid-combat // drag-drop changes who goes next within same-initiative tie. // Currently RED (turnOrderIds not in patch). - test('reorder updates turnOrderIds to reflect new participant order (pre-combat)', () => { + test('reorder updates turnOrderIds to reflect new participant order (pre-combat)', async () => { + const { ctx } = mockCtx(); const ps = [p('a', 10), p('b', 20), p('c', 20)]; let e = enc(ps); // pre-combat, no pointer - e = { ...e, ...reorderParticipants(e, 'c', 'b').patch }; + e = await reorderParticipants(e, 'c', 'b', ctx); expect(e.turnOrderIds).toEqual(['a', 'c', 'b']); }); }); diff --git a/shared/tests/turn.round-rotation.test.js b/shared/tests/turn.round-rotation.test.js index b12d586..8d01261 100644 --- a/shared/tests/turn.round-rotation.test.js +++ b/shared/tests/turn.round-rotation.test.js @@ -2,9 +2,10 @@ // 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. // -// 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 { mockCtx } = require('./_helpers'); const { startEncounter, nextTurn, makeParticipant } = shared; function p(id, initiative, extra = {}) { @@ -24,17 +25,18 @@ function enc(ps) { } 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)]; let e = enc(ps); - e = { ...e, ...startEncounter(e).patch }; + e = await startEncounter(e, ctx); const startOrder = e.turnOrderIds.slice(); const visited = [e.currentTurnParticipantId]; // advance (len-1) turns: visits remaining participants, round NOT yet wrapped. for (let i = 0; i < startOrder.length - 1; i++) { - e = { ...e, ...nextTurn(e).patch }; + e = await nextTurn(e, ctx); visited.push(e.currentTurnParticipantId); } @@ -44,18 +46,19 @@ describe('round rotation integrity', () => { 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 = [ p('Goblin1', 12), p('Wolf', 13), p('Merchant', 8), p('OrcBoss', 11), p('Goblin2', 12), p('Fighter', 14), p('Rogue', 15), p('Cleric', 10), ]; let e = enc(ps); - e = { ...e, ...startEncounter(e).patch }; + e = await startEncounter(e, ctx); const startOrder = e.turnOrderIds.slice(); const visited = [e.currentTurnParticipantId]; for (let i = 0; i < startOrder.length - 1; i++) { - e = { ...e, ...nextTurn(e).patch }; + e = await nextTurn(e, ctx); visited.push(e.currentTurnParticipantId); } @@ -65,10 +68,11 @@ describe('round rotation integrity', () => { 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)]; let e = enc(ps); - e = { ...e, ...startEncounter(e).patch }; + e = await startEncounter(e, ctx); const startOrder = e.turnOrderIds.slice(); const expectedRound = e.round; @@ -76,7 +80,7 @@ describe('round rotation integrity', () => { // capture exactly one full round (current + len-1 advances), no wrap yet. const visited = [e.currentTurnParticipantId]; for (let i = 0; i < startOrder.length - 1; i++) { - e = { ...e, ...nextTurn(e).patch }; + e = await nextTurn(e, ctx); visited.push(e.currentTurnParticipantId); } const uniq = new Set(visited); @@ -88,63 +92,65 @@ describe('round rotation integrity', () => { describe('round rotation with mid-round state changes', () => { const { toggleParticipantActive, addParticipant, removeParticipant, reorderParticipants, applyHpChange } = shared; - test('toggle a participant inactive mid-round, others still each visited once', () => { + test('toggle a participant inactive mid-round, others still each visited once', async () => { + const { ctx } = mockCtx(); const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)]; let e = enc(ps); - e = { ...e, ...startEncounter(e).patch }; + e = await startEncounter(e, ctx); const startOrder = e.turnOrderIds.slice(); 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) - e = { ...e, ...toggleParticipantActive(e, 'a').patch }; - e = { ...e, ...nextTurn(e).patch }; visited.push(e.currentTurnParticipantId); - e = { ...e, ...nextTurn(e).patch }; visited.push(e.currentTurnParticipantId); + e = await toggleParticipantActive(e, 'a', ctx); + e = await nextTurn(e, ctx); visited.push(e.currentTurnParticipantId); + e = await nextTurn(e, ctx); visited.push(e.currentTurnParticipantId); // round should wrap, but 'a' inactive so only b,c,d visited const visitedActive = visited.filter(id => id !== 'a'); const uniq = new Set(visitedActive); expect(uniq.size).toBe(startOrder.length - 1); // b,c,d each once }); - test('reactivate inactive participant mid-round, it gets a turn this round', () => { + test('reactivate inactive participant mid-round, it gets a turn this round', async () => { + const { ctx } = mockCtx(); const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)]; let e = enc(ps); // start with 'c' inactive e.participants = e.participants.map(p => p.id === 'c' ? { ...p, isActive: false } : p); - e = { ...e, ...startEncounter(e).patch }; + e = await startEncounter(e, ctx); // 1-list: c stays in slot (inactive), skipped by nextTurn expect(e.turnOrderIds).toEqual(['a', 'b', 'c', 'd']); expect(e.currentTurnParticipantId).toBe('a'); // c inactive, a first // advance one turn, then reactivate c - e = { ...e, ...nextTurn(e).patch }; // b - e = { ...e, ...toggleParticipantActive(e, 'c').patch }; + e = await nextTurn(e, ctx); // b + e = await toggleParticipantActive(e, 'c', ctx); // continue rotation - c should now be reachable const visited = [e.currentTurnParticipantId]; for (let i = 0; i < e.turnOrderIds.length; i++) { - e = { ...e, ...nextTurn(e).patch }; + e = await nextTurn(e, ctx); visited.push(e.currentTurnParticipantId); } expect(visited).toContain('c'); }); - test('addParticipant mid-round: new participant gets turn this round or next', () => { + test('addParticipant mid-round: new participant gets turn this round or next', async () => { + const { ctx } = mockCtx(); const ps = [p('a', 20), p('b', 15), p('c', 10)]; let e = enc(ps); - e = { ...e, ...startEncounter(e).patch }; + e = await startEncounter(e, ctx); const startOrder = e.turnOrderIds.slice(); - e = { ...e, ...nextTurn(e).patch }; // advance one + e = await nextTurn(e, ctx); // advance one // add new participant 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 const visited = [startOrder[0], e.currentTurnParticipantId]; while (e.round === 1) { - const r = nextTurn(e); - e = { ...e, ...r.patch }; + e = await nextTurn(e, ctx); visited.push(e.currentTurnParticipantId); if (visited.length > 20) break; // safety } @@ -153,23 +159,23 @@ describe('round rotation with mid-round state changes', () => { 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) let e = enc(ps); - e = { ...e, ...startEncounter(e).patch }; + e = await startEncounter(e, ctx); const startOrder = e.turnOrderIds.slice(); - e = { ...e, ...nextTurn(e).patch }; + e = await nextTurn(e, ctx); // 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]; for (let i = 0; i < startOrder.length; i++) { - e = { ...e, ...nextTurn(e).patch }; + e = await nextTurn(e, ctx); visited.push(e.currentTurnParticipantId); } const uniq = new Set(visited); expect(uniq.size).toBeGreaterThanOrEqual(startOrder.length); }); }); - diff --git a/shared/tests/turn.skip.test.js b/shared/tests/turn.skip.test.js index 97ee59e..8f77ecd 100644 --- a/shared/tests/turn.skip.test.js +++ b/shared/tests/turn.skip.test.js @@ -4,6 +4,10 @@ // // Guards BUG-5 fix (slot-array turn order, no re-sort on wrap/resume). // If this goes RED, turn order rotation is skipping participants again. +// +// New API: mutating funcs are async, take ctx last, write encounter + log +// internally, return newEnc. setup(ctx) returns the started encounter; +// loop bodies await each mutating call. 'use strict'; @@ -13,14 +17,14 @@ const { startEncounter, nextTurn, togglePause, addParticipant, removeParticipant, toggleParticipantActive, } = shared; +const { mockCtx } = require('./_helpers'); -const apply = (e, r) => (r && r.patch) ? { ...e, ...r.patch } : e; const nm = (enc) => (id) => { const f = enc.participants.find(p => p.id === id); return f ? f.name : id; }; -function setup() { +function setup(ctx) { const ps = [ buildCharacterParticipant({ id: 'c1', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 }).participant, buildCharacterParticipant({ id: 'c2', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 }).participant, @@ -29,20 +33,21 @@ function setup() { buildMonsterParticipant({ name: 'Goblin2', maxHp: 100, initMod: 2 }).participant, buildMonsterParticipant({ name: 'OrcBoss', maxHp: 500, initMod: 1 }).participant, buildMonsterParticipant({ name: 'Wolf', maxHp: 120, initMod: 3 }).participant, - buildMonsterParticipant({ name: 'Merchant', maxHp: 150, initMod: 0, isNpc: true }).participant, + buildMonsterParticipant({ name: 'Merchant', maxHp: 150, initMod: 0, asNpc: true }).participant, ]; let e = { name: 't', participants: ps, isStarted: false, isPaused: false, round: 0, currentTurnParticipantId: null, turnOrderIds: [], }; - return apply(e, startEncounter(e)); + return startEncounter(e, ctx); // async → Promise } describe('BUG-5: turn-order rotation never skips (deterministic)', () => { jest.setTimeout(15000); - test('pure nextTurn: 0 skips across 100 rounds', () => { - let e = setup(); + test('pure nextTurn: 0 skips across 100 rounds', async () => { + const { ctx } = mockCtx(); + let e = await setup(ctx); let totalSkips = 0; for (let roundN = 1; roundN <= 100; roundN++) { const startRound = e.round; @@ -52,7 +57,7 @@ describe('BUG-5: turn-order rotation never skips (deterministic)', () => { let guard = 0; const cap = e.participants.length + 1; while (e.round === startRound && guard < cap) { - e = apply(e, nextTurn(e)); + e = await nextTurn(e, ctx); if (e.round === startRound) acted.add(e.currentTurnParticipantId); guard++; } @@ -65,8 +70,9 @@ describe('BUG-5: turn-order rotation never skips (deterministic)', () => { expect(totalSkips).toBe(0); }); - test('with pause/resume + add/remove/toggle: 0 skips across ~540 rounds', () => { - let e = setup(); + test('with pause/resume + add/remove/toggle: 0 skips across ~540 rounds', async () => { + const { ctx } = mockCtx(); + let e = await setup(ctx); const N = nm(e); let curRound = null; let activeAtRoundStart = new Set(); @@ -85,10 +91,10 @@ describe('BUG-5: turn-order rotation never skips (deterministic)', () => { const MAX_TURNS = 2000; while (turns < MAX_TURNS && e.isStarted) { turns++; - if (e.isPaused) e = apply(e, togglePause(e)); - if (turns % 7 === 0 && !e.isPaused) { e = apply(e, togglePause(e)); continue; } + if (e.isPaused) e = await togglePause(e, ctx); + if (turns % 7 === 0 && !e.isPaused) { e = await togglePause(e, ctx); continue; } const prevRound = e.round; - e = apply(e, nextTurn(e)); + e = await nextTurn(e, ctx); if (e.round !== prevRound) { const skipped = [...activeAtRoundStart].filter(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) { const b = buildMonsterParticipant({ name: `R${added + 1}`, maxHp: 120, initMod: 3 }).participant; b.id = `reinforce${added + 1}`; - e = apply(e, addParticipant(e, b)); added++; + e = await addParticipant(e, b, ctx); added++; } if (turns % 13 === 0) { const cand = e.participants.filter(p => p.type === 'monster' && p.isActive && p.id !== e.currentTurnParticipantId); - if (cand.length) e = apply(e, removeParticipant(e, cand[0].id)); + if (cand.length) e = await removeParticipant(e, cand[0].id, ctx); } if (turns % 17 === 0) { const cand = e.participants.filter(p => p.isActive && p.id !== e.currentTurnParticipantId); if (cand.length) { const t = cand[0]; - e = apply(e, toggleParticipantActive(e, t.id)); - e = apply(e, toggleParticipantActive(e, t.id)); + e = await toggleParticipantActive(e, t.id, ctx); + e = await toggleParticipantActive(e, t.id, ctx); } } } diff --git a/shared/tests/turn.slot-not-sort.test.js b/shared/tests/turn.slot-not-sort.test.js index 4d3ec40..795283e 100644 --- a/shared/tests/turn.slot-not-sort.test.js +++ b/shared/tests/turn.slot-not-sort.test.js @@ -6,19 +6,20 @@ // "Tie-break = original add order. Later additions slot AFTER existing // same-init participants. Stable insertion." // -// Current code uses sortParticipantsByInitiative (stable sort, tie-break = -// original array index). That is NOT stable insertion: it re-sorts the whole -// list, destroying drag order when a drag moved a same-init pair. +// 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. // -// RED now: drag tie order survives neither add nor edit. +// New API: addParticipant / updateParticipant / reorderParticipants are async, +// take ctx, return newEnc. 'use strict'; -const shared = require('@ttrpg/shared'); const { makeParticipant, startEncounter, addParticipant, updateParticipant, reorderParticipants, -} = shared; +} = require('@ttrpg/shared'); +const { mockCtx } = require('./_helpers'); function p(id, init, extra = {}) { return makeParticipant({ id, name: id, type: 'monster', @@ -28,47 +29,50 @@ function enc(ps, extra = {}) { return { name:'t', participants:ps, isStarted:false, isPaused:false, round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra }; } -const apply = (e, r) => r && r.patch ? { ...e, ...r.patch } : e; describe('SLOT-NOT-SORT: drag tie order survives add/edit', () => { - test('addParticipant preserves existing drag tie order', () => { + 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 = apply(e, reorderParticipants(e, 'b', 'a')); // drag b ahead of a → [b,a] + 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 = apply(e, addParticipant(e, p('c', 5))); + 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', () => { + 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 = apply(e, reorderParticipants(e, 'b', 'a')); // [b,a] - e = apply(e, addParticipant(e, p('d', 10))); // init 10, after tie group + 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', () => { + test('updateParticipant preserves drag tie order on unrelated field edit', async () => { + const { ctx } = mockCtx(); let e = enc([p('a', 10), p('b', 10)]); - e = apply(e, reorderParticipants(e, 'b', 'a')); // [b,a] + e = await reorderParticipants(e, 'b', 'a', ctx); // [b,a] // Edit a's name (not initiative). Tie order MUST survive. - e = apply(e, updateParticipant(e, 'a', { name: 'A2' })); + 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', () => { + 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 = apply(e, reorderParticipants(e, 'b', 'a')); // [b,a,c,d] + 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 = apply(e, updateParticipant(e, 'c', { initiative: 10 })); + 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). diff --git a/shared/tests/turn.undo.test.js b/shared/tests/turn.undo.test.js index 777729a..ecc84f3 100644 --- a/shared/tests/turn.undo.test.js +++ b/shared/tests/turn.undo.test.js @@ -1,7 +1,17 @@ -// Undo roundtrip: every op that returns log.undo must restore prior state. -// Apply op → patch → apply undo → assert deepEqual original. - +// Undo + redo roundtrip: every op that writes a log entry must restore prior +// state on undo AND re-apply forward state on redo. Uses REAL mock storage +// undo() (applies patch + flips undone flag), not just expandUndo output. +// +// Pattern per case: +// before = snap(enc) // state before op +// newEnc = await op(enc) // apply op (writes enc doc + log entry) +// afterUndo = undoLast() // real undo via storage.undo +// expect(afterUndo) === before +// afterRedo = redoLast() // real redo via storage.undo({redo:true}) +// expect(afterRedo) === snap(newEnc) const shared = require('@ttrpg/shared'); +const { mockCtx, undoLast, redoLast } = require('./_helpers'); +const { expandUndo } = shared; const { makeParticipant, startEncounter, nextTurn, togglePause, addParticipant, removeParticipant, toggleParticipantActive, @@ -21,110 +31,177 @@ function enc(ps) { } const snap = (e) => JSON.parse(JSON.stringify(e)); -describe('undo roundtrip', () => { - test('startEncounter undo restores pre-start', () => { +describe('undo + redo roundtrip', () => { + test('startEncounter', async () => { + const { storage, ctx } = mockCtx(); const before = enc([p('a',10),p('b',20)]); - const r = startEncounter(before); - expect(r.log.undo).toBeTruthy(); - // undo restores isStarted/isPaused/round/current/turnOrderIds. - // participants[] may be reordered (1-list sort on start) — undo snapshot - // captures turn-state fields, not participant order. - const after = { ...before, ...r.patch, ...r.log.undo }; - expect(after.isStarted).toBe(before.isStarted); - expect(after.isPaused).toBe(before.isPaused); - expect(after.round).toBe(before.round); - expect(after.currentTurnParticipantId).toBe(before.currentTurnParticipantId); - expect(after.turnOrderIds).toEqual(before.turnOrderIds); + await storage.setDoc(ctx.encPath, before); + const newEnc = await startEncounter(before, ctx); + const last = storage.logs().at(-1); + const afterUndo = await undoLast(ctx, newEnc); + // participants[] order may differ (start sorts); check turn-state fields. + expect(afterUndo.isStarted).toBe(before.isStarted); + expect(afterUndo.isPaused).toBe(before.isPaused); + expect(afterUndo.round).toBe(before.round); + expect(afterUndo.currentTurnParticipantId).toBe(before.currentTurnParticipantId); + expect(afterUndo.turnOrderIds).toEqual(before.turnOrderIds); + const afterRedo = await redoLast(ctx, afterUndo, last); + expect(afterRedo.isStarted).toBe(newEnc.isStarted); + expect(afterRedo.round).toBe(newEnc.round); + expect(afterRedo.currentTurnParticipantId).toBe(newEnc.currentTurnParticipantId); + expect(afterRedo.turnOrderIds).toEqual(newEnc.turnOrderIds); }); - test('nextTurn undo restores prior currentTurn/round', () => { + test('nextTurn', async () => { + const { storage, ctx } = mockCtx(); 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 r = nextTurn(e); - expect(r.log.undo).toBeTruthy(); - const after = { ...e, ...r.patch, ...r.log.undo }; - expect(snap(after)).toEqual(before); + const newEnc = await nextTurn(e, ctx); + const last = storage.logs().at(-1); + const afterUndo = await undoLast(ctx, newEnc); + expect(snap(afterUndo)).toEqual(before); + const afterRedo = await redoLast(ctx, afterUndo, last); + expect(snap(afterRedo)).toEqual(snap(newEnc)); }); - test('togglePause 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)]); - e = { ...e, ...startEncounter(e).patch }; + e = await startEncounter(e, ctx); + await storage.setDoc(ctx.encPath, e); const before = snap(e); - const r = togglePause(e); - expect(r.log.undo).toBeTruthy(); - const after = { ...e, ...r.patch, ...r.log.undo }; - expect(snap(after)).toEqual(before); + const newEnc = await togglePause(e, ctx); + // state flips (isPaused), but no log written + expect(newEnc.isPaused).toBe(true); + expect(storage.logs()).toHaveLength(1); // start only }); - test('applyHpChange 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)]); - e = { ...e, ...startEncounter(e).patch }; + e = await startEncounter(e, ctx); + await storage.setDoc(ctx.encPath, e); const before = snap(e); - const r = applyHpChange(e, 'a', 'damage', 20); - expect(r.log.undo).toBeTruthy(); - const after = { ...e, ...r.patch, ...r.log.undo }; - expect(snap(after)).toEqual(before); + const newEnc = await applyHpChange(e, 'a', 'damage', 20, ctx); + const last = storage.logs().at(-1); + const afterUndo = await undoLast(ctx, newEnc); + expect(snap(afterUndo)).toEqual(before); + const afterRedo = await redoLast(ctx, afterUndo, last); + expect(snap(afterRedo)).toEqual(snap(newEnc)); }); - test('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)]); - e = { ...e, ...startEncounter(e).patch }; + e = await startEncounter(e, ctx); + await storage.setDoc(ctx.encPath, e); const before = snap(e); - const r = toggleCondition(e, 'a', 'stunned'); - expect(r.log.undo).toBeTruthy(); - const after = { ...e, ...r.patch, ...r.log.undo }; - expect(snap(after)).toEqual(before); + const newEnc = await toggleCondition(e, 'a', 'stunned', ctx); + const last = storage.logs().at(-1); + const afterUndo = await undoLast(ctx, newEnc); + expect(snap(afterUndo)).toEqual(before); + const afterRedo = await redoLast(ctx, afterUndo, last); + expect(snap(afterRedo)).toEqual(snap(newEnc)); }); - test('toggleParticipantActive 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)]); - e = { ...e, ...startEncounter(e).patch }; + e = await startEncounter(e, ctx); + await storage.setDoc(ctx.encPath, e); const before = snap(e); - const r = toggleParticipantActive(e, 'b'); - expect(r.log.undo).toBeTruthy(); - const after = { ...e, ...r.patch, ...r.log.undo }; - expect(snap(after)).toEqual(before); + const newEnc = await toggleParticipantActive(e, 'b', ctx); + const last = storage.logs().at(-1); + const afterUndo = await undoLast(ctx, newEnc); + expect(snap(afterUndo)).toEqual(before); + const afterRedo = await redoLast(ctx, afterUndo, last); + expect(snap(afterRedo)).toEqual(snap(newEnc)); }); - test('addParticipant undo restores prior participants', () => { + test('addParticipant', async () => { + const { storage, ctx } = mockCtx(); 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 np = makeParticipant({ id:'z', name:'z', type:'monster', initiative:15, maxHp:50, currentHp:50 }); - const r = addParticipant(e, np); - expect(r.log.undo).toBeTruthy(); - const after = { ...e, ...r.patch, ...r.log.undo }; - expect(snap(after)).toEqual(before); + const newEnc = await addParticipant(e, np, ctx); + const last = storage.logs().at(-1); + const afterUndo = await undoLast(ctx, newEnc); + expect(snap(afterUndo)).toEqual(before); + const afterRedo = await redoLast(ctx, afterUndo, last); + expect(snap(afterRedo)).toEqual(snap(newEnc)); }); - test('removeParticipant 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)]); - e = { ...e, ...startEncounter(e).patch }; + e = await startEncounter(e, ctx); + await storage.setDoc(ctx.encPath, e); const before = snap(e); - const r = removeParticipant(e, 'b'); - expect(r.log.undo).toBeTruthy(); - const after = { ...e, ...r.patch, ...r.log.undo }; - expect(snap(after)).toEqual(before); + const newEnc = await removeParticipant(e, 'b', ctx); + const last = storage.logs().at(-1); + const afterUndo = await undoLast(ctx, newEnc); + expect(snap(afterUndo)).toEqual(before); + const afterRedo = await redoLast(ctx, afterUndo, last); + expect(snap(afterRedo)).toEqual(snap(newEnc)); }); - test('endEncounter undo restores prior state', () => { + test('endEncounter', async () => { + const { storage, ctx } = mockCtx(); 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 r = endEncounter(e); - expect(r.log.undo).toBeTruthy(); - const after = { ...e, ...r.patch, ...r.log.undo }; - expect(snap(after)).toEqual(before); + const newEnc = await endEncounter(e, ctx); + const last = storage.logs().at(-1); + const afterUndo = await undoLast(ctx, newEnc); + expect(snap(afterUndo)).toEqual(before); + const afterRedo = await redoLast(ctx, afterUndo, last); + expect(snap(afterRedo)).toEqual(snap(newEnc)); }); - test('reorderParticipants 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 let e = enc(ps); - e = { ...e, ...startEncounter(e).patch }; - const r = reorderParticipants(e, 'c', 'b'); - // Documents: reorderParticipants returns log: null. Cannot undo. - // If undo expected here, this is BUG-7. - expect(r.log).toBeNull(); + await storage.setDoc(ctx.encPath, e); + const before = snap(e); + const newEnc = await reorderParticipants(e, 'c', 'b', ctx); + const last = storage.logs().at(-1); + const afterUndo = await undoLast(ctx, newEnc); + expect(snap(afterUndo)).toEqual(before); + const afterRedo = await redoLast(ctx, afterUndo, last); + expect(snap(afterRedo)).toEqual(snap(newEnc)); + }); + + test('updateParticipant', async () => { + const { storage, ctx } = mockCtx(); + let e = enc([p('a',10),p('b',20)]); + e = await startEncounter(e, ctx); + await storage.setDoc(ctx.encPath, e); + const before = snap(e); + const newEnc = await shared.updateParticipant(e, 'a', { initiative: 99 }, ctx); + const last = storage.logs().at(-1); + const afterUndo = await undoLast(ctx, newEnc); + expect(snap(afterUndo)).toEqual(before); + const afterRedo = await redoLast(ctx, afterUndo, last); + expect(snap(afterRedo)).toEqual(snap(newEnc)); }); }); diff --git a/shared/turn.js b/shared/turn.js index b3fd753..5ad7bf2 100644 --- a/shared/turn.js +++ b/shared/turn.js @@ -1,14 +1,16 @@ // @ttrpg/shared — turn.js -// Pure turn-order logic. No I/O, no React, no Firebase. -// Ported VERBATIM from src/App.js (M1). Bugs preserved intentionally. -// Characterization tests lock current behavior. Fixes come in M4. +// Turn-order + combat actions. WRITES OWN LOGS (single source of truth). +// Every mutating func is async, takes ctx {storage, encPath, logPath, +// displayPath}, persists encounter patch + log entry itself. UI, replay, +// anything calling these funcs = identical writes. Impossible to forget log. // -// Functions return NEW state (immutable). They never mutate input encounter. - -'use strict'; +// Pure helpers (generateId, build*, sort, slot) stay sync — no I/O. +// +// return value: NEW encounter state (immutable). Callers use for local state +// sync; persistence already done inside. // ---------------------------------------------------------------------------- -// Constants (mirror src/App.js) +// Constants // ---------------------------------------------------------------------------- const DEFAULT_MAX_HP = 10; @@ -16,7 +18,7 @@ const DEFAULT_INIT_MOD = 0; const MONSTER_DEFAULT_INIT_MOD = 2; // ---------------------------------------------------------------------------- -// Utility functions (verbatim from src/App.js) +// Pure utilities (no I/O) // ---------------------------------------------------------------------------- const generateId = () => @@ -32,17 +34,6 @@ const formatInitMod = (mod) => { }; // SLOT, NEVER SORT. 1-list model (docs/INITIATIVE_ORDERING.md). -// -// `sortParticipantsByInitiative` exists for ONE call site: startEncounter -// (freeze list once by init). After that, mutations = insert/move ops that -// PRESERVE existing array order. No wholesale re-sort ever — destroys drag -// tie-break order. -// -// slotIndexForInit: pure insert position. Scans existing list, returns index -// of first participant with init STRICTLY LESS than target. New participant -// splices there. Same-init participants stay ahead (stable). Existing tie -// order (incl. drag-established) untouched because scan reads current array, -// not original add-order. const sortParticipantsByInitiative = (participants, originalOrder) => { return [...participants].sort((a, b) => { if (a.initiative === b.initiative) { @@ -54,10 +45,6 @@ const sortParticipantsByInitiative = (participants, originalOrder) => { }); }; -// Find splice index for a single participant by initiative. List assumed -// already initiative-descending (startEncounter freezes it; add/edit maintain). -// Returns position to insert so list stays descending, ties go AFTER existing -// same-init (stable insertion). Current array order = source of truth. function slotIndexForInit(list, init) { for (let i = 0; i < list.length; i++) { if (init > list[i].initiative) return i; @@ -65,22 +52,22 @@ function slotIndexForInit(list, init) { return list.length; } -// 1-LIST SYNC: turnOrderIds always mirrors participants[].map(id). -// Call after any participants[] mutation. Returns turnOrderIds patch. +function withDeathStatusConditions(participant, updates) { + const status = updates.status !== undefined ? updates.status : participant.status; + const current = participant.conditions || []; + let conditions = current; + if (status === 'dying' || status === 'stable') { + conditions = current.includes('unconscious') ? current : [...current, 'unconscious']; + } else if (status === 'conscious' || status === 'dead') { + conditions = current.filter(c => c !== 'unconscious'); + } + return { ...updates, conditions }; +} + const syncTurnOrder = (participants) => ({ turnOrderIds: participants.map(p => p.id), }); -// SHARED ADVANCE CORE (BUG-5 DRY fix). -// Single source of truth for "who acts next". Both nextTurn and -// computeTurnOrderAfterRemoval delegate here — prevents drift where one path -// changes and the other doesn't. -// -// order: turnOrderIds (raw, may contain inactive/removed ids). -// fromPos: index of the last-acted slot (current participant, or the removed -// participant's old slot). Step +1 forward, skip fromPos itself. -// isActive: predicate id -> bool. -// Returns { nextId, wrapped }. wrapped = cycled past order end = new round. const nextActiveAfter = (order, fromPos, isActive) => { const n = order.length; if (n === 0) return { nextId: null, wrapped: false }; @@ -89,15 +76,11 @@ const nextActiveAfter = (order, fromPos, isActive) => { const id = order[idx]; if (isActive(id)) return { nextId: id, wrapped: idx <= fromPos }; } - return { nextId: null, wrapped: false }; // no other active participant + return { nextId: null, wrapped: false }; }; -// Verbatim from src/App.js. Returns turnOrderIds/currentTurnParticipantId updates -// when a participant leaves active combat. const computeTurnOrderAfterRemoval = (encounter, removedId, updatedParticipants) => { if (!encounter.isStarted) return {}; - // 1-list: turnOrderIds syncs from participants[].map(id) at call site. - // Here only handle current-advance if removed == current. const updates = {}; if (encounter.currentTurnParticipantId === removedId) { const removedPos = (encounter.turnOrderIds || []).indexOf(removedId); @@ -109,34 +92,252 @@ const computeTurnOrderAfterRemoval = (encounter, removedId, updatedParticipants) return updates; }; -// Insert addedId into turnOrderIds by initiative. New participant slots into -// correct initiative position at add time (not appended to end). Preserves -// current pointer — no re-sort anywhere except startEncounter. -// Tie rule: insert AFTER existing same-init (preserves creation order). -// NOTE: 1-list model — caller syncs participants[] in same pos as insert target. -// Participant factory (mirrors ParticipantManager.handleAddParticipant shape) +// ---------------------------------------------------------------------------- +// Snapshot helper (lean — analyzer + viewer use this only) +// ---------------------------------------------------------------------------- + +function snapshotOf(enc) { + return { + round: enc.round ?? 0, + currentTurnParticipantId: enc.currentTurnParticipantId ?? null, + turnOrderIds: [...(enc.turnOrderIds || [])], + activeIds: (enc.participants || []).filter(p => p.isActive).map(p => p.id), + }; +} + +// Build LEAN log entry. Stores mutation delta only — no roster arrays, +// no payload duplication, no redo wrapper. Undo = id-based inverse. +// log object now carries: { type, message, participantId, delta, undo }. +// delta: type-specific scalars (amount, condition, init, changedFields...) +// undo: type-specific inverse (full participant obj for remove; id scalars +// for others). Client expandUndo() turns this into a patch at click. +function buildEntry(enc, encPath, log) { + if (!log) return null; + const e = { + ts: Date.now(), + type: log.type, + message: log.message || '', + encounterId: enc.id || null, + encounterName: enc.name || null, + encounterPath: encPath, + participantId: log.participantId || null, + participantName: log.participantName || null, + undone: false, + snapshot: snapshotOf(enc), + }; + if (log.delta) e.delta = log.delta; + if (log.undo) e.undo = log.undo; + return e; +} + +// Execute: apply patch to encounter doc + write lean log. Returns newEnc. +async function commit(enc, patch, log, ctx) { + const newEnc = { ...enc, ...(patch || {}) }; + const logPromise = (log && ctx.logPath) + ? ctx.storage.addDoc(ctx.logPath, buildEntry(newEnc, ctx.encPath, log)) + : Promise.resolve(); + await Promise.all([ + ctx.storage.updateDoc(ctx.encPath, patch), + logPromise, + ]); + return newEnc; +} + +// expandUndo: turn lean `undo` (id-based) into full patch for server merge. +// Caller passes CURRENT encounter doc (from subscription) so roster ops can +// rebuild arrays. Returns { encounterPath, updates, redo } shaped for legacy +// transactionalUndo contract. redo = forward delta applied fresh. +function expandUndo(entry, currentEnc) { + const u = entry.undo; + if (!u || !currentEnc) return null; + const encPath = entry.encounterPath; + const parts = currentEnc.participants || []; + const snap = entry.snapshot || {}; + // snapshot captured POST-action state; redo = re-apply forward from pre. + // pre snapshot derivable from undo when scalar; roster ops need stored obj. + switch (entry.type) { + case 'add_participant': + case 'add_participants': { + const added = Array.isArray(u.participants) ? u.participants : [u.participant]; + const ids = new Set(added.filter(Boolean).map(p => p.id)); + const turnState = {}; + if (u.currentTurnParticipantId !== undefined) turnState.currentTurnParticipantId = u.currentTurnParticipantId; + if (u.turnOrderIds) turnState.turnOrderIds = u.turnOrderIds; + // redo: re-insert added into current parts, order by stored newTurnOrderIds. + const redoOrder = u.newTurnOrderIds || null; + let redoParts = null; + if (redoOrder) { + const pool = new Map([...parts, ...added].map(p => [p.id, p])); + redoParts = redoOrder.map(id => pool.get(id)).filter(Boolean); + } + const redoTurnState = {}; + if (u.newCurrentTurnParticipantId !== undefined) redoTurnState.currentTurnParticipantId = u.newCurrentTurnParticipantId; + return { + encounterPath: encPath, + updates: { participants: parts.filter(p => !ids.has(p.id)), ...turnState }, + redo: redoParts + ? { participants: redoParts, turnOrderIds: redoOrder, ...redoTurnState } + : { participants: [...parts, ...added.filter(p => !parts.some(x => x.id === p.id))] }, + }; + } + case 'remove_participant': { + const p = u.participant; + const turnState = {}; + if (u.currentTurnParticipantId !== undefined) turnState.currentTurnParticipantId = u.currentTurnParticipantId; + if (u.turnOrderIds) turnState.turnOrderIds = u.turnOrderIds; + let restoredParts = parts; + if (p) { + const idx = typeof u.slotIdx === 'number' ? Math.min(u.slotIdx, parts.length) : parts.length; + restoredParts = [...parts.slice(0, idx), p, ...parts.slice(idx)]; + } + // redo: re-remove + restore stored forward turn state. + const redoTurnState = {}; + if (u.newCurrentTurnParticipantId !== undefined) redoTurnState.currentTurnParticipantId = u.newCurrentTurnParticipantId; + if (u.newTurnOrderIds) redoTurnState.turnOrderIds = u.newTurnOrderIds; + return { + encounterPath: encPath, + updates: { participants: restoredParts, ...turnState }, + redo: u.newTurnOrderIds + ? { participants: parts.filter(x => x.id !== (p && p.id)), ...redoTurnState } + : { participants: parts.filter(x => x.id !== (p && p.id)) }, + }; + } + case 'damage': + case 'heal': + case 'death_save': + case 'stabilize': + case 'revive': + case 'deactivate_dead_monster': { + // Full death-state restore: currentHp, status, death-save counters, + // isActive, conditions (unconscious added/removed by withDeathStatusConditions). + const pid = entry.participantId; + const cur = parts.find(p => p.id === pid); + if (!cur) return null; + const oldVals = u.oldValues || {}; + const newVals = u.newValues || {}; + const restoreFields = (vals) => { + const out = {}; + if (vals.currentHp !== undefined) out.currentHp = vals.currentHp; + if (vals.status !== undefined) out.status = vals.status; + if (vals.deathSaveSuccesses !== undefined) out.deathSaveSuccesses = vals.deathSaveSuccesses; + if (vals.deathSaveFailures !== undefined) out.deathSaveFailures = vals.deathSaveFailures; + if (vals.isActive !== undefined) out.isActive = vals.isActive; + if (vals.conditions !== undefined) out.conditions = vals.conditions; + return out; + }; + return { + encounterPath: encPath, + updates: { participants: parts.map(p => p.id === pid ? { ...p, ...restoreFields(oldVals) } : p) }, + redo: { participants: parts.map(p => p.id === pid ? { ...p, ...restoreFields(newVals) } : p) }, + }; + } + case 'add_condition': + case 'remove_condition': { + const cond = u.condition; + const pid = entry.participantId; + const cur = parts.find(p => p.id === pid); + if (!cur) return null; + const has = (cur.conditions || []).includes(cond); + const next = has ? (cur.conditions || []).filter(c => c !== cond) : [...(cur.conditions || []), cond]; + return { + encounterPath: encPath, + updates: { participants: parts.map(p => p.id === pid ? { ...p, conditions: next } : p) }, + redo: { participants: parts.map(p => p.id === pid ? { ...p, conditions: !has ? [...(p.conditions||[]), cond] : (p.conditions||[]).filter(c => c !== cond) } : p) }, + }; + } + case 'reactivate': + case 'deactivate': { + const pid = entry.participantId; + const wasActive = u.wasActive; + const turnState = {}; + if (u.currentTurnParticipantId !== undefined) turnState.currentTurnParticipantId = u.currentTurnParticipantId; + if (u.turnOrderIds) turnState.turnOrderIds = u.turnOrderIds; + return { + encounterPath: encPath, + updates: { participants: parts.map(p => p.id === pid ? { ...p, isActive: wasActive } : p), ...turnState }, + redo: { participants: parts.map(p => p.id === pid ? { ...p, isActive: !wasActive } : p) }, + }; + } + case 'reorder': { + // undo: restore old order. redo: re-apply stored forward order. + const redoParts = u.newParticipants || null; + return { + encounterPath: encPath, + updates: { participants: u.participants || parts, ...(u.turnOrderIds ? { turnOrderIds: u.turnOrderIds } : {}), ...(u.currentTurnParticipantId !== undefined ? { currentTurnParticipantId: u.currentTurnParticipantId } : {}) }, + redo: redoParts + ? { participants: redoParts, turnOrderIds: u.newTurnOrderIds || redoParts.map(p => p.id) } + : { participants: parts, turnOrderIds: snap.turnOrderIds || currentEnc.turnOrderIds || [] }, + }; + } + case 'update_participant': { + const pid = entry.participantId; + const oldVals = u.oldValues || {}; + const newVals = u.newValues || {}; + // If initiative changed, restore array order too (undo by oldTurnOrderIds, + // redo by newTurnOrderIds). Otherwise just patch fields in place. + const applyFields = (vals) => parts.map(p => p.id === pid ? { ...p, ...vals } : p); + const hasOrderChange = !!(u.oldTurnOrderIds && u.oldTurnOrderIds.length && u.newTurnOrderIds && u.newTurnOrderIds.length && JSON.stringify(u.oldTurnOrderIds) !== JSON.stringify(u.newTurnOrderIds)); + if (hasOrderChange && u.oldTurnOrderIds && u.newTurnOrderIds) { + const undoApplied = applyFields(oldVals); + const undoPool = new Map(undoApplied.map(p => [p.id, p])); + const undoOrdered = u.oldTurnOrderIds.map(id => undoPool.get(id)).filter(Boolean); + const redoApplied = applyFields(newVals); + const redoPool = new Map(redoApplied.map(p => [p.id, p])); + const redoOrdered = u.newTurnOrderIds.map(id => redoPool.get(id)).filter(Boolean); + return { + encounterPath: encPath, + updates: { participants: undoOrdered, turnOrderIds: u.oldTurnOrderIds }, + redo: { participants: redoOrdered, turnOrderIds: u.newTurnOrderIds }, + }; + } + return { + encounterPath: encPath, + updates: { participants: applyFields(oldVals) }, + redo: { participants: applyFields(newVals), ...(u.newTurnOrderIds ? { turnOrderIds: u.newTurnOrderIds } : {}) }, + }; + } + case 'next_turn': + case 'auto_end': { + return { + encounterPath: encPath, + updates: { currentTurnParticipantId: u.currentTurnParticipantId, round: u.round, ...(u.turnOrderIds ? { turnOrderIds: u.turnOrderIds } : {}) }, + redo: { currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [] }, + }; + } + case 'start_encounter': + case 'end_encounter': { + return { + encounterPath: encPath, + updates: u, + redo: { isStarted: entry.type === 'start_encounter', isPaused: false, currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [] }, + }; + } + default: + return null; + } +} + +// ---------------------------------------------------------------------------- +// Participant factories (pure — no write) // ---------------------------------------------------------------------------- function makeParticipant(opts) { return { id: opts.id || generateId(), name: opts.name, - type: opts.type, // 'character' | 'monster' + type: opts.type, originalCharacterId: opts.originalCharacterId || null, initiative: opts.initiative, maxHp: opts.maxHp, currentHp: opts.currentHp, - isNpc: opts.isNpc || false, conditions: opts.conditions || [], isActive: opts.isActive !== undefined ? opts.isActive : true, - deathSaves: opts.deathSaves || 0, // successes (0-3) - deathFails: opts.deathFails || 0, // failures (0-3) - isStable: opts.isStable || false, - isDying: opts.isDying || false, + status: opts.status || (opts.currentHp === 0 ? 'dying' : 'conscious'), + deathSaveSuccesses: opts.deathSaveSuccesses || 0, + deathSaveFailures: opts.deathSaveFailures || 0, }; } -// Build a character participant from a campaign character (rolls initiative). function buildCharacterParticipant(character) { const initiativeRoll = rollD20(); const modifier = character.defaultInitMod || 0; @@ -150,14 +351,12 @@ function buildCharacterParticipant(character) { initiative: finalInitiative, maxHp, currentHp: maxHp, - isNpc: false, }), roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative }, }; } -// Build a monster participant (rolls initiative). -function buildMonsterParticipant({ name, maxHp, initMod, isNpc }) { +function buildMonsterParticipant({ name, maxHp, initMod, asNpc }) { const initiativeRoll = rollD20(); const modifier = initMod !== undefined ? initMod : MONSTER_DEFAULT_INIT_MOD; const finalInitiative = initiativeRoll + modifier; @@ -165,90 +364,65 @@ function buildMonsterParticipant({ name, maxHp, initMod, isNpc }) { return { participant: makeParticipant({ name, - type: 'monster', + type: asNpc ? 'npc' : 'monster', initiative: finalInitiative, maxHp: hp, currentHp: hp, - isNpc: isNpc || false, }), roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative }, }; } // ---------------------------------------------------------------------------- -// Action handlers — pure: (encounter, action) => { encounter, patch, log } -// Return patch = partial fields to merge into stored encounter. -// Caller persists patch + broadcasts. +// Combat actions — async. Write encounter + log inside. Return newEnc. +// ctx = { storage, encPath, logPath, displayPath } // ---------------------------------------------------------------------------- -// START_ENCOUNTER — verbatim from InitiativeControls.handleStartEncounter -function startEncounter(encounter) { +async function startEncounter(encounter, ctx) { if (!encounter.participants || encounter.participants.length === 0) { throw new Error('Add participants first.'); } - // 1-list model: sort ALL participants by init (active + inactive) so display - // order = initiative. nextTurn skips inactive. turnOrderIds mirrors list. const sortedParticipants = sortParticipantsByInitiative(encounter.participants || [], encounter.participants); const firstActive = sortedParticipants.find(p => p.isActive); - if (!firstActive) { - throw new Error('No active participants.'); - } - const orderedParticipants = sortedParticipants; - return { - patch: { - isStarted: true, - isPaused: false, - round: 1, - participants: orderedParticipants, - currentTurnParticipantId: firstActive.id, - turnOrderIds: orderedParticipants.map(p => p.id), - }, - log: { - message: `Combat started: "${encounter.name}" — ${firstActive.name}'s turn (Round 1)`, - undo: { - isStarted: encounter.isStarted ?? false, - isPaused: encounter.isPaused ?? false, - round: encounter.round ?? 0, - currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, - turnOrderIds: [...(encounter.turnOrderIds || [])], - }, + if (!firstActive) throw new Error('No active participants.'); + const patch = { + isStarted: true, isPaused: false, round: 1, + participants: sortedParticipants, + currentTurnParticipantId: firstActive.id, + turnOrderIds: sortedParticipants.map(p => p.id), + }; + const log = { + type: 'start_encounter', + participantId: firstActive.id, participantName: firstActive.name, + message: `Combat started: "${encounter.name}" — ${firstActive.name}'s turn (Round 1)`, + delta: { round: 1, firstTurnId: firstActive.id }, + undo: { + isStarted: encounter.isStarted ?? false, + isPaused: encounter.isPaused ?? false, + round: encounter.round ?? 0, + currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, + turnOrderIds: [...(encounter.turnOrderIds || [])], }, }; + return commit(encounter, patch, log, ctx); } -// NEXT_TURN — verbatim from InitiativeControls.handleNextTurn -// NOTE: this is the suspected skip-bug source. Preserved for M3 characterization. -function nextTurn(encounter) { +async function nextTurn(encounter, ctx) { if (!encounter.isStarted || encounter.isPaused) { throw new Error('Encounter not running.'); } if (!encounter.currentTurnParticipantId || !encounter.turnOrderIds || encounter.turnOrderIds.length === 0) { throw new Error('No active turn.'); } - const activePsInOrder = encounter.turnOrderIds .map(id => encounter.participants.find(p => p.id === id && p.isActive)) .filter(Boolean); - if (activePsInOrder.length === 0) { - // End encounter — no active participants left. - return { - patch: { - isStarted: false, - isPaused: false, - currentTurnParticipantId: null, - round: encounter.round, - }, - log: { message: `Combat auto-ended: no active participants`, undo: null }, - }; + const patch = { isStarted: false, isPaused: false, currentTurnParticipantId: null, round: encounter.round }; + const log = { type: 'auto_end', message: `Combat auto-ended: no active participants`, undo: { currentTurnParticipantId: encounter.currentTurnParticipantId, round: encounter.round, isStarted: true, isPaused: false } }; + return commit(encounter, patch, log, ctx); } - let nextRound = encounter.round; - let newTurnOrderIds = encounter.turnOrderIds; - - // Delegate to shared advance core (BUG-5 DRY fix). Same math - // computeTurnOrderAfterRemoval uses → no drift. fromPos = current's slot - // in raw turnOrderIds; -1 path handles removed/stale current. const order = encounter.turnOrderIds || []; const fromPos = order.indexOf(encounter.currentTurnParticipantId); const isActive = id => { @@ -256,306 +430,319 @@ function nextTurn(encounter) { return !!p && p.isActive; }; const { nextId, wrapped } = nextActiveAfter(order, fromPos, isActive); - - if (!nextId) { - throw new Error('Could not determine next participant.'); - } + if (!nextId) throw new Error('Could not determine next participant.'); if (wrapped) nextRound += 1; - const nextParticipant = encounter.participants.find(p => p.id === nextId); - - return { - patch: { - currentTurnParticipantId: nextParticipant.id, - round: nextRound, - turnOrderIds: newTurnOrderIds, - }, - log: { - message: `${nextParticipant.name}'s turn (Round ${nextRound})`, - undo: { - currentTurnParticipantId: encounter.currentTurnParticipantId, - round: encounter.round, - turnOrderIds: [...encounter.turnOrderIds], - }, - }, + const patch = { currentTurnParticipantId: nextParticipant.id, round: nextRound, turnOrderIds: encounter.turnOrderIds }; + const log = { + type: 'next_turn', + participantId: nextParticipant.id, participantName: nextParticipant.name, + message: `${nextParticipant.name}'s turn (Round ${nextRound})`, + delta: { wrapped, prevRound: encounter.round }, + undo: { currentTurnParticipantId: encounter.currentTurnParticipantId, round: encounter.round, turnOrderIds: [...encounter.turnOrderIds] }, }; + return commit(encounter, patch, log, ctx); } -// PAUSE / RESUME — verbatim from InitiativeControls.handleTogglePause -function togglePause(encounter) { - if (!encounter || !encounter.isStarted) { - throw new Error('Encounter not started.'); - } +async function togglePause(encounter, ctx) { + if (!encounter || !encounter.isStarted) throw new Error('Encounter not started.'); const newPausedState = !encounter.isPaused; - let newTurnOrderIds = encounter.turnOrderIds; - if (!newPausedState && encounter.isPaused) { - // Resume: do NOT re-sort. Re-sorting displaces the current pointer — - // participants who already acted move earlier in order and nextTurn - // revisits them (whole round replays). Order is frozen at startEncounter - // and patched incrementally; resume keeps it stable. - } - return { - patch: { isPaused: newPausedState, turnOrderIds: newTurnOrderIds }, - log: { - message: `Combat ${newPausedState ? 'paused' : 'resumed'}: "${encounter.name}"`, - undo: { - isPaused: encounter.isPaused ?? false, - turnOrderIds: [...(encounter.turnOrderIds || [])], - }, - }, - }; + const patch = { isPaused: newPausedState, turnOrderIds: encounter.turnOrderIds }; + // Lifecycle op: no log entry. Pause/resume excluded from undo stack so DM + // undo targets the last player action (damage/condition/etc), not a flag flip. + return commit(encounter, patch, null, ctx); } -// ADD_PARTICIPANT — appends participant. (Initiative rolled by caller via build*.) -// If encounter already started, also slot participant into turnOrderIds by -// initiative (slotIndexForInit). 1-list model. -function addParticipant(encounter, participant) { +async function addParticipant(encounter, participant, ctx) { if ((encounter.participants || []).some(p => p.id === participant.id)) { throw new Error(`Participant with id "${participant.id}" already exists in encounter.`); } - // SLOT (not sort): insert by initiative into current list. Preserves - // existing array order incl. drag-established tie order. const base = [...(encounter.participants || [])]; const idx = slotIndexForInit(base, participant.initiative); base.splice(idx, 0, participant); - const updatedParticipants = base; - return { - patch: { participants: updatedParticipants, ...syncTurnOrder(updatedParticipants) }, - log: { - message: `${participant.name} added to encounter (Initiative: ${participant.initiative})`, - undo: { - participants: [...(encounter.participants || [])], - ...(encounter.isStarted ? { - turnOrderIds: [...(encounter.turnOrderIds || [])], - } : {}), - }, + const patch = { participants: base, ...syncTurnOrder(base) }; + const log = { + type: 'add_participant', + participantId: participant.id, participantName: participant.name, + message: `${participant.name} added to encounter (Initiative: ${participant.initiative})`, + delta: { init: participant.initiative, maxHp: participant.maxHp, ptype: participant.type }, + undo: { + participant, + currentTurnParticipantId: encounter.currentTurnParticipantId, + turnOrderIds: [...(encounter.turnOrderIds || [])], + newTurnOrderIds: patch.turnOrderIds, + newCurrentTurnParticipantId: patch.currentTurnParticipantId, }, }; + return commit(encounter, patch, log, ctx); } -// ADD_PARTICIPANTS — bulk add (e.g. "add all campaign characters"). -function addParticipants(encounter, newParticipants) { - const undo = { participants: encounter.participants || [] }; +async function addParticipants(encounter, newParticipants, ctx) { const updatedParticipants = [...(encounter.participants || []), ...newParticipants]; const names = newParticipants.map(p => p.name).join(', '); - return { - patch: { participants: updatedParticipants }, - log: { - message: `Added ${newParticipants.length} participant${newParticipants.length === 1 ? '' : 's'}: ${names}`, - undo, - }, + const patch = { participants: updatedParticipants }; + const log = { + type: 'add_participants', + message: `Added ${newParticipants.length} participant${newParticipants.length === 1 ? '' : 's'}: ${names}`, + delta: { count: newParticipants.length }, + undo: { participants: newParticipants }, }; + return commit(encounter, patch, log, ctx); } -// UPDATE_PARTICIPANT — edit modal save (name/initiative/hp/isNpc). -function updateParticipant(encounter, participantId, updatedData) { +async function updateParticipant(encounter, participantId, updatedData, ctx) { const existing = encounter.participants || []; const target = existing.find(p => p.id === participantId); if (!target) throw new Error(`Participant "${participantId}" not found.`); const merged = { ...target, ...updatedData }; - // SLOT only if initiative changed. Other edits (name/hp/notes/isNpc) = no - // position change (design doc). Re-slots on unrelated edits shuffle the - // list mid-round → rotation dupes. - const undo = { participants: encounter.participants || [] }; + let patch; + const oldValues = {}; + for (const k of Object.keys(updatedData)) oldValues[k] = target[k]; if (!('initiative' in updatedData) || updatedData.initiative === target.initiative) { const sameSlot = existing.map(p => p.id === participantId ? merged : p); const fields = Object.keys(updatedData).join(', '); - return { - patch: { participants: sameSlot, ...syncTurnOrder(sameSlot) }, - log: { - message: `${target.name} updated (${fields})`, - undo, - }, + patch = { participants: sameSlot, ...syncTurnOrder(sameSlot) }; + const log = { + type: 'update_participant', + participantId, participantName: target.name, + message: `${target.name} updated (${fields})`, + delta: { changedFields: Object.keys(updatedData) }, + undo: { oldValues, newValues: { ...updatedData }, oldTurnOrderIds: encounter.turnOrderIds || [], newTurnOrderIds: patch.turnOrderIds }, }; - } - const without = existing.filter(p => p.id !== participantId); - const idx = slotIndexForInit(without, merged.initiative); - without.splice(idx, 0, merged); - return { - patch: { participants: without, ...syncTurnOrder(without) }, - log: { + return commit(encounter, patch, log, ctx); + } else { + const without = existing.filter(p => p.id !== participantId); + const idx = slotIndexForInit(without, merged.initiative); + without.splice(idx, 0, merged); + patch = { participants: without, ...syncTurnOrder(without) }; + const log = { + type: 'update_participant', + participantId, participantName: target.name, message: `${target.name} updated (initiative: ${merged.initiative})`, - undo, - }, - }; + delta: { changedFields: Object.keys(updatedData), initiative: merged.initiative }, + undo: { oldValues, newValues: { ...updatedData }, oldTurnOrderIds: encounter.turnOrderIds || [], newTurnOrderIds: patch.turnOrderIds }, + }; + return commit(encounter, patch, log, ctx); + } } -// REMOVE_PARTICIPANT — verbatim from ParticipantManager.confirmDeleteParticipant -function removeParticipant(encounter, participantId) { +async function removeParticipant(encounter, participantId, ctx) { const updatedParticipants = (encounter.participants || []).filter(p => p.id !== participantId); const advUpdates = computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants); const turnUpdates = encounter.isStarted ? { ...syncTurnOrder(updatedParticipants), ...advUpdates } : {}; const participant = (encounter.participants || []).find(p => p.id === participantId); - return { - patch: { participants: updatedParticipants, ...turnUpdates }, - log: { - message: `${participant ? participant.name : 'Participant'} removed from encounter`, - undo: { - participants: [...(encounter.participants || [])], - ...(encounter.isStarted ? { - currentTurnParticipantId: encounter.currentTurnParticipantId, - turnOrderIds: [...(encounter.turnOrderIds || [])], - } : {}), - }, + const slotIdx = (encounter.participants || []).findIndex(p => p.id === participantId); + const patch = { participants: updatedParticipants, ...turnUpdates }; + const log = { + type: 'remove_participant', + participantId, participantName: participant ? participant.name : null, + message: `${participant ? participant.name : 'Participant'} removed from encounter`, + delta: { dead: true }, + undo: { + participant, slotIdx, + currentTurnParticipantId: encounter.currentTurnParticipantId, + turnOrderIds: [...(encounter.turnOrderIds || [])], + newCurrentTurnParticipantId: patch.currentTurnParticipantId, + newTurnOrderIds: patch.turnOrderIds, }, }; + return commit(encounter, patch, log, ctx); } -// TOGGLE_ACTIVE — verbatim from ParticipantManager.toggleParticipantActive -function toggleParticipantActive(encounter, participantId) { +async function toggleParticipantActive(encounter, participantId, ctx) { const participant = (encounter.participants || []).find(p => p.id === participantId); if (!participant) throw new Error('Participant not found.'); const newIsActive = !participant.isActive; const updatedParticipants = (encounter.participants || []).map(p => p.id === participantId ? { ...p, isActive: newIsActive } : p ); - // 1-list: participant stays in slot on toggle (active or not). nextTurn - // skips inactive. Only advance current if deact hits current. - let turnUpdates = {}; - if (encounter.isStarted) { - turnUpdates = syncTurnOrder(updatedParticipants); - if (!newIsActive && encounter.currentTurnParticipantId === participantId) { - const adv = computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants); - turnUpdates = { ...turnUpdates, ...adv }; - } - } - return { - patch: { participants: updatedParticipants, ...turnUpdates }, - log: { - message: `${participant.name} ${newIsActive ? 'reactivated' : 'deactivated'}`, - undo: { - participants: [...(encounter.participants || [])], - ...(encounter.isStarted ? { - currentTurnParticipantId: encounter.currentTurnParticipantId, - turnOrderIds: [...(encounter.turnOrderIds || [])], - } : {}), - }, - }, + // Toggle active changes only active state + mirrored order. It must NOT + // advance turn or increment round; DM can deactivate current, then click Next. + // Design: "Toggle active: No position change. Skip in rotation only." + const turnUpdates = encounter.isStarted ? syncTurnOrder(updatedParticipants) : {}; + const patch = { participants: updatedParticipants, ...turnUpdates }; + const log = { + type: newIsActive ? 'reactivate' : 'deactivate', + participantId, participantName: participant.name, + message: `${participant.name} ${newIsActive ? 'reactivated' : 'deactivated'}`, + delta: { revived: newIsActive }, + undo: { wasActive: participant.isActive, currentTurnParticipantId: encounter.currentTurnParticipantId, turnOrderIds: [...(encounter.turnOrderIds || [])] }, }; + return commit(encounter, patch, log, ctx); } -// APPLY_HP_CHANGE — verbatim from ParticipantManager.applyHpChange -// changeType: 'damage' | 'heal' -function applyHpChange(encounter, participantId, changeType, amount) { +async function applyHpChange(encounter, participantId, changeType, amount, optionsOrCtx, maybeCtx) { + const ctx = maybeCtx || optionsOrCtx; + const options = maybeCtx ? (optionsOrCtx || {}) : {}; const participant = (encounter.participants || []).find(p => p.id === participantId); if (!participant) throw new Error('Participant not found.'); - if (isNaN(amount) || amount === 0) { - return { patch: null, log: null }; // no-op + if (isNaN(amount) || amount === 0) return encounter; + if (amount < 0) { + amount = Math.abs(amount); + changeType = changeType === 'damage' ? 'heal' : 'damage'; } - let newHp = participant.currentHp; - if (changeType === 'damage') newHp = Math.max(0, participant.currentHp - amount); - else if (changeType === 'heal') newHp = Math.min(participant.maxHp, participant.currentHp + amount); - const wasDead = participant.currentHp === 0; - const isDead = newHp === 0; - const wasResurrected = wasDead && newHp > 0; - - // Unified (App main): death flips isActive=false (removed from active - // rotation, skipped by nextTurn). Revive flips true. No turnOrderIds change. - const updatedParticipants = (encounter.participants || []).map(p => { - if (p.id !== participantId) return p; - const updates = { ...p, currentHp: newHp }; - if (isDead && !wasDead) { - updates.isActive = false; - updates.deathSaves = p.deathSaves || 0; - updates.isDying = false; - } - if (wasResurrected) { - updates.isActive = true; - updates.deathSaves = 0; - updates.isDying = false; - } - return updates; - }); - - const turnUpdates = {}; - - const hpLine = `${participant.currentHp} → ${newHp} HP`; - const deathSuffix = (isDead && !wasDead) - ? (participant.type === 'character' ? ' — Unconscious' : ' — Defeated') - : ''; - const resurSuffix = wasResurrected ? ' — Revived' : ''; - const message = changeType === 'damage' - ? `${participant.name} took ${amount} damage (${hpLine})${deathSuffix}` - : `${participant.name} healed for ${amount} (${hpLine})${resurSuffix}`; - - return { - patch: { participants: updatedParticipants, ...turnUpdates }, - log: { - message, - undo: { - participants: [...(encounter.participants || [])], - ...((isDead && !wasDead) || wasResurrected ? { - currentTurnParticipantId: encounter.currentTurnParticipantId, - turnOrderIds: [...(encounter.turnOrderIds || [])], - } : {}), - }, - }, + const oldValues = { + currentHp: participant.currentHp, + status: participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious'), + deathSaveSuccesses: participant.deathSaveSuccesses || 0, + deathSaveFailures: participant.deathSaveFailures || 0, + isActive: participant.isActive, + conditions: [...(participant.conditions || [])], }; -} -// DEATH_SAVE — D&D 5e model. Separate success/fail tracking. -// type: 'success' | 'fail' -// n: 1 | 2 | 3 (which pip clicked) -// 3 successes = stable (0hp unconscious, safe). 3 fails = dead (isDying, -// caller animates removal). Toggling same pip = undo that pip. -// Returns { patch, log, status } where status: 'stable'|'dead'|'pending'. -function deathSave(encounter, participantId, type, n) { - const participant = (encounter.participants || []).find(p => p.id === participantId); - if (!participant) throw new Error('Participant not found.'); - if (type !== 'success' && type !== 'fail') { - throw new Error(`deathSave type must be 'success' or 'fail', got "${type}".`); - } - const name = participant.name; - const undo = { participants: encounter.participants || [] }; + let updates = {}; + let message = ''; + let logDelta = { amount, from: participant.currentHp }; + const status = oldValues.status; - // Toggle: clicking current-value pip decrements (undo that pip). - const current = type === 'success' - ? (participant.deathSaves || 0) - : (participant.deathFails || 0); - const next = current === n ? n - 1 : n; - - const field = type === 'success' ? 'deathSaves' : 'deathFails'; - const updates = { [field]: next }; - - // Clear terminal flags on any change (re-eval below). - let status = 'pending'; - const newSuccesses = type === 'success' ? next : (participant.deathSaves || 0); - const newFails = type === 'fail' ? next : (participant.deathFails || 0); - - if (newSuccesses >= 3) { - updates.isStable = true; - updates.isDying = false; - status = 'stable'; - } else if (newFails >= 3) { - updates.isStable = false; - updates.isDying = true; - status = 'dead'; + if (changeType === 'damage') { + if (participant.currentHp === 0) { + if (status === 'dead') { + if (participant.type === 'monster' && participant.isActive !== false) { + const updatedParticipants = (encounter.participants || []).map(p => + p.id === participantId ? { ...p, isActive: false } : p + ); + const patch = { participants: updatedParticipants }; + const newP = updatedParticipants.find(p => p.id === participantId); + const newValues = { + currentHp: newP.currentHp, + status: newP.status, + deathSaveSuccesses: newP.deathSaveSuccesses || 0, + deathSaveFailures: newP.deathSaveFailures || 0, + isActive: newP.isActive, + conditions: [...(newP.conditions || [])], + }; + const log = { + type: 'deactivate_dead_monster', + participantId, + participantName: participant.name, + message: `${participant.name} marked inactive (dead monster)`, + delta: { status: 'dead', isActive: false }, + undo: { oldValues, newValues }, + }; + return commit(encounter, patch, log, ctx); + } + return encounter; + } + const add = options.isCriticalHit ? 2 : 1; + const failures = (status === 'stable' ? 0 : (participant.deathSaveFailures || 0)) + add; + if (failures >= 3) { + updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, ...(participant.type === 'monster' ? { isActive: false } : {}) }; + } else { + updates = { currentHp: 0, status: 'dying', deathSaveSuccesses: 0, deathSaveFailures: failures }; + } + message = `${participant.name} took ${amount} damage while ${status === 'stable' ? 'stable' : 'dying'}`; + logDelta = { ...logDelta, to: 0, status: updates.status, deathSaveFailures: updates.deathSaveFailures }; + } else if (amount >= participant.currentHp + participant.maxHp) { + updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, ...(participant.type === 'monster' ? { isActive: false } : {}) }; + message = `Massive damage! ${participant.name} instantly killed`; + logDelta = { ...logDelta, to: 0, status: 'dead', massive: true }; + } else { + const newHp = Math.max(0, participant.currentHp - amount); + if (newHp === 0) { + const zeroStatus = participant.type === 'monster' ? 'dead' : 'dying'; + updates = { currentHp: 0, status: zeroStatus, deathSaveSuccesses: 0, deathSaveFailures: 0, ...(zeroStatus === 'dead' ? { isActive: false } : {}) }; + } else { + updates = { currentHp: newHp, status: 'conscious' }; + } + message = `${participant.name} took ${amount} damage (${participant.currentHp} → ${newHp} HP)`; + logDelta = { ...logDelta, to: newHp, status: updates.status }; + } + } else if (changeType === 'heal') { + if (status === 'dead') return encounter; + const newHp = Math.min(participant.maxHp, Math.max(1, participant.currentHp + amount)); + updates = { currentHp: newHp, status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0 }; + message = `${participant.name} healed for ${amount} (${participant.currentHp} → ${newHp} HP)`; + logDelta = { ...logDelta, to: newHp, status: 'conscious', deathSavesCleared: true }; } else { - updates.isStable = false; - updates.isDying = false; + throw new Error(`Unknown HP change type: ${changeType}`); } const updatedParticipants = (encounter.participants || []).map(p => - p.id === participantId ? { ...p, ...updates } : p + p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p ); - - const message = status === 'stable' - ? `${name} stabilized (3 death saves)` - : status === 'dead' - ? `${name} failed 3 death saves — dead` - : `${name} death ${type}: ${next}/3`; - - return { - patch: { participants: updatedParticipants }, - log: { message, undo }, - status, - isDying: status === 'dead', // back-compat for App.js handler + const patch = { participants: updatedParticipants }; + const undoAmount = changeType === 'damage' ? amount : -amount; + const newP = updatedParticipants.find(p => p.id === participantId); + const newValues = { + currentHp: newP.currentHp, + status: newP.status, + deathSaveSuccesses: newP.deathSaveSuccesses || 0, + deathSaveFailures: newP.deathSaveFailures || 0, + isActive: newP.isActive, + conditions: [...(newP.conditions || [])], }; + const log = { + type: changeType, + participantId, participantName: participant.name, + message, + delta: logDelta, + undo: { amount: undoAmount, oldValues, newValues }, + }; + return commit(encounter, patch, log, ctx); } -// TOGGLE_CONDITION — verbatim from ParticipantManager.toggleCondition -function toggleCondition(encounter, participantId, conditionId) { +async function deathSave(encounter, participantId, outcome, ctx) { + const participant = (encounter.participants || []).find(p => p.id === participantId); + if (!participant) throw new Error('Participant not found.'); + const statusBefore = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious'); + if (statusBefore !== 'dying') throw new Error('Death saves only apply while dying.'); + if (!['success', 'fail', 'nat1', 'nat20'].includes(outcome)) { + throw new Error(`deathSave outcome must be success, fail, nat1, or nat20; got "${outcome}".`); + } + + const oldValues = { + currentHp: participant.currentHp, + status: statusBefore, + deathSaveSuccesses: participant.deathSaveSuccesses || 0, + deathSaveFailures: participant.deathSaveFailures || 0, + isActive: participant.isActive, + conditions: [...(participant.conditions || [])], + }; + + let updates; + if (outcome === 'nat20') { + updates = { currentHp: 1, status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0 }; + } else { + const successes = (participant.deathSaveSuccesses || 0) + (outcome === 'success' ? 1 : 0); + const failures = (participant.deathSaveFailures || 0) + (outcome === 'fail' ? 1 : outcome === 'nat1' ? 2 : 0); + if (failures >= 3) updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0 }; + else if (successes >= 3) updates = { currentHp: 0, status: 'stable', deathSaveSuccesses: 0, deathSaveFailures: 0 }; + else updates = { currentHp: 0, status: 'dying', deathSaveSuccesses: successes, deathSaveFailures: failures }; + } + + const name = participant.name; + const message = outcome === 'nat20' ? `Nat 20! ${name} restored to 1 HP, conscious` + : outcome === 'nat1' ? `Nat 1! ${name} takes 2 death save failures` + : updates.status === 'stable' ? `${name} stabilized (3 death saves)` + : updates.status === 'dead' ? `${name} failed 3 death saves — dead` + : `${name} death save ${outcome}`; + const updatedParticipants = (encounter.participants || []).map(p => + p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p + ); + const patch = { participants: updatedParticipants }; + const newP = updatedParticipants.find(p => p.id === participantId); + const newValues = { + currentHp: newP.currentHp, + status: newP.status, + deathSaveSuccesses: newP.deathSaveSuccesses || 0, + deathSaveFailures: newP.deathSaveFailures || 0, + isActive: newP.isActive, + conditions: [...(newP.conditions || [])], + }; + const log = { + type: 'death_save', + participantId, participantName: name, + message, + delta: { outcome, status: updates.status, deathSaveSuccesses: updates.deathSaveSuccesses, deathSaveFailures: updates.deathSaveFailures }, + undo: { oldValues, newValues }, + }; + const enc = await commit(encounter, patch, log, ctx); + return { enc, status: updates.status }; +} + +async function toggleCondition(encounter, participantId, conditionId, ctx) { const participant = (encounter.participants || []).find(p => p.id === participantId); if (!participant) throw new Error('Participant not found.'); const wasActive = (participant.conditions || []).includes(conditionId); @@ -565,136 +752,174 @@ function toggleCondition(encounter, participantId, conditionId) { const next = wasActive ? current.filter(c => c !== conditionId) : [...current, conditionId]; return { ...p, conditions: next }; }); - return { - patch: { participants: updatedParticipants }, - log: { - message: `${participant.name} ${wasActive ? 'lost' : 'gained'} ${conditionId}`, - undo: { participants: [...(encounter.participants || [])] }, - }, + const patch = { participants: updatedParticipants }; + const log = { + type: wasActive ? 'remove_condition' : 'add_condition', + participantId, participantName: participant.name, + message: `${participant.name} ${wasActive ? 'lost' : 'gained'} ${conditionId}`, + delta: { condition: conditionId, added: !wasActive }, + undo: { condition: conditionId }, }; + return commit(encounter, patch, log, ctx); } -// REORDER_PARTICIPANTS — drag. Same-initiative ONLY (tie-break override). -// Cross-init drag = no-op (use edit-initiative field instead). Splice move. -// Cross-pointer drag = no-op once encounter started (BUG-13). nextTurn walks -// turnOrderIds forward from current pointer. Dragging a not-yet-acted -// participant to a position behind the pointer would skip them (wrap past). -// Dragging an already-acted participant ahead of pointer would re-act them. -// Blocking cross-pointer keeps rotation deterministic. Pre-combat: free. -function reorderParticipants(encounter, draggedId, targetId) { +async function stabilizeParticipant(encounter, participantId, ctx) { + const participant = (encounter.participants || []).find(p => p.id === participantId); + if (!participant) throw new Error('Participant not found.'); + const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious'); + if (status === 'conscious' || participant.currentHp > 0) throw new Error('Cannot stabilize conscious participant.'); + if (status === 'dead') throw new Error('Cannot stabilize dead participant.'); + if (status === 'stable') return encounter; + + const updates = { currentHp: 0, status: 'stable', deathSaveSuccesses: 0, deathSaveFailures: 0 }; + const updatedParticipants = (encounter.participants || []).map(p => + p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p + ); + + const patch = { participants: updatedParticipants }; + const oldValues = { + currentHp: participant.currentHp, + status, + deathSaveSuccesses: participant.deathSaveSuccesses || 0, + deathSaveFailures: participant.deathSaveFailures || 0, + isActive: participant.isActive, + conditions: [...(participant.conditions || [])], + }; + const newP = updatedParticipants.find(p => p.id === participantId); + const newValues = { + currentHp: newP.currentHp, + status: newP.status, + deathSaveSuccesses: newP.deathSaveSuccesses || 0, + deathSaveFailures: newP.deathSaveFailures || 0, + isActive: newP.isActive, + conditions: [...(newP.conditions || [])], + }; + const log = { + type: 'stabilize', + participantId, + participantName: participant.name, + message: `${participant.name} stabilized at 0 HP`, + delta: { status: 'stable' }, + undo: { oldValues, newValues }, + }; + return commit(encounter, patch, log, ctx); +} + +async function reviveParticipant(encounter, participantId, ctx) { + const participant = (encounter.participants || []).find(p => p.id === participantId); + if (!participant) throw new Error('Participant not found.'); + const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious'); + if (status !== 'dead') return encounter; + + const updates = { currentHp: 0, status: 'stable', deathSaveSuccesses: 0, deathSaveFailures: 0, isActive: true }; + const updatedParticipants = (encounter.participants || []).map(p => + p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p + ); + + const patch = { participants: updatedParticipants }; + const oldValues = { + currentHp: participant.currentHp, + status, + deathSaveSuccesses: participant.deathSaveSuccesses || 0, + deathSaveFailures: participant.deathSaveFailures || 0, + isActive: participant.isActive, + conditions: [...(participant.conditions || [])], + }; + const newP = updatedParticipants.find(p => p.id === participantId); + const newValues = { + currentHp: newP.currentHp, + status: newP.status, + deathSaveSuccesses: newP.deathSaveSuccesses || 0, + deathSaveFailures: newP.deathSaveFailures || 0, + isActive: newP.isActive, + conditions: [...(newP.conditions || [])], + }; + const log = { + type: 'revive', + participantId, + participantName: participant.name, + message: `${participant.name} revived to 0 HP (stable, unconscious)`, + delta: { status: 'stable', currentHp: 0 }, + undo: { oldValues, newValues }, + }; + return commit(encounter, patch, log, ctx); +} + +async function reorderParticipants(encounter, draggedId, targetId, ctx) { const participants = [...(encounter.participants || [])]; const dragged = participants.find(p => p.id === draggedId); const target = participants.find(p => p.id === targetId); if (!dragged || !target) throw new Error('Dragged or target item not found.'); - if (draggedId === targetId) return { patch: null, log: null }; - if (dragged.initiative !== target.initiative) { - return { patch: null, log: null }; // cross-init blocked - } + if (draggedId === targetId) return encounter; // no-op, no write + if (dragged.initiative !== target.initiative) return encounter; // cross-init blocked const draggedIndex = participants.findIndex(p => p.id === draggedId); - // BUG-13: block cross-pointer reorder while encounter running. nextTurn - // walks turnOrderIds forward from current pointer. Dragging across the - // pointer makes who-acts-next ambiguous — either skip (upcoming dragged - // ahead of pointer, walk wraps past) or double-act (acted dragged behind). - // Full fix needs actedThisRound tracking. Pragmatic now: block both dirs. - // Pre-combat: free reorder (no pointer yet). - if (encounter.isStarted && encounter.currentTurnParticipantId) { + if (encounter.isStarted && !encounter.isPaused && encounter.currentTurnParticipantId) { const pointerIdx = participants.findIndex(p => p.id === encounter.currentTurnParticipantId); const targetIdx0 = participants.findIndex(p => p.id === targetId); if (pointerIdx >= 0) { const crosses = (a, b) => (a <= pointerIdx && b > pointerIdx) || (a > pointerIdx && b <= pointerIdx); - if (crosses(draggedIndex, targetIdx0)) { - return { patch: null, log: null }; // cross-pointer blocked - } + if (crosses(draggedIndex, targetIdx0)) return encounter; // cross-pointer blocked } } + const targetIndex = participants.findIndex(p => p.id === targetId); const [removedItem] = participants.splice(draggedIndex, 1); const newTargetIndex = participants.findIndex(p => p.id === targetId); - participants.splice(newTargetIndex, 0, removedItem); - const turnUpdates = syncTurnOrder(participants); // 1-list: always mirror - return { - patch: { participants, ...turnUpdates }, - log: { - message: `${removedItem.name} reordered before ${(participants.find(p => p.id === targetId) || {}).name || targetId}`, - undo: { - participants: encounter.participants || [], - turnOrderIds: encounter.turnOrderIds || [], - currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, - }, + const insertIndex = draggedIndex < targetIndex ? newTargetIndex + 1 : newTargetIndex; + participants.splice(insertIndex, 0, removedItem); + const patch = { participants, ...syncTurnOrder(participants) }; + const log = { + type: 'reorder', + participantId: draggedId, participantName: removedItem.name, + message: `${removedItem.name} reordered before ${(participants.find(p => p.id === targetId) || {}).name || targetId}`, + delta: { draggedId, targetId }, + undo: { + participants: encounter.participants || [], + turnOrderIds: encounter.turnOrderIds || [], + currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, + newParticipants: participants, + newTurnOrderIds: patch.turnOrderIds, }, }; + return commit(encounter, patch, log, ctx); } -// END_ENCOUNTER — verbatim from InitiativeControls.confirmEndEncounter -function endEncounter(encounter) { - return { - patch: { - isStarted: false, - isPaused: false, - currentTurnParticipantId: null, - round: 0, - turnOrderIds: [], - }, - log: { - message: `Combat ended: "${encounter.name}"`, - undo: { - isStarted: encounter.isStarted ?? false, - isPaused: encounter.isPaused ?? false, - round: encounter.round ?? 0, - currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, - turnOrderIds: [...(encounter.turnOrderIds || [])], - }, - }, +async function endEncounter(encounter, ctx) { + const patch = { isStarted: false, isPaused: false, currentTurnParticipantId: null, round: 0, turnOrderIds: [] }; + const log = { + type: 'end_encounter', + message: `Combat ended: "${encounter.name}"`, + delta: {}, + undo: { isStarted: encounter.isStarted ?? false, isPaused: encounter.isPaused ?? false, round: encounter.round ?? 0, currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, turnOrderIds: [...(encounter.turnOrderIds || [])] }, }; + return commit(encounter, patch, log, ctx); } // ---------------------------------------------------------------------------- -// Display lifecycle (activeDisplay/status doc). Pure patches, same shape as -// App's inline storage.updateDoc(getPath.activeDisplay(), ...). Single source. -// Callers persist patch to the activeDisplay/status doc. +// Display lifecycle — write activeDisplay doc, no combat log. +// ctx.displayPath required. // ---------------------------------------------------------------------------- -// ACTIVATE_DISPLAY — start combat: point player display at this encounter. -function activateDisplay({ campaignId, encounterId }) { - return { patch: { activeCampaignId: campaignId, activeEncounterId: encounterId } }; +async function activateDisplay({ campaignId, encounterId }, ctx) { + await ctx.storage.updateDoc(ctx.displayPath, { activeCampaignId: campaignId, activeEncounterId: encounterId }); } -// CLEAR_DISPLAY — end combat: player display shows nothing. -function clearDisplay() { - return { patch: { activeCampaignId: null, activeEncounterId: null } }; +async function clearDisplay(ctx) { + await ctx.storage.updateDoc(ctx.displayPath, { activeCampaignId: null, activeEncounterId: null }); } -// TOGGLE_HIDE_PLAYER_HP — flip player-HP visibility on display. -function toggleHidePlayerHp(currentValue) { - return { patch: { hidePlayerHp: !currentValue } }; +async function toggleHidePlayerHp(currentValue, ctx) { + await ctx.storage.updateDoc(ctx.displayPath, { hidePlayerHp: !currentValue }); } module.exports = { - DEFAULT_MAX_HP, - DEFAULT_INIT_MOD, - MONSTER_DEFAULT_INIT_MOD, - generateId, - rollD20, - formatInitMod, - sortParticipantsByInitiative, - syncTurnOrder, - computeTurnOrderAfterRemoval, - makeParticipant, - buildCharacterParticipant, - buildMonsterParticipant, - startEncounter, - nextTurn, - togglePause, - addParticipant, - addParticipants, - updateParticipant, - removeParticipant, - toggleParticipantActive, - applyHpChange, - deathSave, - toggleCondition, - reorderParticipants, - endEncounter, - activateDisplay, - clearDisplay, - toggleHidePlayerHp, + DEFAULT_MAX_HP, DEFAULT_INIT_MOD, MONSTER_DEFAULT_INIT_MOD, + generateId, rollD20, formatInitMod, + sortParticipantsByInitiative, syncTurnOrder, computeTurnOrderAfterRemoval, + snapshotOf, buildEntry, expandUndo, + makeParticipant, buildCharacterParticipant, buildMonsterParticipant, + startEncounter, nextTurn, togglePause, + addParticipant, addParticipants, updateParticipant, removeParticipant, + toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, toggleCondition, + reorderParticipants, endEncounter, + activateDisplay, clearDisplay, toggleHidePlayerHp, }; diff --git a/src/App.js b/src/App.js index ef1e225..671f20d 100644 --- a/src/App.js +++ b/src/App.js @@ -1,13 +1,13 @@ import React, { useState, useEffect, useRef, useMemo, createContext, useContext, useCallback } from 'react'; import * as shared from '@ttrpg/shared'; -import { initializeApp, getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken, getFirestore, orderBy, limit, getStorage, getStorageMode } from './storage'; +import { initializeApp, getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken, getFirestore, where, orderBy, limit, offset, getStorage, getStorageMode } from './storage'; import { PlusCircle, Users, Swords, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown, UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle, Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon, StopCircle as StopCircleIcon, Users2, Dices, ChevronUp, ChevronDown, ScrollText, - Maximize2, Minimize2, Moon, Coffee, Clock, ChevronRight, CheckCircle2, X, - Undo2, Redo2 + Maximize2, Minimize2, Moon, Coffee, Clock, ChevronRight, X, + Undo2, Redo2, Crosshair } from 'lucide-react'; // ----- UI feedback: toast (transient) + info modal (persistent) ----- @@ -118,16 +118,15 @@ const APP_VERSION = 'v0.4'; const { DEFAULT_MAX_HP, DEFAULT_INIT_MOD, MONSTER_DEFAULT_INIT_MOD, generateId, rollD20, formatInitMod, - sortParticipantsByInitiative, syncTurnOrder, - computeTurnOrderAfterRemoval, startEncounter, nextTurn, togglePause, endEncounter, addParticipant, addParticipants, updateParticipant, removeParticipant, toggleParticipantActive: combatToggleActive, applyHpChange: combatApplyHpChange, deathSave: combatDeathSave, + stabilizeParticipant, + reviveParticipant, toggleCondition: combatToggleCondition, reorderParticipants, - activateDisplay, clearDisplay, toggleHidePlayerHp: combatToggleHidePlayerHp, } = shared; const ROLL_DISPLAY_DURATION = 5000; @@ -235,18 +234,28 @@ const getPath = { // generateId, rollD20, formatInitMod, sortParticipantsByInitiative, // computeTurnOrderAfterRemoval/Addition: imported from @ttrpg/shared (1-list model). -const LOG_QUERY = [orderBy('timestamp', 'desc'), limit(500)]; +// Display limit: recent logs only. Download/copy fetch full set on demand. +// Keep small — 500 = ~900KB per snapshot = slow load. +const LOG_PAGE_SIZE = 100; +const LOG_ORDER = orderBy('ts', 'desc'); -const logAction = async (message, context = {}, undoData = null) => { - if (!db) return; - try { - const entry = { timestamp: Date.now(), message, ...context }; - if (undoData) entry.undo = undoData; - await storage.addDoc(getPath.logs(), entry); - } catch (err) { - console.error('Error writing log:', err); - } -}; +// Combat actions now live in shared/turn.js and WRITE THEIR OWN LOGS. +// Every mutating func is async, takes ctx {storage, encPath, logPath}, +// persists encounter patch + log entry itself. No separate logEvent call. +// +// buildCtx: standard context for combat action calls. Lazy getStorage() +// so handlers work even if module-level `storage` assigned late (test/auth). +const buildCtx = (encounterPath) => ({ + storage: getStorage(), + encPath: encounterPath, + logPath: getPath.logs(), +}); + +// Read undo payload: new lean `undo` (id-based) or legacy (`undo_payload`/`undo`). +// Lean events need expandUndo() at click time (needs current enc doc). +// expandUndo lives in shared/turn.js (re-exported via @ttrpg/shared). +const { expandUndo } = shared; +const getUndo = (entry) => entry.undo_payload || entry.undo || null; // ============================================================================ @@ -307,12 +316,14 @@ function useFirestoreCollection(collectionPath, queryConstraints = []) { setData(items); setIsLoading(false); }, queryConstraints, (err) => { - console.error(`Error fetching collection ${collectionPath}:`, err); + console.error(`[useFirestoreCollection] ERR ${collectionPath}:`, err); setError(err.message || "Failed to fetch collection."); setIsLoading(false); setData([]); }); - return () => { if (typeof unsubscribe === 'function') unsubscribe(); }; + return () => { + if (typeof unsubscribe === 'function') unsubscribe(); + }; // queryString, not array ref // eslint-disable-next-line react-hooks/exhaustive-deps }, [collectionPath, queryString]); @@ -349,8 +360,35 @@ function Modal({ onClose, title, children }) { } function ConfirmationModal({ isOpen, onClose, onConfirm, title, message }) { + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + // Reset transient state each time modal opens. + useEffect(() => { + if (isOpen) { setPending(false); setError(null); } + }, [isOpen]); + if (!isOpen) return null; + const handleConfirm = async () => { + if (pending) return; // double-click guard + setPending(true); + setError(null); + try { + await onConfirm(); + onClose(); + } catch (err) { + console.error('ConfirmationModal action failed:', err); + setError(err?.message || 'Action failed. Try again.'); + setPending(false); + } + }; + + const close = () => { + if (pending) return; // block close mid-action + onClose(); + }; + return (
@@ -359,18 +397,23 @@ function ConfirmationModal({ isOpen, onClose, onConfirm, title, message }) {

{title || "Confirm Action"}

{message || "Are you sure you want to proceed?"}

+ {error && ( +

{error}

+ )}
@@ -509,7 +552,7 @@ function EditParticipantModal({ participant, onClose, onSave }) { const [initiative, setInitiative] = useState(participant.initiative); const [currentHp, setCurrentHp] = useState(participant.currentHp); const [maxHp, setMaxHp] = useState(participant.maxHp); - const [isNpc, setIsNpc] = useState(participant.type === 'monster' ? (participant.isNpc || false) : false); + const [asNpc, setAsNpc] = useState(participant.type === 'npc'); const handleSubmit = (e) => { e.preventDefault(); @@ -518,7 +561,7 @@ function EditParticipantModal({ participant, onClose, onSave }) { initiative: parseInt(initiative, 10), currentHp: parseInt(currentHp, 10), maxHp: parseInt(maxHp, 10), - isNpc: participant.type === 'monster' ? isNpc : false, + type: participant.type === 'monster' || participant.type === 'npc' ? (asNpc ? 'npc' : 'monster') : participant.type, }); }; @@ -564,13 +607,13 @@ function EditParticipantModal({ participant, onClose, onSave }) { /> - {participant.type === 'monster' && ( + {(participant.type === 'monster' || participant.type === 'npc') && (
setIsNpc(e.target.checked)} + checked={asNpc} + onChange={(e) => setAsNpc(e.target.checked)} className="h-4 w-4 text-violet-600 border-stone-400 rounded focus:ring-violet-500" />