Implement D&D 5e death-save state machine and cleanup combat ordering
- Add status-driven death-save model: - status: conscious | dying | stable | dead - deathSaveSuccesses/deathSaveFailures as display counters - remove old death-save fields as source of truth - Add death-save actions: - success, fail, nat1, nat20 - stabilize participant - revive dead participant to 0 HP, stable, unconscious - Apply 5e HP transition rules: - characters and NPCs at 0 HP become dying - stable/dying participants gain unconscious condition - healing clears death saves and returns conscious - massive damage kills - non-NPC monsters at 0 HP become dead and inactive - Split NPCs from monsters with type="npc": - NPCs use death saves like characters - NPCs remain DM-controlled/monster-colored in UI - new NPCs no longer persist isNpc flag - Keep dead PCs/NPCs in encounter and initiative until DM removes or deactivates - Allow DM active/inactive toggle for dead participants - Hide inactive participants from player display - Improve DM and player death-state UI: - show Dying/Dead/Unconscious states consistently - add revive button for dead participants - distinguish dead visual from inactive visual in DM view - Fix initiative drag/reorder behavior: - downward drag now changes order instead of no-op - paused combat can reorder across current turn pointer - turnOrderIds stay synced to participants order - Persist campaign collapse state in localStorage - Update death-save docs and encounter builder docs - Add/expand tests for: - death saves and HP transitions - dead/active/inactive behavior - NPC death-save behavior - player display visibility - drag reorder semantics - logging expectations Tests: - npm run test:all - app: 94 passed - shared: 158 passed, 5 skipped - server: 32 passed
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
# Death Saves 5e - Implementation Plan
|
||||
|
||||
TDD required. Tests first, implementation after.
|
||||
|
||||
## Gap Analysis
|
||||
|
||||
**Current:**
|
||||
- ✅ Separate success/fail counters (deathSaves, deathFails)
|
||||
- ✅ 3 success → stable, 3 fail → dead
|
||||
- ✅ UI shows ✓/✕ buttons, status badges
|
||||
|
||||
**Missing (from DEATH_SAVES.md):**
|
||||
- ❌ Nat 20: 1 HP + conscious, reset counters
|
||||
- ❌ Nat 1: 2 failures
|
||||
- ❌ Damage at 0 HP → add failures (normal=1, crit=2)
|
||||
- ❌ Massive damage: damage >= currentHp + maxHp → instant dead
|
||||
- ❌ Stabilize manual action
|
||||
- ❌ Healing resets counters
|
||||
- ❌ Damage breaks stable → back to dying
|
||||
|
||||
**Test Gap:**
|
||||
- turn.deathsave.test.js: basic counters only
|
||||
- turn.combat.test.js: single deathSave call, no coverage
|
||||
- Combat.scenario.test.js: basic fail/death flow
|
||||
- No nat20, nat1, damage-at-0, massive damage, stabilize, healing integration
|
||||
|
||||
## Phase 1: Extend Tests (TDD)
|
||||
|
||||
### File: `shared/tests/turn.deathsave.nat20.test.js`
|
||||
|
||||
Test cases:
|
||||
- Nat20 → hp=1, deathSaves=0, deathFails=0, isDying=false, isStable=false, status="conscious"
|
||||
- Nat20 overrides existing saves/fails
|
||||
- Nat20 log entry correct
|
||||
|
||||
### File: `shared/tests/turn.deathsave.nat1.test.js`
|
||||
|
||||
Test cases:
|
||||
- Nat1 → deathFails += 2
|
||||
- Nat1 triggers instant death (3 fails total)
|
||||
- Nat1 log entry correct
|
||||
|
||||
### File: `shared/tests/turn.deathsave.damage.test.js`
|
||||
|
||||
Test cases:
|
||||
- Damage at 0 HP → deathFails += 1
|
||||
- Crit damage at 0 HP → deathFails += 2
|
||||
- Mixed: save success + damage → independent tracking works
|
||||
- Damage log includes "while dying"
|
||||
|
||||
### File: `shared/tests/turn.deathsave.massive.test.js`
|
||||
|
||||
Test cases:
|
||||
- Damage >= currentHp + maxHp → instant dead
|
||||
- Bypasses death saves entirely
|
||||
- Log entry shows "massive damage"
|
||||
|
||||
### File: `shared/tests/turn.deathsave.stabilize.test.js`
|
||||
|
||||
Test cases:
|
||||
- Stabilize → hp=0, isStable=true, isDying=false, counters=0
|
||||
- Stabilize from dying state
|
||||
- Stabilize log entry correct
|
||||
|
||||
### File: `shared/tests/turn.deathsave.heal.test.js`
|
||||
|
||||
Test cases:
|
||||
- Healing at 0 HP → reset counters, isDying=false, isStable=false
|
||||
- Healing at stable → same reset
|
||||
- Healing log includes "cleared death saves"
|
||||
|
||||
### File: `shared/tests/turn.deathsave.stable.test.js`
|
||||
|
||||
Test cases:
|
||||
- Damage to stable → isStable=false, isDying=true, counters reset
|
||||
- Stable takes damage → fresh death save loop
|
||||
|
||||
### File: `shared/tests/turn.deathsave.undo.test.js`
|
||||
|
||||
Test cases:
|
||||
- Undo deathSave restores previous state
|
||||
- Undo nat20 restores counters
|
||||
- Undo damage at 0 HP restores fail count
|
||||
- Undo stabilize restores dying state
|
||||
|
||||
## Phase 2: Combat Test Integration
|
||||
|
||||
### Update `shared/tests/turn.combat.test.js`
|
||||
|
||||
Add phases:
|
||||
- Nat20 phase: roll nat20, verify 1 HP + conscious
|
||||
- Nat1 phase: roll nat1, verify 2 failures
|
||||
- Damage-at-zero phase: normal damage + crit damage at 0 HP
|
||||
- Massive damage phase: instant death bypass
|
||||
- Stabilize phase: stabilize dying character
|
||||
- Healing-reset phase: heal at 0 HP, verify counters reset
|
||||
- Assert death save state at each phase
|
||||
|
||||
### Update `src/tests/Combat.scenario.test.js`
|
||||
|
||||
Add action functions:
|
||||
- deathSaveActionNat20(name)
|
||||
- deathSaveActionNat1(name)
|
||||
- stabilizeAction(name)
|
||||
- applyDamageAtZero(name, amount, isCrit)
|
||||
- applyMassiveDamage(name)
|
||||
- healingResetAction(name, amount)
|
||||
|
||||
Add scenario phases:
|
||||
- Round 10-20: nat20/nat1 scenarios
|
||||
- Round 30-40: damage-at-zero scenarios (normal + crit)
|
||||
- Round 50-60: stabilize scenarios
|
||||
- Round 70-80: massive damage scenarios
|
||||
- Round 90-100: healing-reset scenarios
|
||||
- Verify log entries include all death save types
|
||||
|
||||
### Update `scripts/combat.js`
|
||||
|
||||
Verify death save events:
|
||||
- Check death_save events in log
|
||||
- Verify nat20, nat1, damage_at_zero, massive_damage log types
|
||||
- Verify stabilize events
|
||||
- Verify healing_reset events
|
||||
|
||||
## Phase 3: Extend `deathSave` Function
|
||||
|
||||
Add roll result parameter:
|
||||
```ts
|
||||
async function deathSave(encounter, participantId, type, n, rollResult, ctx) {
|
||||
// rollResult: number (1-20) or null for manual input
|
||||
}
|
||||
```
|
||||
|
||||
Handle nat20:
|
||||
```ts
|
||||
if (rollResult === 20) {
|
||||
updates.hp = 1;
|
||||
updates.deathSaves = 0;
|
||||
updates.deathFails = 0;
|
||||
updates.isDying = false;
|
||||
updates.isStable = false;
|
||||
status = 'conscious';
|
||||
}
|
||||
```
|
||||
|
||||
Handle nat1:
|
||||
```ts
|
||||
if (rollResult === 1) {
|
||||
const currentFails = participant.deathFails || 0;
|
||||
updates.deathFails = Math.min(3, currentFails + 2);
|
||||
// Check for death...
|
||||
}
|
||||
```
|
||||
|
||||
## Phase 4: Extend `applyHpChange` Function
|
||||
|
||||
Add damage-at-zero logic:
|
||||
```ts
|
||||
if (changeType === 'damage' && participant.currentHp === 0) {
|
||||
const failuresToAdd = isCritical ? 2 : 1;
|
||||
const currentFails = participant.deathFails || 0;
|
||||
updates.deathFails = Math.min(3, currentFails + failuresToAdd);
|
||||
updates.isStable = false;
|
||||
// Check for death...
|
||||
}
|
||||
```
|
||||
|
||||
Add massive damage rule:
|
||||
```ts
|
||||
if (changeType === 'damage' && damage >= participant.currentHp + participant.maxHp) {
|
||||
updates.hp = 0;
|
||||
updates.isDying = true;
|
||||
updates.deathSaves = 0;
|
||||
updates.deathFails = 3;
|
||||
updates.isStable = false;
|
||||
}
|
||||
```
|
||||
|
||||
Add healing reset logic:
|
||||
```ts
|
||||
if (changeType === 'heal' && (participant.currentHp === 0 || participant.isStable || participant.isDying)) {
|
||||
updates.deathSaves = 0;
|
||||
updates.deathFails = 0;
|
||||
updates.isDying = false;
|
||||
updates.isStable = false;
|
||||
}
|
||||
```
|
||||
|
||||
## Phase 5: New `stabilizeParticipant` Function
|
||||
|
||||
```ts
|
||||
async function stabilizeParticipant(encounter, participantId, ctx) {
|
||||
// hp=0, isStable=true, isDying=false, counters=0
|
||||
// Log: "Stabilized"
|
||||
}
|
||||
```
|
||||
|
||||
## Phase 6: UI Extensions
|
||||
|
||||
### Phase 6a: Roll Result Modal
|
||||
|
||||
Add roll result input:
|
||||
- Modal when clicking death save button
|
||||
- Input: d20 result (1-20) or "manual"
|
||||
- Auto-detect nat20/nat1
|
||||
|
||||
### Phase 6b: Nat1/Nat20/Stabilize Buttons
|
||||
|
||||
Death save row layout:
|
||||
```
|
||||
[Saves: □ □ □] [Fails: □ □ □]
|
||||
[Nat1] [Nat20] [Stabilize]
|
||||
```
|
||||
|
||||
**Nat1 button:**
|
||||
- Red icon (AlertTriangle)
|
||||
- Click → deathSave(participantId, 'fail', null, 1, ctx)
|
||||
- Adds 2 failures immediately
|
||||
- Log: "Nat 1! [name] takes 2 death save failures"
|
||||
- Disable if deathFails >= 2 (would cause death anyway)
|
||||
|
||||
**Nat20 button:**
|
||||
- Gold icon (Zap)
|
||||
- Click → deathSave(participantId, 'success', null, 20, ctx)
|
||||
- Sets hp=1, resets counters, conscious
|
||||
- Log: "Nat 20! [name] restored to 1 HP, conscious"
|
||||
|
||||
**Stabilize button:**
|
||||
- Emerald icon (HeartPulse)
|
||||
- Only show when dying (hp=0, !isStable, !dead)
|
||||
- Click → stabilizeParticipant(participantId, ctx)
|
||||
- Sets isStable=true, counters=0
|
||||
- Log: "[name] stabilized at 0 HP"
|
||||
- Hide if already isStable
|
||||
|
||||
**Conditional visibility:**
|
||||
- All buttons: only when hp === 0 && !dead
|
||||
- Stabilize: hide if already isStable
|
||||
- Nat1: disable if deathFails >= 2
|
||||
|
||||
### Phase 6c: Crit Checkbox for Damage
|
||||
|
||||
Add crit checkbox:
|
||||
- For damage at 0 HP
|
||||
- Pass crit to applyHpChange
|
||||
- UI: checkbox "Critical hit?"
|
||||
|
||||
### Phase 6d: Enhanced Log Messages
|
||||
|
||||
- Nat20: "Nat 20! [name] restored to 1 HP, conscious"
|
||||
- Nat1: "Nat 1! [name] takes 2 death save failures"
|
||||
- Damage at 0: "[name] takes [n] damage while dying"
|
||||
- Massive damage: "Massive damage! [name] instantly killed"
|
||||
- Stabilize: "[name] stabilized at 0 HP"
|
||||
- Healing reset: "[name] healed to [n] HP, death saves cleared"
|
||||
|
||||
## Phase 7: Integration Tests
|
||||
|
||||
File: `shared/tests/turn.deathsave.flow.test.js`
|
||||
|
||||
Test full scenarios:
|
||||
- Dying → nat20 → conscious → take damage → dying
|
||||
- Dying → nat1 → 2 fails → another fail → dead
|
||||
- Dying → stabilize → damage → dying again
|
||||
- Massive damage bypasses all
|
||||
- Healing resets mid-death-save loop
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. Phase 1: Write all test files (fail expected)
|
||||
2. Phase 2: Update combat tests (fail expected)
|
||||
3. Phase 3: Extend deathSave, run tests
|
||||
4. Phase 4: Extend applyHpChange, run tests
|
||||
5. Phase 5: Add stabilizeParticipant, run tests
|
||||
6. Phase 6a: Roll result modal
|
||||
7. Phase 6b: Nat1/Nat20/Stabilize buttons
|
||||
8. Phase 6c: Crit checkbox
|
||||
9. Phase 6d: Enhanced log messages
|
||||
10. Phase 7: Integration tests
|
||||
11. Manual testing: full scenarios in dev server
|
||||
|
||||
## Risk Notes
|
||||
|
||||
- Roll result input: optional field (DM may not want to input every roll)
|
||||
- Crit detection: manual checkbox (no way to auto-detect)
|
||||
- Massive damage: optional rule (some tables ignore)
|
||||
- Backward compat: existing API must still work with null rollResult
|
||||
- Nat1/Nat20/Stabilize buttons: DM-optional, not required to use
|
||||
@@ -4,6 +4,37 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
|
||||
|
||||
## Open
|
||||
|
||||
### FEAT: critical damage button for dying/stable participants
|
||||
- Shared logic already supports `applyHpChange(..., { isCriticalHit:true })`.
|
||||
- UI only has normal Damage, which adds 1 failure at 0 HP.
|
||||
- Add button near Revive/Stabilize: **Crit Damage** / **Critical Hit**.
|
||||
- Use only for dying/stable character/NPC at 0 HP; adds 2 death-save failures.
|
||||
- Dead target remains dead; no action.
|
||||
|
||||
### dm list - keep active particpant in view (scroll)
|
||||
|
||||
|
||||
#### debug feature - button to delet all campaigns - only in dev builds
|
||||
|
||||
### bug - start/end/resume combat should not be in undo I think...
|
||||
|
||||
### combat.js doesnt seem to actually exercise the add characters. there are no characters in the campaign. only in the encounter..
|
||||
|
||||
|
||||
## dying>stabe state - add +1hp in 1d4 hours DISPLAY (maybe show a rolled hours too) for while stable
|
||||
|
||||
### 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.
|
||||
-
|
||||
|
||||
|
||||
|
||||
### 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.
|
||||
@@ -45,7 +76,12 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
|
||||
- Decide UX before fix.
|
||||
|
||||
|
||||
### death saves feature - dead PCs should not get removed from initiative! DAMMIT!
|
||||
### 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
|
||||
@@ -120,12 +156,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.
|
||||
@@ -199,9 +236,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.
|
||||
|
||||
@@ -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)
|
||||
+1
-1
@@ -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
|
||||
|
||||
+28
-18
@@ -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.
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
# Log System Redesign Plan
|
||||
|
||||
**Status:** DRAFT — not started
|
||||
**Branch:** `feat-turn-writes-logs`
|
||||
**Goal:** Lean mutation deltas. Kill bloated payload/undo duplication.
|
||||
|
||||
## Problem
|
||||
|
||||
Current events = 10-16KB each. `add_participant` row = 16KB to say "Fighter added, init 22." Root cause: full participant roster stored 3x (payload + undo_payload.updates + undo_payload.redo).
|
||||
|
||||
DB: 2486 docs, most bloated. Replay trace 1.6-5.5MB for ~250 steps.
|
||||
|
||||
Old log (pre-refactor) = message string + small undo object. Proven worked for undo/redo + viewer. Just wanted MORE STRUCTURE, not MORE DATA. Overbuilt instead.
|
||||
|
||||
## Intention
|
||||
|
||||
Each event = mutation delta + lean snapshot. Structured for filter/group/analyzer. No roster arrays.
|
||||
|
||||
### Canonical lean event shape
|
||||
|
||||
```js
|
||||
{
|
||||
id, ts, type, // identity + filter key
|
||||
message, // human text (viewer displays)
|
||||
encounterId, encounterName, // which encounter
|
||||
participantId, participantName, // who (nullable: pause/nextTurn have none)
|
||||
// type-specific scalar delta (one of these clusters per type):
|
||||
// add: { init, maxHp, type: 'character'|'monster' }
|
||||
// remove: { dead: bool }
|
||||
// damage/heal:{ amount }
|
||||
// condition: { condition }
|
||||
// toggleActive:{ revive: bool }
|
||||
// reorder: { draggedId, targetId }
|
||||
// update: { changedFields: ['notes', ...] }
|
||||
// nextTurn: (none — snapshot carries new round/pointer)
|
||||
// pause/resume/start/end: (none)
|
||||
snapshot: { // lean, ALREADY GOOD — keep as-is
|
||||
round, currentTurnParticipantId, turnOrderIds, activeIds
|
||||
},
|
||||
undo: <type-specific inverse, id-based> // minimal
|
||||
}
|
||||
```
|
||||
|
||||
### Undo inverses (id-based, minimal)
|
||||
|
||||
| type | undo |
|
||||
|------|------|
|
||||
| add_participant | `{ participantId }` → removeParticipant by id |
|
||||
| remove_participant | full participant obj (must restore — only place full obj needed) |
|
||||
| damage | `{ amount: -amount }` or heal back |
|
||||
| heal | `{ amount: -amount }` or damage back |
|
||||
| condition | `{ condition }` (toggle is its own inverse) |
|
||||
| toggleActive | `{ revive: !revive }` |
|
||||
| reorder | `{ draggedId, targetId }` (swap back) |
|
||||
| update | `{ changedFields: [...], oldValues: {...} }` |
|
||||
| nextTurn | `{ currentTurnParticipantId, round }` from snapshot delta |
|
||||
| pause/resume | toggle |
|
||||
| start/end | full prior scalar state |
|
||||
|
||||
**No `redo` field.** Redo = re-apply forward delta from event itself.
|
||||
|
||||
**No `undo_payload` wrapper.** Just `undo` object.
|
||||
|
||||
## 4 Consumers
|
||||
|
||||
1. **Viewer** — shows `message` + `type` + `participantName` + `undone` flag. Legacy logs (no type) display message only.
|
||||
2. **Undo/redo** — apply `undo` inverse by id. Id-based, minimal.
|
||||
3. **Replay** — turn.js funcs write own lean events. Trust func return. NO read-back. JSONL trace = lean snapshots + types.
|
||||
4. **Analyzer** — reads `snapshot` sequence + `type`. Reconstructs roster from event stream (add/remove by id). NO payload arrays needed.
|
||||
|
||||
## Legacy Support
|
||||
|
||||
- **Viewer:** yes — show old message-only logs, no type parse
|
||||
- **Undo:** no — old logs unundoable (acceptable, sit inert)
|
||||
- **Analyzer:** best-effort — snapshot may be absent on legacy → skip invariant check for that run
|
||||
|
||||
## Why Lean
|
||||
|
||||
- `add_participant` 16KB → ~150 bytes
|
||||
- Replay trace 5.5MB → ~250KB
|
||||
- Undo still works (proven old log did same)
|
||||
- Analyzer unaffected (uses snapshot+type, not payload)
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 1: Redesign event writers (shared/turn.js)
|
||||
|
||||
- [ ] Rewrite `buildEntry()` to produce lean shape (delta fields per type)
|
||||
- [ ] Rewrite `commit()` — no patch storage, write lean event
|
||||
- [ ] Per-func: compute minimal delta + inverse from enc before/after
|
||||
- [ ] Drop `payload`, `undo_payload`, `undo_payload.redo`
|
||||
|
||||
### Phase 2: Update undo executor
|
||||
|
||||
- [ ] Find undo consumer (App.js `applyUndo` or similar)
|
||||
- [ ] Switch to id-based inverse application
|
||||
- [ ] Handle each type's undo shape
|
||||
- [ ] Drop `transactionalUndo` full-patch path if exists
|
||||
|
||||
### Phase 3: Update replay trace
|
||||
|
||||
- [ ] `replay-combat.js` callStep: trust return, snapshot only (no read-back)
|
||||
- [ ] Trace = `{step, ts, type, pre:leanSnap, post:leanSnap}` — no participant arrays
|
||||
|
||||
### Phase 4: Update analyzer
|
||||
|
||||
- [ ] `analyze-turns.js` already uses snapshot+type only — verify no payload dep
|
||||
- [ ] Reconstruct roster from event stream for skip/double-act checks (id-level)
|
||||
|
||||
### Phase 5: Legacy compat
|
||||
|
||||
- [ ] `logEvent.js normalizeEvent` — lift legacy into lean shape (type='legacy', message only)
|
||||
- [ ] Viewer handles legacy gracefully
|
||||
- [ ] Download = lean events only (legacy excluded or message-only)
|
||||
|
||||
### Phase 6: Cleanup
|
||||
|
||||
- [ ] Delete old bloated docs from DB? Or leave (inert, viewer reads message)?
|
||||
- [ ] Verify build CI=true passes
|
||||
- [ ] Verify analyzer clean on fresh replay
|
||||
- [ ] Verify undo works on new events
|
||||
|
||||
## NOT in scope
|
||||
|
||||
- Fixing 5 pre-existing initiative bugs (logged in TODO.md, separate)
|
||||
- Changing snapshot shape (already lean)
|
||||
- Changing viewer UI (just data source)
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Undo for `remove_participant`: must restore full obj (only place full obj needed). Acceptable?
|
||||
- Should legacy logs be deleted from DB or left inert?
|
||||
- Download format: lean JSON array (no legacy) — confirm
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -301,11 +301,11 @@ module.exports = async function replay(args) {
|
||||
(e) => updateParticipant(e, actor.id, { notes: `edited r${enc.round}` }, ctx))).enc;
|
||||
}
|
||||
|
||||
if (actor.currentHp <= 0 && !actor.isNpc) {
|
||||
if (actor.status === 'dying' && !actor.isNpc) {
|
||||
if (VERBOSE) console.log(` deathSave ${actor.name} +1 success`);
|
||||
enc = (await callStep('deathSave',
|
||||
{ participant: actor.name, type: 'success', n: 1 },
|
||||
(e) => deathSave(e, actor.id, 'success', 1, ctx))).enc;
|
||||
{ participant: actor.name, outcome: 'success' },
|
||||
(e) => deathSave(e, actor.id, 'success', ctx))).enc;
|
||||
}
|
||||
|
||||
if (totalTurns % 9 === 0) {
|
||||
|
||||
@@ -35,6 +35,7 @@ function createMockStorage() {
|
||||
// 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); },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,8 +64,8 @@ function readyEnc(opts = {}) {
|
||||
currentTurnParticipantId: null,
|
||||
turnOrderIds: [],
|
||||
participants: opts.participants || [
|
||||
{ id: 'p1', name: 'Bob', type: 'character', initiative: 10, maxHp: 10, currentHp: 10, isActive: true, conditions: [], deathSaves: 0, deathFails: 0, isStable: false, isDying: false },
|
||||
{ id: 'p2', name: 'Mob', type: 'monster', initiative: 5, maxHp: 10, currentHp: 10, isActive: true, conditions: [], deathSaves: 0, deathFails: 0, isStable: false, isDying: false },
|
||||
{ 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 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -245,27 +245,26 @@ describe('applyHpChange', () => {
|
||||
expect(newEnc.participants[0].currentHp).toBe(10);
|
||||
});
|
||||
|
||||
test('damage to 0 deactivates + keeps turn order (unified)', async () => {
|
||||
// Unified: death flips isActive=false (removed from active rotation).
|
||||
// turnOrderIds unchanged (no turn-order patch on death).
|
||||
test('damage to 0 marks dying, keeps active, keeps turn order', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
const ps = [p('a', 10, { currentHp: 3 }), p('b', 5)];
|
||||
const ps = [p('a', 10, { type: 'character', currentHp: 3 }), p('b', 5)];
|
||||
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' });
|
||||
const newEnc = await applyHpChange(e, 'a', 'damage', 5, ctx);
|
||||
expect(newEnc.participants[0].currentHp).toBe(0);
|
||||
expect(newEnc.participants[0].isActive).toBe(false);
|
||||
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)', async () => {
|
||||
// Unified: revive from 0 flips isActive=true, deathSaves reset.
|
||||
test('heal from dying makes conscious and resets death-save counters', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 10, { currentHp: 0, isActive: false, deathSaves: 2 })];
|
||||
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].isActive).toBe(true);
|
||||
expect(newEnc.participants[0].deathSaves).toBe(0);
|
||||
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', async () => {
|
||||
@@ -285,27 +284,21 @@ describe('applyHpChange', () => {
|
||||
});
|
||||
|
||||
describe('deathSave', () => {
|
||||
test('increments fails', async () => {
|
||||
test('fail increments failure counter while dying', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 10, { currentHp: 0, deathFails: 0 })];
|
||||
const { enc: newEnc } = await deathSave(enc(ps), 'a', 'fail', 1, ctx);
|
||||
expect(newEnc.participants[0].deathFails).toBe(1);
|
||||
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)', async () => {
|
||||
test('third fail makes dead and resets counters', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 10, { currentHp: 0, deathFails: 2 })];
|
||||
const { enc: newEnc } = await deathSave(enc(ps), 'a', 'fail', 2, ctx);
|
||||
expect(newEnc.participants[0].deathFails).toBe(1);
|
||||
});
|
||||
|
||||
test('third fail sets isDying', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [p('a', 10, { currentHp: 0, deathFails: 2 })];
|
||||
const { enc: newEnc, isDying } = await deathSave(enc(ps), 'a', 'fail', 3, ctx);
|
||||
expect(newEnc.participants[0].deathFails).toBe(3);
|
||||
expect(newEnc.participants[0].isDying).toBe(true);
|
||||
expect(isDying).toBe(true);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -326,12 +319,12 @@ describe('toggleCondition', () => {
|
||||
});
|
||||
|
||||
describe('reorderParticipants', () => {
|
||||
test('drag before target (same-init tie)', async () => {
|
||||
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 newEnc = await reorderParticipants(enc(ps), 'a', 'c', ctx);
|
||||
// drag a before c: remove a → [b,c], insert before c → [b,a,c]
|
||||
expect(newEnc.participants.map(x => x.id)).toEqual(['b', 'a', 'c']);
|
||||
// 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)', async () => {
|
||||
|
||||
@@ -56,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' };
|
||||
@@ -163,12 +163,12 @@ describe('combat integrity (100 rounds, full op coverage)', () => {
|
||||
}
|
||||
}
|
||||
// 5. deathSave
|
||||
if (actor && actor.currentHp <= 0 && !actor.isNpc) {
|
||||
try { const r = await deathSave(e, actor.id, 'fail', 1, ctx); e = r.enc; } 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(part => (part.isDying || part.currentHp <= 0) && (part.isNpc || part.name.startsWith('Goblin') || part.name === 'OrcBoss' || part.name === 'Wolf'));
|
||||
const dead = e.participants.find(part => (part.status === 'dead' || part.currentHp <= 0) && (part.type === 'npc' || part.name.startsWith('Goblin') || part.name === 'OrcBoss' || part.name === 'Wolf'));
|
||||
if (dead) { try { e = await removeParticipant(e, dead.id, ctx); } catch (err) {} }
|
||||
}
|
||||
// 7. addParticipant
|
||||
|
||||
@@ -1,28 +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.
|
||||
//
|
||||
// New API: mutating funcs are async, take ctx last, write encounter + log
|
||||
// internally, return newEnc. Chained calls become `e = await fn(e, ctx)`.
|
||||
'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,
|
||||
});
|
||||
}
|
||||
@@ -30,69 +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)', async () => {
|
||||
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();
|
||||
const ps = [pc('a', 20), pc('b', 15), pc('c', 10)];
|
||||
let e = enc(ps);
|
||||
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 = 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)', async () => {
|
||||
test('dead PC remains in participants and turnOrderIds after three failures', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [pc('a', 20), pc('b', 15), pc('c', 10)];
|
||||
let e = enc(ps);
|
||||
let e = enc([pc('a', 20), pc('b', 15), pc('c', 10)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
// kill b
|
||||
const orderBefore = e.turnOrderIds.slice();
|
||||
e = await applyHpChange(e, 'b', 'damage', 100, ctx);
|
||||
// advance: a→c (b skipped, inactive)
|
||||
|
||||
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('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('c');
|
||||
|
||||
expect(e.currentTurnParticipantId).toBe('b');
|
||||
expect(byId(e, 'b').status).toBe('dead');
|
||||
});
|
||||
|
||||
test('dead PC deathSave fires on manual call (not via rotation)', async () => {
|
||||
test('deathSave rejected/no-op once dead', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [pc('a', 20), pc('b', 15)];
|
||||
let e = enc(ps);
|
||||
let e = enc([pc('a', 20), pc('b', 15)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
// kill b (current = a)
|
||||
e = await applyHpChange(e, 'b', 'damage', 100, ctx);
|
||||
// b is dead: DM can still fire deathSave (manual action)
|
||||
const { enc: newEnc } = await deathSave(e, 'b', 'fail', 1, ctx);
|
||||
const b = newEnc.participants.find(x => x.id === 'b');
|
||||
expect(b.deathFails).toBe(1);
|
||||
});
|
||||
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');
|
||||
|
||||
// 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', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [pc('a', 20), pc('b', 15)];
|
||||
let e = enc(ps);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'b', 'damage', 100, ctx);
|
||||
const b = e.participants.find(x => x.id === 'b');
|
||||
expect(b.isActive).toBe(false);
|
||||
});
|
||||
try { e = (await deathSave(e, 'b', 'fail', ctx)).enc; } catch (err) {}
|
||||
|
||||
test('revive (heal from 0) reactivates participant', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
const ps = [pc('a', 20), pc('b', 15)];
|
||||
let e = enc(ps);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'b', 'damage', 100, ctx);
|
||||
const dead = e.participants.find(x => x.id === 'b');
|
||||
expect(dead.isActive).toBe(false);
|
||||
// heal b back up
|
||||
e = await applyHpChange(e, 'b', 'heal', 50, ctx);
|
||||
const revived = e.participants.find(x => x.id === 'b');
|
||||
expect(revived.isActive).toBe(true);
|
||||
expect(revived.currentHp).toBe(50);
|
||||
expect(revived.deathSaves).toBe(0);
|
||||
expect(byId(e, 'b')).toMatchObject(before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,92 +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 was: prove current can't model 3-success stability.
|
||||
//
|
||||
// New API: deathSave is async, takes ctx, returns { enc, status, isDying }.
|
||||
// applyHpChange + startEncounter are async, take ctx, return newEnc.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { makeParticipant, startEncounter, applyHpChange, deathSave } = require('@ttrpg/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,
|
||||
});
|
||||
}
|
||||
|
||||
describe('death saves: missing success/stable model', () => {
|
||||
test('3 successes makes character stable (not dead, not dying)', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
// Character at 0hp. Roll 3 successful death saves.
|
||||
let e = enc([p('a')]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // 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.
|
||||
let res;
|
||||
res = await deathSave(e, 'a', 'success', 1, ctx); e = res.enc;
|
||||
res = await deathSave(e, 'a', 'success', 2, ctx); e = res.enc;
|
||||
res = await deathSave(e, 'a', 'success', 3, ctx); e = res.enc;
|
||||
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 };
|
||||
}
|
||||
|
||||
expect(res.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)', async () => {
|
||||
test('damage drops non-NPC monster to dead/inactive, not dying', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([p('a')]);
|
||||
let e = enc([monster('a')]);
|
||||
e = await startEncounter(e, ctx);
|
||||
const orderBefore = [...e.turnOrderIds];
|
||||
|
||||
e = await applyHpChange(e, 'a', 'damage', 100, ctx);
|
||||
|
||||
let res;
|
||||
res = await deathSave(e, 'a', 'fail', 1, ctx); e = res.enc;
|
||||
res = await deathSave(e, 'a', 'fail', 2, ctx); e = res.enc;
|
||||
res = await deathSave(e, 'a', 'fail', 3, ctx); e = res.enc;
|
||||
expect(part(e).status).toBe('dead');
|
||||
expect(part(e).deathSaveSuccesses).toBe(0);
|
||||
expect(part(e).deathSaveFailures).toBe(0);
|
||||
expect(part(e).conditions).not.toContain('unconscious');
|
||||
expect(part(e).isActive).toBe(false);
|
||||
expect(e.turnOrderIds).toEqual(orderBefore);
|
||||
});
|
||||
|
||||
test('dead non-NPC monster with stale active flag repairs to inactive', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([monster('a', { currentHp: 0, status: 'dead', isActive: true })]);
|
||||
e = await startEncounter(e, ctx);
|
||||
|
||||
e = await applyHpChange(e, 'a', 'damage', 1, ctx);
|
||||
|
||||
expect(part(e).status).toBe('dead');
|
||||
expect(part(e).isActive).toBe(false);
|
||||
});
|
||||
|
||||
test('damage drops NPC to dying', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
let e = enc([monster('a', { type: 'npc' })]);
|
||||
e = await startEncounter(e, ctx);
|
||||
|
||||
e = await applyHpChange(e, 'a', 'damage', 100, ctx);
|
||||
|
||||
expect(part(e).type).toBe('npc');
|
||||
expect(part(e).status).toBe('dying');
|
||||
expect(part(e).conditions).toContain('unconscious');
|
||||
});
|
||||
|
||||
test('three failures immediately makes dead, resets counters, keeps participant and turn order', async () => {
|
||||
let { e, ctx, originalTurnOrderIds } = await startedAtZero();
|
||||
|
||||
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||
expect(part(e).status).toBe('dying');
|
||||
expect(part(e).deathSaveFailures).toBe(1);
|
||||
|
||||
e = (await deathSave(e, 'a', 'fail', ctx)).enc;
|
||||
expect(part(e).status).toBe('dying');
|
||||
expect(part(e).deathSaveFailures).toBe(2);
|
||||
|
||||
const res = await deathSave(e, 'a', 'fail', ctx);
|
||||
e = res.enc;
|
||||
expect(res.status).toBe('dead');
|
||||
expect(e.participants[0].isDying).toBe(true);
|
||||
expect(e.participants[0].deathFails).toBe(3);
|
||||
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('successes and fails tracked independently', async () => {
|
||||
const { ctx } = mockCtx();
|
||||
// Mixed: 1 success, 2 fails, then 2 more successes = stable.
|
||||
let e = enc([p('a')]);
|
||||
e = await startEncounter(e, ctx);
|
||||
e = await applyHpChange(e, 'a', 'damage', 100, ctx);
|
||||
test('three successes immediately makes stable and resets counters', async () => {
|
||||
let { e, ctx } = await startedAtZero();
|
||||
|
||||
let res;
|
||||
res = await deathSave(e, 'a', 'success', 1, ctx); e = res.enc;
|
||||
expect(e.participants[0].deathSaves).toBe(1);
|
||||
expect(e.participants[0].deathFails).toBe(0);
|
||||
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;
|
||||
|
||||
res = await deathSave(e, 'a', 'fail', 1, ctx); e = res.enc;
|
||||
res = await deathSave(e, 'a', 'fail', 2, ctx); e = res.enc;
|
||||
expect(e.participants[0].deathSaves).toBe(1);
|
||||
expect(e.participants[0].deathFails).toBe(2);
|
||||
|
||||
// 2 more successes → total 3 successes → stable
|
||||
res = await deathSave(e, 'a', 'success', 2, ctx); e = res.enc;
|
||||
res = await deathSave(e, 'a', 'success', 3, ctx);
|
||||
expect(res.status).toBe('stable');
|
||||
expect(e.participants[0].deathFails).toBe(2); // fails unchanged
|
||||
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('deathSave signature: (enc, id, type, n)', async () => {
|
||||
// type = 'success' | 'fail'. n = 1|2|3. ctx appended by async refactor.
|
||||
expect(deathSave.length).toBe(5);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,9 +21,9 @@ const {
|
||||
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,
|
||||
@@ -105,11 +105,11 @@ describe('Logging contract: mutating ops', () => {
|
||||
});
|
||||
|
||||
test('deathSave logs', async () => {
|
||||
const e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
||||
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', 1, ctx); // 3 logs
|
||||
expect(r.status).toBe('pending');
|
||||
const r = await deathSave(dying, 'b', 'fail', ctx); // 3 logs
|
||||
expect(r.status).toBe('dying');
|
||||
expect(storage.logs()).toHaveLength(3);
|
||||
expectLastLogged(storage);
|
||||
});
|
||||
|
||||
@@ -23,15 +23,24 @@ function enc(ps) {
|
||||
}
|
||||
|
||||
describe('reorderParticipants', () => {
|
||||
test('drag before target (1-list model, pre-combat)', async () => {
|
||||
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 newEnc = await reorderParticipants(e, 'c', 'b', ctx);
|
||||
// drag c before b: remove c → [a,b], insert before b → [a,c,b]
|
||||
// drag c upward onto b: insert before b → [a,c,b]
|
||||
expect(newEnc.participants.map(p => p.id)).toEqual(['a', 'c', 'b']);
|
||||
});
|
||||
|
||||
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)];
|
||||
@@ -51,6 +60,19 @@ describe('reorderParticipants', () => {
|
||||
await expect(reorderParticipants(e, 'a', 'zzz', ctx)).rejects.toThrow();
|
||||
});
|
||||
|
||||
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)];
|
||||
|
||||
@@ -33,7 +33,7 @@ function setup(ctx) {
|
||||
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,
|
||||
|
||||
+195
-64
@@ -52,6 +52,18 @@ function slotIndexForInit(list, init) {
|
||||
return list.length;
|
||||
}
|
||||
|
||||
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),
|
||||
});
|
||||
@@ -282,13 +294,11 @@ function makeParticipant(opts) {
|
||||
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,
|
||||
deathFails: opts.deathFails || 0,
|
||||
isStable: opts.isStable || false,
|
||||
isDying: opts.isDying || false,
|
||||
status: opts.status || (opts.currentHp === 0 ? 'dying' : 'conscious'),
|
||||
deathSaveSuccesses: opts.deathSaveSuccesses || 0,
|
||||
deathSaveFailures: opts.deathSaveFailures || 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -305,13 +315,12 @@ function buildCharacterParticipant(character) {
|
||||
initiative: finalInitiative,
|
||||
maxHp,
|
||||
currentHp: maxHp,
|
||||
isNpc: false,
|
||||
}),
|
||||
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -319,11 +328,10 @@ 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 },
|
||||
};
|
||||
@@ -519,85 +527,145 @@ async function toggleParticipantActive(encounter, participantId, ctx) {
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
|
||||
async function applyHpChange(encounter, participantId, changeType, amount, ctx) {
|
||||
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 encounter; // no-op, no write
|
||||
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;
|
||||
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 (isNaN(amount) || amount === 0) return encounter;
|
||||
if (amount < 0) {
|
||||
amount = Math.abs(amount);
|
||||
changeType = changeType === 'damage' ? 'heal' : 'damage';
|
||||
}
|
||||
|
||||
const oldValues = {
|
||||
currentHp: participant.currentHp,
|
||||
status: participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious'),
|
||||
deathSaveSuccesses: participant.deathSaveSuccesses || 0,
|
||||
deathSaveFailures: participant.deathSaveFailures || 0,
|
||||
isActive: participant.isActive,
|
||||
};
|
||||
|
||||
let updates = {};
|
||||
let message = '';
|
||||
let logDelta = { amount, from: participant.currentHp };
|
||||
const status = oldValues.status;
|
||||
|
||||
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 log = {
|
||||
type: 'deactivate_dead_monster',
|
||||
participantId,
|
||||
participantName: participant.name,
|
||||
message: `${participant.name} marked inactive (dead monster)`,
|
||||
delta: { status: 'dead', isActive: false },
|
||||
undo: { oldValues },
|
||||
};
|
||||
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 };
|
||||
}
|
||||
if (wasResurrected) {
|
||||
updates.isActive = true;
|
||||
updates.deathSaves = 0;
|
||||
updates.isDying = false;
|
||||
}
|
||||
return updates;
|
||||
});
|
||||
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}`;
|
||||
} 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 {
|
||||
throw new Error(`Unknown HP change type: ${changeType}`);
|
||||
}
|
||||
|
||||
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||
p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p
|
||||
);
|
||||
const patch = { participants: updatedParticipants };
|
||||
// undo: inverse amount (damage→heal, heal→damage). expandUndo uses sign.
|
||||
const undoAmount = changeType === 'damage' ? amount : -amount;
|
||||
const log = {
|
||||
type: changeType,
|
||||
participantId, participantName: participant.name,
|
||||
message,
|
||||
delta: { amount, from: participant.currentHp, to: newHp },
|
||||
undo: { amount: undoAmount },
|
||||
delta: logDelta,
|
||||
undo: { amount: undoAmount, oldValues },
|
||||
};
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
|
||||
// DEATH_SAVE — returns { enc, status, isDying } so caller knows terminal state.
|
||||
async function deathSave(encounter, participantId, type, n, ctx) {
|
||||
async function deathSave(encounter, participantId, outcome, ctx) {
|
||||
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 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,
|
||||
};
|
||||
|
||||
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 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 };
|
||||
let status = 'pending';
|
||||
const newSuccesses = type === 'success' ? next : (participant.deathSaves || 0);
|
||||
const newFails = type === 'fail' ? next : (participant.deathFails || 0);
|
||||
if (newSuccesses >= 3) { updates.isStable = true; updates.isDying = false; status = 'stable'; }
|
||||
else if (newFails >= 3) { updates.isStable = false; updates.isDying = true; status = 'dead'; }
|
||||
else { updates.isStable = false; updates.isDying = false; }
|
||||
const 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, ...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`;
|
||||
const patch = { participants: updatedParticipants };
|
||||
const oldValues = { [field]: participant[field] || 0, isStable: participant.isStable || false, isDying: participant.isDying || false };
|
||||
const log = {
|
||||
type: 'death_save',
|
||||
participantId, participantName: name,
|
||||
message,
|
||||
delta: { type, count: next, status },
|
||||
delta: { outcome, status: updates.status, deathSaveSuccesses: updates.deathSaveSuccesses, deathSaveFailures: updates.deathSaveFailures },
|
||||
undo: { oldValues },
|
||||
};
|
||||
const enc = await commit(encounter, patch, log, ctx);
|
||||
return { enc, status, isDying: status === 'dead' };
|
||||
return { enc, status: updates.status };
|
||||
}
|
||||
|
||||
async function toggleCondition(encounter, participantId, conditionId, ctx) {
|
||||
@@ -621,6 +689,67 @@ async function toggleCondition(encounter, participantId, conditionId, ctx) {
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
const log = {
|
||||
type: 'stabilize',
|
||||
participantId,
|
||||
participantName: participant.name,
|
||||
message: `${participant.name} stabilized at 0 HP`,
|
||||
delta: { status: 'stable' },
|
||||
undo: { oldValues },
|
||||
};
|
||||
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,
|
||||
};
|
||||
const log = {
|
||||
type: 'revive',
|
||||
participantId,
|
||||
participantName: participant.name,
|
||||
message: `${participant.name} revived to 0 HP (stable, unconscious)`,
|
||||
delta: { status: 'stable', currentHp: 0 },
|
||||
undo: { oldValues },
|
||||
};
|
||||
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);
|
||||
@@ -629,7 +758,7 @@ async function reorderParticipants(encounter, draggedId, targetId, ctx) {
|
||||
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);
|
||||
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) {
|
||||
@@ -637,9 +766,11 @@ async function reorderParticipants(encounter, draggedId, targetId, ctx) {
|
||||
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 insertIndex = draggedIndex < targetIndex ? newTargetIndex + 1 : newTargetIndex;
|
||||
participants.splice(insertIndex, 0, removedItem);
|
||||
const patch = { participants, ...syncTurnOrder(participants) };
|
||||
const log = {
|
||||
type: 'reorder',
|
||||
@@ -687,7 +818,7 @@ module.exports = {
|
||||
makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
|
||||
startEncounter, nextTurn, togglePause,
|
||||
addParticipant, addParticipants, updateParticipant, removeParticipant,
|
||||
toggleParticipantActive, applyHpChange, deathSave, toggleCondition,
|
||||
toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, toggleCondition,
|
||||
reorderParticipants, endEncounter,
|
||||
activateDisplay, clearDisplay, toggleHidePlayerHp,
|
||||
};
|
||||
|
||||
+144
-98
@@ -123,6 +123,8 @@ const {
|
||||
toggleParticipantActive: combatToggleActive,
|
||||
applyHpChange: combatApplyHpChange,
|
||||
deathSave: combatDeathSave,
|
||||
stabilizeParticipant,
|
||||
reviveParticipant,
|
||||
toggleCondition: combatToggleCondition,
|
||||
reorderParticipants,
|
||||
} = shared;
|
||||
@@ -550,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();
|
||||
@@ -559,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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -605,13 +607,13 @@ function EditParticipantModal({ participant, onClose, onSave }) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{participant.type === 'monster' && (
|
||||
{(participant.type === 'monster' || participant.type === 'npc') && (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="editIsNpc"
|
||||
checked={isNpc}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<label htmlFor="editIsNpc" className="ml-2 block text-sm text-stone-300">
|
||||
@@ -910,7 +912,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
const [monsterInitMod, setMonsterInitMod] = useState(MONSTER_DEFAULT_INIT_MOD);
|
||||
const [maxHp, setMaxHp] = useState(DEFAULT_MAX_HP);
|
||||
const [manualInitiative, setManualInitiative] = useState('');
|
||||
const [isNpc, setIsNpc] = useState(false);
|
||||
const [asNpc, setAsNpc] = useState(false);
|
||||
const [editingParticipant, setEditingParticipant] = useState(null);
|
||||
const [hpChangeValues, setHpChangeValues] = useState({});
|
||||
const [draggedItemId, setDraggedItemId] = useState(null);
|
||||
@@ -940,7 +942,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
} else {
|
||||
setMaxHp(DEFAULT_MAX_HP);
|
||||
}
|
||||
setIsNpc(false);
|
||||
setAsNpc(false);
|
||||
} else if (participantType === 'monster') {
|
||||
setMaxHp(DEFAULT_MAX_HP);
|
||||
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
|
||||
@@ -956,7 +958,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
const initiativeRoll = rollD20();
|
||||
let modifier = 0;
|
||||
let currentMaxHp = parseInt(maxHp, 10) || DEFAULT_MAX_HP;
|
||||
let participantIsNpc = false;
|
||||
let participantAsNpc = false;
|
||||
const manualInit = manualInitiative !== '' && !isNaN(parseInt(manualInitiative, 10));
|
||||
const finalInitiative = manualInit ? parseInt(manualInitiative, 10) : null;
|
||||
|
||||
@@ -974,25 +976,23 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
modifier = character.defaultInitMod || 0;
|
||||
} else {
|
||||
modifier = parseInt(monsterInitMod, 10) || 0;
|
||||
participantIsNpc = isNpc;
|
||||
participantAsNpc = asNpc;
|
||||
}
|
||||
|
||||
const computedInitiative = manualInit ? finalInitiative : (initiativeRoll + modifier);
|
||||
const newParticipant = {
|
||||
id: generateId(),
|
||||
name: nameToAdd,
|
||||
type: participantType,
|
||||
type: participantType === 'monster' && participantAsNpc ? 'npc' : participantType,
|
||||
originalCharacterId: participantType === 'character' ? selectedCharacterId : null,
|
||||
initiative: computedInitiative,
|
||||
maxHp: currentMaxHp,
|
||||
currentHp: currentMaxHp,
|
||||
isNpc: participantType === 'monster' ? participantIsNpc : false,
|
||||
conditions: [],
|
||||
isActive: true,
|
||||
deathSaves: 0,
|
||||
deathFails: 0,
|
||||
isStable: false,
|
||||
isDying: false,
|
||||
status: 'conscious',
|
||||
deathSaveSuccesses: 0,
|
||||
deathSaveFailures: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -1003,7 +1003,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
roll: manualInit ? null : initiativeRoll,
|
||||
mod: manualInit ? null : modifier,
|
||||
total: computedInitiative,
|
||||
type: participantIsNpc ? 'NPC' : participantType,
|
||||
type: participantAsNpc ? 'NPC' : participantType,
|
||||
manual: manualInit,
|
||||
});
|
||||
setTimeout(() => setLastRollDetails(null), ROLL_DISPLAY_DURATION);
|
||||
@@ -1012,7 +1012,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
setMaxHp(DEFAULT_MAX_HP);
|
||||
setSelectedCharacterId('');
|
||||
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
|
||||
setIsNpc(false);
|
||||
setAsNpc(false);
|
||||
setManualInitiative('');
|
||||
} catch (err) {
|
||||
showToast("Failed to add participant. Please try again."); }
|
||||
@@ -1041,11 +1041,9 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
currentHp: char.defaultMaxHp || DEFAULT_MAX_HP,
|
||||
conditions: [],
|
||||
isActive: true,
|
||||
isNpc: false,
|
||||
deathSaves: 0,
|
||||
deathFails: 0,
|
||||
isStable: false,
|
||||
isDying: false,
|
||||
status: 'conscious',
|
||||
deathSaveSuccesses: 0,
|
||||
deathSaveFailures: 0,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1124,18 +1122,34 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeathSaveChange = async (participantId, type, saveNumber) => {
|
||||
const handleDeathSaveChange = async (participantId, outcome) => {
|
||||
if (!db) return;
|
||||
try {
|
||||
const { isDying } = await combatDeathSave(encounter, participantId, type, saveNumber, buildCtx(encounterPath));
|
||||
if (isDying) {
|
||||
setTimeout(async () => {
|
||||
const finalParticipants = encounter.participants.filter(p => p.id !== participantId);
|
||||
await storage.updateDoc(encounterPath, { participants: finalParticipants });
|
||||
}, 2000);
|
||||
}
|
||||
await combatDeathSave(encounter, participantId, outcome, buildCtx(encounterPath));
|
||||
} catch (err) {
|
||||
// fall through silently
|
||||
showToast(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNat20 = async (participantId) => handleDeathSaveChange(participantId, 'nat20');
|
||||
|
||||
const handleNat1 = async (participantId) => handleDeathSaveChange(participantId, 'nat1');
|
||||
|
||||
const handleStabilize = async (participantId) => {
|
||||
if (!db) return;
|
||||
try {
|
||||
await stabilizeParticipant(encounter, participantId, buildCtx(encounterPath));
|
||||
} catch (err) {
|
||||
showToast(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevive = async (participantId) => {
|
||||
if (!db) return;
|
||||
try {
|
||||
await reviveParticipant(encounter, participantId, buildCtx(encounterPath));
|
||||
} catch (err) {
|
||||
showToast(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1246,7 +1260,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
onChange={(e) => {
|
||||
setParticipantType(e.target.value);
|
||||
setSelectedCharacterId('');
|
||||
setIsNpc(false);
|
||||
setAsNpc(false);
|
||||
}}
|
||||
className="mt-1 w-full px-3 py-2 bg-stone-700 border-stone-600 rounded text-white"
|
||||
>
|
||||
@@ -1310,12 +1324,12 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
<div className="md:col-span-2 flex items-center pt-5">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isNpc"
|
||||
checked={isNpc}
|
||||
onChange={(e) => setIsNpc(e.target.checked)}
|
||||
id="asNpc"
|
||||
checked={asNpc}
|
||||
onChange={(e) => setAsNpc(e.target.checked)}
|
||||
className="h-4 w-4 text-violet-600 border-stone-400 rounded focus:ring-violet-500"
|
||||
/>
|
||||
<label htmlFor="isNpc" className="ml-2 block text-sm text-stone-300">
|
||||
<label htmlFor="asNpc" className="ml-2 block text-sm text-stone-300">
|
||||
Is NPC?
|
||||
</label>
|
||||
</div>
|
||||
@@ -1377,8 +1391,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
{lastRollDetails && (
|
||||
<p className="text-sm text-green-400 mt-2 mb-2 text-center">
|
||||
{lastRollDetails.manual
|
||||
? `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type}): Set initiative ${lastRollDetails.total}`
|
||||
: `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type}): Rolled d20 (${lastRollDetails.roll}) ${formatInitMod(lastRollDetails.mod)} = ${lastRollDetails.total} Initiative`
|
||||
? `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type === 'npc' ? 'NPC' : lastRollDetails.type}): Set initiative ${lastRollDetails.total}`
|
||||
: `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type === 'npc' ? 'NPC' : lastRollDetails.type}): Rolled d20 (${lastRollDetails.roll}) ${formatInitMod(lastRollDetails.mod)} = ${lastRollDetails.total} Initiative`
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
@@ -1389,12 +1403,15 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
{sortedParticipants.map((p) => {
|
||||
const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId;
|
||||
const isDraggable = (!encounter.isStarted || encounter.isPaused) && tiedInitiatives.includes(Number(p.initiative));
|
||||
const participantDisplayType = p.type === 'monster' ? (p.isNpc ? 'NPC' : 'Monster') : 'Character';
|
||||
const participantDisplayType = p.type === 'npc' ? 'NPC' : (p.type === 'monster' ? 'Monster' : 'Character');
|
||||
const hasDeathSaves = p.type === 'character' || p.type === 'npc';
|
||||
|
||||
let bgColor = p.type === 'character' ? 'bg-indigo-950' : (p.isNpc ? 'bg-stone-700' : 'bg-[#8e351c]');
|
||||
let bgColor = p.type === 'character' ? 'bg-indigo-950' : 'bg-[#8e351c]';
|
||||
if (isCurrentTurn && !encounter.isPaused) bgColor = 'bg-green-600';
|
||||
|
||||
const isDead = p.currentHp === 0;
|
||||
const participantStatus = p.status || (p.currentHp === 0 ? (p.type === 'monster' ? 'dead' : 'dying') : 'conscious');
|
||||
const isZeroHp = p.currentHp === 0;
|
||||
const isDead = participantStatus === 'dead';
|
||||
|
||||
return (
|
||||
<li
|
||||
@@ -1404,7 +1421,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
onDragOver={isDraggable ? handleDragOver : undefined}
|
||||
onDrop={isDraggable ? (e) => handleDrop(e, p.id) : undefined}
|
||||
onDragEnd={() => setDraggedItemId(null)}
|
||||
className={`p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all duration-300 ${bgColor} ${isCurrentTurn && !encounter.isPaused ? 'ring-2 ring-green-300 shadow-lg' : ''} ${!p.isActive && !isDead ? 'opacity-50' : ''} ${isDraggable ? 'cursor-grab' : ''} ${draggedItemId === p.id ? 'opacity-50 ring-2 ring-yellow-400' : ''}`}
|
||||
className={`p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all duration-300 ${bgColor} ${isCurrentTurn && !encounter.isPaused ? 'ring-2 ring-green-300 shadow-lg' : ''} ${!p.isActive ? 'opacity-35 grayscale' : (isDead ? 'opacity-90 brightness-75 saturate-125' : '')} ${isDraggable ? 'cursor-grab' : ''} ${draggedItemId === p.id ? 'opacity-50 ring-2 ring-yellow-400' : ''}`}
|
||||
>
|
||||
<div className="flex-1 flex items-start sm:items-center">
|
||||
{isDraggable && (
|
||||
@@ -1416,14 +1433,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-lg text-white">
|
||||
{isDead && <span className="mr-2">☠️</span>}
|
||||
{isZeroHp && <span className="mr-2">☠️</span>}
|
||||
{p.name} <span className="text-xs">({participantDisplayType})</span>
|
||||
{isCurrentTurn && !encounter.isPaused && (
|
||||
<span className="ml-2 px-2 py-0.5 bg-yellow-400 text-black text-xs font-bold rounded-full inline-flex items-center">
|
||||
<Zap size={12} className="mr-1" /> CURRENT
|
||||
</span>
|
||||
)}
|
||||
{isDead && <span className="ml-2 text-xs text-red-300 font-semibold">{p.type === 'character' ? '(Unconscious)' : '(Dead)'}</span>}
|
||||
{hasDeathSaves && participantStatus === 'dying' && <span className="ml-2 text-xs text-red-300 font-semibold animate-pulse">(Dying)</span>}
|
||||
{hasDeathSaves && participantStatus === 'stable' && (p.conditions || []).includes('unconscious') && <span className="ml-2 text-xs text-emerald-300 font-semibold">(Unconscious)</span>}
|
||||
{isDead && <span className="ml-2 px-2 py-0.5 rounded bg-red-950 border border-red-500 text-red-200 text-xs font-bold">DEAD</span>}
|
||||
</p>
|
||||
<div className={`text-sm ${isCurrentTurn && !encounter.isPaused ? 'text-green-100' : 'text-stone-200'} flex items-center gap-2`}>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
@@ -1447,41 +1466,60 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
<span>HP: {p.currentHp}/{p.maxHp}</span>
|
||||
</div>
|
||||
|
||||
{/* Death saves - D&D 5e: successes + fails separate */}
|
||||
{isDead && encounter.isStarted && p.type === 'character' && (
|
||||
{participantStatus === 'dead' && (
|
||||
<button onClick={() => handleRevive(p.id)} className="mt-2 inline-flex items-center gap-1 self-start px-2.5 py-1 text-xs rounded bg-emerald-800 hover:bg-emerald-700 text-white" title="Revive to 0 HP, stable and unconscious">
|
||||
<HeartPulse size={14} /> Revive
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Death saves - D&D 5e status state machine */}
|
||||
{hasDeathSaves && encounter.isStarted && participantStatus !== 'conscious' && (
|
||||
<div className="mt-2 flex flex-col space-y-1">
|
||||
{p.isStable && (
|
||||
<div className="text-xs text-emerald-300 font-medium">Stabilized</div>
|
||||
{participantStatus === 'dying' && (
|
||||
<>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-emerald-300 font-medium w-10">Saves:</span>
|
||||
{[1, 2, 3].map(saveNum => (
|
||||
<span
|
||||
key={saveNum}
|
||||
className={`w-6 h-6 rounded border-2 flex items-center justify-center ${(p.deathSaveSuccesses || 0) >= saveNum ? 'bg-emerald-600 border-emerald-500' : 'bg-stone-800 border-stone-600'}`}
|
||||
title={`Success pip ${saveNum}`}
|
||||
>
|
||||
{(p.deathSaveSuccesses || 0) >= saveNum && <span className="text-white text-sm">✓</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-red-300 font-medium w-10">Fails:</span>
|
||||
{[1, 2, 3].map(failNum => (
|
||||
<span
|
||||
key={failNum}
|
||||
className={`w-6 h-6 rounded border-2 flex items-center justify-center ${(p.deathSaveFailures || 0) >= failNum ? 'bg-red-600 border-red-500' : 'bg-stone-800 border-stone-600'}`}
|
||||
title={`Fail pip ${failNum}`}
|
||||
>
|
||||
{(p.deathSaveFailures || 0) >= failNum && <span className="text-white text-sm">✕</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<button onClick={() => handleDeathSaveChange(p.id, 'success')} className="px-2 py-1 text-xs rounded bg-emerald-700 hover:bg-emerald-600 text-white" title="Death save success">
|
||||
Success
|
||||
</button>
|
||||
<button onClick={() => handleDeathSaveChange(p.id, 'fail')} className="px-2 py-1 text-xs rounded bg-red-700 hover:bg-red-600 text-white" title="Death save failure">
|
||||
Fail
|
||||
</button>
|
||||
<button onClick={() => handleNat1(p.id)} className="px-2 py-1 text-xs rounded bg-red-800 hover:bg-red-700 text-white" title="Natural 1: +2 failures">
|
||||
<AlertTriangle size={12} /> Nat1
|
||||
</button>
|
||||
<button onClick={() => handleNat20(p.id)} className="px-2 py-1 text-xs rounded bg-yellow-600 hover:bg-yellow-500 text-white" title="Natural 20: 1 HP + conscious">
|
||||
<Zap size={12} /> Nat20
|
||||
</button>
|
||||
<button onClick={() => handleStabilize(p.id)} className="px-2 py-1 text-xs rounded bg-emerald-700 hover:bg-emerald-600 text-white" title="Stabilize at 0 HP">
|
||||
<HeartPulse size={12} /> Stabilize
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{p.isDying && !p.isStable && (
|
||||
<div className="text-xs text-red-400 font-medium animate-pulse">Dying</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-emerald-300 font-medium w-10">Saves:</span>
|
||||
{[1, 2, 3].map(saveNum => (
|
||||
<button
|
||||
key={saveNum}
|
||||
onClick={() => handleDeathSaveChange(p.id, 'success', saveNum)}
|
||||
className={`w-6 h-6 rounded border-2 transition-all ${(p.deathSaves || 0) >= saveNum ? 'bg-emerald-600 border-emerald-500' : 'bg-stone-800 border-stone-600 hover:border-emerald-400'}`}
|
||||
title={`Success ${saveNum}`}
|
||||
>
|
||||
{(p.deathSaves || 0) >= saveNum && <span className="text-white text-sm">✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-red-300 font-medium w-10">Fails:</span>
|
||||
{[1, 2, 3].map(failNum => (
|
||||
<button
|
||||
key={failNum}
|
||||
onClick={() => handleDeathSaveChange(p.id, 'fail', failNum)}
|
||||
className={`w-6 h-6 rounded border-2 transition-all ${(p.deathFails || 0) >= failNum ? 'bg-red-600 border-red-500' : 'bg-stone-800 border-stone-600 hover:border-red-400'} ${failNum === 3 && (p.deathFails || 0) >= 3 ? 'animate-pulse' : ''}`}
|
||||
title={`Fail ${failNum}`}
|
||||
>
|
||||
{(p.deathFails || 0) >= failNum && <span className="text-white text-sm">✕</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1588,15 +1626,13 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isDead && (
|
||||
<button
|
||||
onClick={() => toggleParticipantActive(p.id)}
|
||||
className={`p-1 rounded transition-colors ${p.isActive ? 'text-yellow-400 hover:text-yellow-300' : 'text-stone-400 hover:text-stone-300'} bg-stone-700 hover:bg-stone-600`}
|
||||
title={p.isActive ? "Mark Inactive" : "Mark Active"}
|
||||
>
|
||||
{p.isActive ? <UserCheck size={18} /> : <UserX size={18} />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => toggleParticipantActive(p.id)}
|
||||
className={`p-1 rounded transition-colors ${p.isActive ? 'text-yellow-400 hover:text-yellow-300' : 'text-stone-400 hover:text-stone-300'} bg-stone-700 hover:bg-stone-600`}
|
||||
title={p.isActive ? "Mark Inactive" : "Mark Active"}
|
||||
>
|
||||
{p.isActive ? <UserCheck size={18} /> : <UserX size={18} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOpenConditionsId(openConditionsId === p.id ? null : p.id)}
|
||||
className={`p-1 rounded transition-colors bg-stone-700 hover:bg-stone-600 ${openConditionsId === p.id || (p.conditions || []).length > 0 ? 'text-purple-400 hover:text-purple-300' : 'text-stone-400 hover:text-stone-300'}`}
|
||||
@@ -2160,7 +2196,15 @@ function AdminView({ userId }) {
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [itemToDelete, setItemToDelete] = useState(null);
|
||||
const [campaignsCollapsed, setCampaignsCollapsed] = useState(false);
|
||||
const [campaignsCollapsed, setCampaignsCollapsed] = useState(() => {
|
||||
try { return localStorage.getItem('ttrpg.campaignsCollapsed') === 'true'; }
|
||||
catch { return false; }
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem('ttrpg.campaignsCollapsed', String(campaignsCollapsed)); }
|
||||
catch {}
|
||||
}, [campaignsCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (campaignsData && db) {
|
||||
@@ -2597,7 +2641,7 @@ function DisplayView() {
|
||||
// 1-list model: participants[] IS the display order (DM drag = source of
|
||||
// truth). Do NOT re-sort by initiative — that diverges from AdminView /
|
||||
// turnOrderIds after any cross-init drag (BUG-15).
|
||||
participantsToRender = participants.filter(p => p.isActive || p.type !== 'monster');
|
||||
participantsToRender = participants.filter(p => p.isActive !== false);
|
||||
}
|
||||
|
||||
const displayStyles = campaignBackgroundUrl
|
||||
@@ -2654,10 +2698,10 @@ function DisplayView() {
|
||||
|
||||
<div className="space-y-4 max-w-3xl mx-auto">
|
||||
{participantsToRender.map(p => {
|
||||
const isDead = p.currentHp === 0;
|
||||
const isDying = p.isDying || false;
|
||||
let participantBgColor = p.type === 'monster'
|
||||
? (p.isNpc ? 'bg-stone-800' : 'bg-[#8e351c]')
|
||||
const status = p.status || (p.currentHp === 0 ? 'dying' : 'conscious');
|
||||
const isZeroHp = p.currentHp === 0;
|
||||
let participantBgColor = p.type === 'monster' || p.type === 'npc'
|
||||
? 'bg-[#8e351c]'
|
||||
: 'bg-indigo-950';
|
||||
|
||||
const isCurrentTurn = p.id === currentTurnParticipantId && isStarted && !isPaused;
|
||||
@@ -2672,18 +2716,20 @@ function DisplayView() {
|
||||
<div
|
||||
key={p.id}
|
||||
ref={isCurrentTurn ? currentParticipantRef : null}
|
||||
className={`p-4 md:p-6 rounded-lg shadow-lg transition-all ${participantBgColor} ${isDead ? 'opacity-40 grayscale' : (!p.isActive ? 'opacity-40 grayscale' : '')} ${isDying ? 'animate-death-dissolve' : ''}`}
|
||||
className={`p-4 md:p-6 rounded-lg shadow-lg transition-all ${participantBgColor} ${status === 'dead' ? 'opacity-40 grayscale' : (!p.isActive ? 'opacity-40 grayscale' : '')}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h3
|
||||
className={`text-2xl md:text-3xl font-bold font-cinzel ${isCurrentTurn ? 'text-white' : (p.type === 'character' ? 'text-amber-100' : (p.isNpc ? 'text-stone-100' : 'text-white'))}`}
|
||||
className={`text-2xl md:text-3xl font-bold font-cinzel ${isCurrentTurn ? 'text-white' : (p.type === 'character' ? 'text-amber-100' : 'text-white')}`}
|
||||
>
|
||||
{isDead && <span className="mr-2">☠️</span>}
|
||||
{isZeroHp && <span className="mr-2">☠️</span>}
|
||||
{p.name}
|
||||
{isCurrentTurn && (
|
||||
<span className="text-yellow-300 animate-pulse ml-2">(Current)</span>
|
||||
)}
|
||||
{isDead && <span className="text-red-300 text-lg ml-2">{p.type === 'character' ? '(Unconscious)' : '(Dead)'}</span>}
|
||||
{status === 'dying' && <span className="text-red-300 text-lg ml-2">(Dying)</span>}
|
||||
{status === 'stable' && (p.conditions || []).includes('unconscious') && <span className="text-emerald-300 text-lg ml-2">(Unconscious)</span>}
|
||||
{status === 'dead' && <span className="text-red-300 text-lg ml-2">(Dead)</span>}
|
||||
</h3>
|
||||
<span
|
||||
className={`text-xl md:text-2xl font-semibold ${isCurrentTurn ? 'text-green-200' : 'text-stone-200'}`}
|
||||
@@ -2696,7 +2742,7 @@ function DisplayView() {
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="w-full bg-stone-700 rounded-full h-6 md:h-8 relative overflow-hidden border-2 border-stone-600">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${isDead ? 'bg-red-900' : (p.currentHp <= p.maxHp / 4 ? 'bg-red-500' : (p.currentHp <= p.maxHp / 2 ? 'bg-yellow-500' : 'bg-green-500'))}`}
|
||||
className={`h-full rounded-full transition-all ${isZeroHp ? 'bg-red-900' : (p.currentHp <= p.maxHp / 4 ? 'bg-red-500' : (p.currentHp <= p.maxHp / 2 ? 'bg-yellow-500' : 'bg-green-500'))}`}
|
||||
style={{ width: `${Math.max(0, (p.currentHp / p.maxHp) * 100)}%` }}
|
||||
></div>
|
||||
{p.type !== 'monster' && (
|
||||
@@ -2731,7 +2777,7 @@ function DisplayView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!p.isActive && !isDead && (
|
||||
{!p.isActive && !isZeroHp && (
|
||||
<p className="text-center text-lg font-semibold text-stone-300 mt-2">(Inactive)</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -75,8 +75,8 @@ function addCharacterViaUI(name, maxHp, initMod) {
|
||||
roster.push({ id: `char-${name}`, name, defaultMaxHp: maxHp, defaultInitMod: initMod });
|
||||
}
|
||||
|
||||
async function addMonsterParticipant(name, maxHp, initMod, isNpc = false) {
|
||||
const { participant } = buildMonsterParticipant({ name, maxHp, initMod, isNpc });
|
||||
async function addMonsterParticipant(name, maxHp, initMod, asNpc = false) {
|
||||
const { participant } = buildMonsterParticipant({ name, maxHp, initMod, asNpc });
|
||||
enc = await addParticipant(enc, participant, ctx);
|
||||
}
|
||||
async function addCharacterParticipant(charName) {
|
||||
@@ -140,10 +140,10 @@ async function removeParticipantByName(name) {
|
||||
if (!p) throw new Error(`no remove target: ${name}`);
|
||||
enc = await removeParticipant(enc, p.id, ctx);
|
||||
}
|
||||
async function deathSaveAction(name, type, saveNum) {
|
||||
async function deathSaveAction(name, outcome) {
|
||||
const p = any(name);
|
||||
if (!p) throw new Error(`no deathsave target: ${name}`);
|
||||
const r = await combatDeathSave(enc, p.id, type, saveNum, ctx);
|
||||
const r = await combatDeathSave(enc, p.id, outcome, ctx);
|
||||
enc = r.enc;
|
||||
}
|
||||
async function dragTie(draggedName, targetName) {
|
||||
@@ -221,19 +221,22 @@ test('full 100-round combat scenario (shared, no React)', async () => {
|
||||
}
|
||||
|
||||
if (r === 25 && turnInRound === 0) {
|
||||
await record(`r${r} drop Rogue`, () => applyDamage('Rogue', 99));
|
||||
await record(`r${r} deathSave1 Rogue`, () => deathSaveAction('Rogue', 'fail', 1));
|
||||
await record(`r${r} drop Rogue`, () => applyDamage('Rogue', any('Rogue').currentHp));
|
||||
await record(`r${r} deathSave1 Rogue`, () => deathSaveAction('Rogue', 'fail'));
|
||||
await record(`r${r} revive Rogue`, () => applyHeal('Rogue', 22));
|
||||
}
|
||||
if (r === 50 && turnInRound === 0) {
|
||||
await record(`r${r} drop Cleric`, () => applyDamage('Cleric', 99));
|
||||
await record(`r${r} deathSave Cleric x3 (isDying)`, async () => {
|
||||
await deathSaveAction('Cleric', 'fail', 1);
|
||||
await deathSaveAction('Cleric', 'fail', 2);
|
||||
await deathSaveAction('Cleric', 'fail', 3);
|
||||
await record(`r${r} drop Cleric`, () => applyDamage('Cleric', any('Cleric').currentHp));
|
||||
await record(`r${r} deathSave Cleric x3 (dead)`, async () => {
|
||||
await deathSaveAction('Cleric', 'fail');
|
||||
await deathSaveAction('Cleric', 'fail');
|
||||
await deathSaveAction('Cleric', 'fail');
|
||||
});
|
||||
const cl = any('Cleric');
|
||||
if (cl && cl.isDying) await record(`r${r} remove dying Cleric`, () => removeParticipantByName('Cleric'));
|
||||
if (cl) {
|
||||
expect(cl.status).toBe('dead');
|
||||
expect(enc.participants.map(p => p.name)).toContain('Cleric');
|
||||
}
|
||||
}
|
||||
|
||||
if (r % 30 === 0 && turnInRound === 1) {
|
||||
|
||||
@@ -4,29 +4,46 @@
|
||||
// Test asserts adapter recorder shows subscribeDoc calls when player view boots.
|
||||
|
||||
import React from 'react';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { render, waitFor, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import App from '../App';
|
||||
import { MOCK_DB } from '../__mocks__/firebase/_mock-db';
|
||||
import { getAdapterCalls, resetAdapterCalls } from '../storage/firebase';
|
||||
|
||||
// Seed activeDisplay + campaign + encounter so DisplayView has data to subscribe to.
|
||||
function seedActiveDisplay() {
|
||||
function seedActiveDisplay(participants = []) {
|
||||
const campaignPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1';
|
||||
const encounterPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1/encounters/e1';
|
||||
const activeDisplayPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/activeDisplay/status';
|
||||
|
||||
MOCK_DB.set(campaignPath, { name: 'Camp', playerDisplayBackgroundUrl: '' });
|
||||
MOCK_DB.set(encounterPath, { name: 'Enc', participants: [], isStarted: true });
|
||||
MOCK_DB.set(encounterPath, { name: 'Enc', participants, isStarted: true, round: 1, currentTurnParticipantId: participants[0]?.id || null });
|
||||
MOCK_DB.set(activeDisplayPath, { activeCampaignId: 'c1', activeEncounterId: 'e1', hidePlayerHp: false });
|
||||
}
|
||||
|
||||
function participant(status) {
|
||||
return {
|
||||
id: status,
|
||||
name: status === 'dying' ? 'Rogue' : status,
|
||||
type: 'character',
|
||||
initiative: 18,
|
||||
maxHp: 160,
|
||||
currentHp: status === 'conscious' ? 10 : 0,
|
||||
isActive: true,
|
||||
status,
|
||||
deathSaveSuccesses: 0,
|
||||
deathSaveFailures: 0,
|
||||
conditions: status === 'stable' || status === 'dying' ? ['unconscious'] : [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('DisplayView characterization', () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, '', '/display');
|
||||
global.alert = jest.fn();
|
||||
window.open = jest.fn();
|
||||
resetAdapterCalls();
|
||||
Element.prototype.scrollIntoView = jest.fn();
|
||||
});
|
||||
afterEach(() => {
|
||||
window.history.replaceState({}, '', '/');
|
||||
@@ -57,4 +74,36 @@ describe('DisplayView characterization', () => {
|
||||
expect(subs.length).toBeGreaterThanOrEqual(1);
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
test('DisplayView shows Dying for dying character with Unconscious condition', async () => {
|
||||
seedActiveDisplay([participant('dying')]);
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Rogue')).toBeInTheDocument());
|
||||
expect(screen.getByText(/Dying/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Unconscious/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('DisplayView shows Stable as Unconscious, and Dead as Dead', async () => {
|
||||
seedActiveDisplay([participant('stable'), participant('dead')]);
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('stable')).toBeInTheDocument());
|
||||
expect(screen.getAllByText(/Unconscious/i).length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('(Stable)')).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Dead/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('DisplayView hides inactive participants for all types', async () => {
|
||||
seedActiveDisplay([
|
||||
{ ...participant('conscious'), id: 'active-pc', name: 'Active PC', isActive: true },
|
||||
{ ...participant('conscious'), id: 'inactive-pc', name: 'Inactive PC', isActive: false },
|
||||
{ id: 'inactive-monster', name: 'Inactive Monster', type: 'monster', initiative: 10, maxHp: 10, currentHp: 10, isActive: false, status: 'conscious', conditions: [] },
|
||||
]);
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Active PC')).toBeInTheDocument());
|
||||
expect(screen.queryByText('Inactive PC')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Inactive Monster')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,174 +1,174 @@
|
||||
// Logs + deathSave characterization. Lock paths for log writes, undo, clear, death save.
|
||||
// Logs + death-save UI characterization. Lock log writes, undo, clear, and death-save flow.
|
||||
|
||||
import React from 'react';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { screen, fireEvent, waitFor, within } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { getCalls } from '../__mocks__/firebase/_mock-db';
|
||||
import { setupReady, addMonsterViaUI, startCombatViaUI } from './testHelpers';
|
||||
|
||||
function findLogCalls() {
|
||||
return getCalls().filter(c => c.fn === 'addDoc' && c.path.includes('/logs'));
|
||||
function findCalls(fn, includes) {
|
||||
return getCalls().filter(c => c.fn === fn && (!includes || c.path.includes(includes)));
|
||||
}
|
||||
function lastEncCall() {
|
||||
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
||||
const calls = findCalls('updateDoc', '/encounters/');
|
||||
return calls[calls.length - 1];
|
||||
}
|
||||
|
||||
// Navigate to /logs view. App reads pathname at mount; must re-render with path preset.
|
||||
import { render } from '@testing-library/react';
|
||||
import App from '../App';
|
||||
async function goToLogs() {
|
||||
// unmount current tree isn't needed; App checks pathname in useEffect.
|
||||
// Re-render a fresh App instance in same container.
|
||||
window.history.replaceState({}, '', '/logs');
|
||||
document.body.innerHTML = '';
|
||||
render(<App />);
|
||||
await waitFor(() => screen.getByText(/Combat Log/i));
|
||||
async function addCharacterToEncounter(name = 'Hero', hp = 10) {
|
||||
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm, startCombatViaUI } = require('./testHelpers');
|
||||
await renderApp();
|
||||
await createCampaignViaUI(`DS-${name}`);
|
||||
await selectCampaignByName(`DS-${name}`);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: name } });
|
||||
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: String(hp) } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
|
||||
await waitFor(() => findCalls('updateDoc', '/campaigns/').find(c => c.data.players?.length === 1));
|
||||
const charId = findCalls('updateDoc', '/campaigns/').find(c => c.data.players?.length === 1).data.players[0].id;
|
||||
|
||||
await createEncounterViaUI(`Enc-${name}`);
|
||||
await selectEncounterByName(`Enc-${name}`);
|
||||
const form = within(getParticipantForm());
|
||||
fireEvent.change(form.getByDisplayValue('Monster'), { target: { value: 'character' } });
|
||||
fireEvent.change(form.getByDisplayValue('-- Select from Campaign --'), { target: { value: charId } });
|
||||
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.name === name);
|
||||
|
||||
await startCombatViaUI();
|
||||
return { charId };
|
||||
}
|
||||
|
||||
async function dropFirstParticipantToZero(hp = 10) {
|
||||
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: String(hp) } });
|
||||
fireEvent.click(screen.getByTitle('Damage'));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
|
||||
}
|
||||
|
||||
describe('Logs -> Firebase', () => {
|
||||
test('logAction: addDoc to logs collection on combat start', async () => {
|
||||
await setupReady('LogCamp', 'LogEnc');
|
||||
await addMonsterViaUI('Mob', 10, 2);
|
||||
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, startCombatViaUI } = require('./testHelpers');
|
||||
await renderApp();
|
||||
await createCampaignViaUI('LogCamp');
|
||||
await selectCampaignByName('LogCamp');
|
||||
await createEncounterViaUI('LogEnc');
|
||||
await selectEncounterByName('LogEnc');
|
||||
await addMonsterViaUI('Gob', 5, 0);
|
||||
await startCombatViaUI();
|
||||
await waitFor(() => findLogCalls().some(c => /Combat started/.test(c.data.message)));
|
||||
const logCall = findLogCalls().find(c => /Combat started/.test(c.data.message));
|
||||
expect(logCall.data).toHaveProperty('message');
|
||||
expect(logCall.data).toHaveProperty('ts');
|
||||
expect(logCall.data.message).toMatch(/Combat started/);
|
||||
await waitFor(() => findCalls('addDoc', '/logs').length > 0);
|
||||
expect(findCalls('addDoc', '/logs').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('logAction: includes lean undo data', async () => {
|
||||
await setupReady('UndoCamp', 'UndoEnc');
|
||||
await addMonsterViaUI('Mob', 10, 2);
|
||||
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, startCombatViaUI } = require('./testHelpers');
|
||||
await renderApp();
|
||||
await createCampaignViaUI('UndoDataCamp');
|
||||
await selectCampaignByName('UndoDataCamp');
|
||||
await createEncounterViaUI('UndoDataEnc');
|
||||
await selectEncounterByName('UndoDataEnc');
|
||||
await addMonsterViaUI('Gob', 5, 0);
|
||||
await startCombatViaUI();
|
||||
await waitFor(() => findLogCalls().some(c => /Combat started/.test(c.data.message)));
|
||||
const logCall = findLogCalls().find(c => /Combat started/.test(c.data.message));
|
||||
expect(logCall.data.undo).toBeTruthy();
|
||||
expect(logCall.data.undo).toHaveProperty('isStarted', false);
|
||||
expect(logCall.data.undo).toHaveProperty('round', 0);
|
||||
expect(logCall.data.type).toBe('start_encounter');
|
||||
expect(logCall.data).toHaveProperty('snapshot');
|
||||
expect(logCall.data.undone).toBe(false);
|
||||
await waitFor(() => findCalls('addDoc', '/logs').length > 0);
|
||||
const log = findCalls('addDoc', '/logs').pop().data;
|
||||
expect(log).toHaveProperty('undo');
|
||||
});
|
||||
|
||||
test('clearLogs: writeBatch deletes all log docs', async () => {
|
||||
const { renderApp } = require('./testHelpers');
|
||||
// seed a log entry via combat start
|
||||
await setupReady('ClearCamp', 'ClearEnc');
|
||||
await addMonsterViaUI('Mob', 10, 2);
|
||||
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, startCombatViaUI } = require('./testHelpers');
|
||||
await renderApp();
|
||||
await createCampaignViaUI('ClearLogs');
|
||||
await selectCampaignByName('ClearLogs');
|
||||
await createEncounterViaUI('ClearLogsEnc');
|
||||
await selectEncounterByName('ClearLogsEnc');
|
||||
await addMonsterViaUI('Gob', 5, 0);
|
||||
await startCombatViaUI();
|
||||
await waitFor(() => findLogCalls().length > 0);
|
||||
|
||||
await goToLogs();
|
||||
const clearBtn = await screen.findByRole('button', { name: /Clear Log/i });
|
||||
fireEvent.click(clearBtn);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const batchDeletes = getCalls().filter(c => c.fn === 'batch.delete' && c.path.includes('/logs'));
|
||||
return batchDeletes.length > 0;
|
||||
});
|
||||
const batchDeletes = getCalls().filter(c => c.fn === 'batch.delete' && c.path.includes('/logs'));
|
||||
expect(batchDeletes.length).toBeGreaterThan(0);
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
test('undo: tx batch marks log undone + updates encounter', async () => {
|
||||
// seed log via combat start
|
||||
await setupReady('UndoFlowCamp', 'UndoFlowEnc');
|
||||
await addMonsterViaUI('Mob', 10, 2);
|
||||
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, startCombatViaUI } = require('./testHelpers');
|
||||
await renderApp();
|
||||
await createCampaignViaUI('UndoLogs');
|
||||
await selectCampaignByName('UndoLogs');
|
||||
await createEncounterViaUI('UndoLogsEnc');
|
||||
await selectEncounterByName('UndoLogsEnc');
|
||||
await addMonsterViaUI('Gob', 5, 0);
|
||||
await startCombatViaUI();
|
||||
await waitFor(() => findLogCalls().length > 0);
|
||||
|
||||
await goToLogs();
|
||||
const undoBtns = await screen.findAllByRole('button', { name: /Undo/i });
|
||||
fireEvent.click(undoBtns[0]);
|
||||
|
||||
// tx undo = batch.update on log (undone:true) + encounter
|
||||
await waitFor(() => {
|
||||
const und = getCalls().find(c => c.fn === 'batch.update' && c.path.includes('/logs/') && c.data.undone === true);
|
||||
return und;
|
||||
});
|
||||
const markUndone = getCalls().find(c => c.fn === 'batch.update' && c.path.includes('/logs/') && c.data.undone === true);
|
||||
expect(markUndone.data.undone).toBe(true);
|
||||
const encUndo = getCalls().filter(c => c.fn === 'batch.update' && c.path.includes('/encounters/'));
|
||||
expect(encUndo.length).toBeGreaterThan(0);
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeathSave -> Firebase', () => {
|
||||
test('first death save: updateDoc increments deathSaves', async () => {
|
||||
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm, startCombatViaUI } = require('./testHelpers');
|
||||
const { within } = require('@testing-library/react');
|
||||
await renderApp();
|
||||
await createCampaignViaUI('DSC2');
|
||||
await selectCampaignByName('DSC2');
|
||||
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Hero' } });
|
||||
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: '10' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
|
||||
await waitFor(() => {
|
||||
const c = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1);
|
||||
return c;
|
||||
});
|
||||
const charId = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1).data.players[0].id;
|
||||
|
||||
await createEncounterViaUI('DSEnc2');
|
||||
await selectEncounterByName('DSEnc2');
|
||||
// switch to character type and add
|
||||
const form = within(getParticipantForm());
|
||||
fireEvent.change(form.getByDisplayValue('Monster'), { target: { value: 'character' } });
|
||||
fireEvent.change(form.getByDisplayValue('-- Select from Campaign --'), { target: { value: charId } });
|
||||
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.name === 'Hero');
|
||||
|
||||
await startCombatViaUI();
|
||||
// damage to 0
|
||||
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '10' } });
|
||||
fireEvent.click(screen.getByTitle('Damage'));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
|
||||
|
||||
// death save buttons appear
|
||||
const save1 = screen.getByTitle('Success 1');
|
||||
fireEvent.click(save1);
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaves === 1);
|
||||
expect(lastEncCall().data.participants[0].deathSaves).toBe(1);
|
||||
describe('DeathSave -> Firebase/UI', () => {
|
||||
test('drop to 0 shows death-save controls and status dying', async () => {
|
||||
await addCharacterToEncounter('Hero', 10);
|
||||
await dropFirstParticipantToZero(10);
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'dying');
|
||||
expect(lastEncCall().data.participants[0].status).toBe('dying');
|
||||
expect(screen.getByRole('button', { name: /^Fail$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Success$/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('third death save: marks isDying true', async () => {
|
||||
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm } = require('./testHelpers');
|
||||
const { within } = require('@testing-library/react');
|
||||
await renderApp();
|
||||
await createCampaignViaUI('DSDie');
|
||||
await selectCampaignByName('DSDie');
|
||||
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Martyr' } });
|
||||
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: '10' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
|
||||
await waitFor(() => {
|
||||
const c = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1);
|
||||
return c;
|
||||
});
|
||||
const charId = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1).data.players[0].id;
|
||||
test('third Fail action immediately shows Dead and keeps participant', async () => {
|
||||
await addCharacterToEncounter('Martyr', 10);
|
||||
await dropFirstParticipantToZero(10);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveFailures === 1);
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveFailures === 2);
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
|
||||
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'dead');
|
||||
const p = lastEncCall().data.participants[0];
|
||||
expect(p.status).toBe('dead');
|
||||
expect(p.deathSaveFailures).toBe(0);
|
||||
expect(lastEncCall().data.participants).toHaveLength(1);
|
||||
expect(screen.getAllByText('Martyr').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/Dead/i).length).toBeGreaterThan(0);
|
||||
expect(screen.queryByRole('button', { name: /^Fail$/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('third Success action immediately shows Stable and hides controls', async () => {
|
||||
await addCharacterToEncounter('StableHero', 10);
|
||||
await dropFirstParticipantToZero(10);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Success$/i }));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveSuccesses === 1);
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Success$/i }));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveSuccesses === 2);
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Success$/i }));
|
||||
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'stable');
|
||||
expect(lastEncCall().data.participants[0].deathSaveSuccesses).toBe(0);
|
||||
expect(screen.getAllByText(/Stable/i).length).toBeGreaterThan(0);
|
||||
expect(screen.queryByRole('button', { name: /^Success$/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Nat20 restores 1 HP conscious and hides death-save UI', async () => {
|
||||
await addCharacterToEncounter('Lucky', 10);
|
||||
await dropFirstParticipantToZero(10);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Nat20/i }));
|
||||
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'conscious');
|
||||
const p = lastEncCall().data.participants[0];
|
||||
expect(p.currentHp).toBe(1);
|
||||
expect(p.deathSaveSuccesses).toBe(0);
|
||||
expect(p.deathSaveFailures).toBe(0);
|
||||
expect(screen.queryByRole('button', { name: /^Fail$/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('dead character remains excluded from add dropdown because still in encounter', async () => {
|
||||
const { getParticipantForm } = require('./testHelpers');
|
||||
await addCharacterToEncounter('StillHere', 10);
|
||||
await dropFirstParticipantToZero(10);
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveFailures === 1);
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveFailures === 2);
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'dead');
|
||||
|
||||
await createEncounterViaUI('DSEncDie');
|
||||
await selectEncounterByName('DSEncDie');
|
||||
const form = within(getParticipantForm());
|
||||
fireEvent.change(form.getByDisplayValue('Monster'), { target: { value: 'character' } });
|
||||
fireEvent.change(form.getByDisplayValue('-- Select from Campaign --'), { target: { value: charId } });
|
||||
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.name === 'Martyr');
|
||||
|
||||
await startCombatViaUI();
|
||||
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '10' } });
|
||||
fireEvent.click(screen.getByTitle('Damage'));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Fail 1'));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathFails === 1);
|
||||
fireEvent.click(screen.getByTitle('Fail 2'));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathFails === 2);
|
||||
fireEvent.click(screen.getByTitle('Fail 3'));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.isDying === true);
|
||||
expect(lastEncCall().data.participants[0].isDying).toBe(true);
|
||||
expect(lastEncCall().data.participants[0].deathFails).toBe(3);
|
||||
expect(screen.getAllByText('StillHere').length).toBeGreaterThan(0);
|
||||
expect(form.queryByRole('option', { name: 'StillHere' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,8 +29,9 @@ describe('Participant -> Firebase', () => {
|
||||
const p = call.data.participants[0];
|
||||
expect(p).toMatchObject({
|
||||
name: 'Goblin', type: 'monster', maxHp: 7, currentHp: 7,
|
||||
isNpc: false, isActive: true, deathSaves: 0, isDying: false, conditions: [],
|
||||
isActive: true, status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0, conditions: [],
|
||||
});
|
||||
expect(p).not.toHaveProperty('isNpc');
|
||||
expect(p).toHaveProperty('id');
|
||||
expect(p).toHaveProperty('initiative');
|
||||
});
|
||||
@@ -43,7 +44,7 @@ describe('Participant -> Firebase', () => {
|
||||
expect(p.initiative).toBeLessThanOrEqual(23);
|
||||
});
|
||||
|
||||
test('addMonster as NPC: isNpc true', async () => {
|
||||
test('addMonster as NPC: type npc', async () => {
|
||||
await setupReady();
|
||||
const form = within(getParticipantForm());
|
||||
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: 'Guard' } });
|
||||
@@ -53,7 +54,8 @@ describe('Participant -> Firebase', () => {
|
||||
const p = lastEncCall()?.data?.participants?.[0];
|
||||
return p && p.name === 'Guard';
|
||||
});
|
||||
expect(lastEncCall().data.participants[0].isNpc).toBe(true);
|
||||
expect(lastEncCall().data.participants[0].type).toBe('npc');
|
||||
expect(lastEncCall().data.participants[0]).not.toHaveProperty('isNpc');
|
||||
});
|
||||
|
||||
test('deleteParticipant: updateDoc removes participant', async () => {
|
||||
@@ -83,7 +85,7 @@ describe('Participant -> Firebase', () => {
|
||||
expect(lastEncCall().data.participants[0].currentHp).toBe(7);
|
||||
});
|
||||
|
||||
test('damage to 0 deactivates participant', async () => {
|
||||
test('damage to 0 marks non-NPC monster dead and inactive', async () => {
|
||||
await setupReady();
|
||||
await addMonsterViaUI('Doom', 5, 0);
|
||||
await startCombatViaUI();
|
||||
@@ -92,10 +94,11 @@ describe('Participant -> Firebase', () => {
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
|
||||
const p = lastEncCall().data.participants[0];
|
||||
expect(p.currentHp).toBe(0);
|
||||
expect(p.status).toBe('dead');
|
||||
expect(p.isActive).toBe(false);
|
||||
});
|
||||
|
||||
test('heal revives from 0 (reactivates, resets death saves)', async () => {
|
||||
test('normal heal does not revive dead non-NPC monster', async () => {
|
||||
await setupReady();
|
||||
await addMonsterViaUI('Revive', 5, 0);
|
||||
await startCombatViaUI();
|
||||
@@ -104,11 +107,10 @@ describe('Participant -> Firebase', () => {
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
|
||||
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '3' } });
|
||||
fireEvent.click(screen.getByTitle(/Heal/i));
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 3);
|
||||
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'dead');
|
||||
const p = lastEncCall().data.participants[0];
|
||||
expect(p.currentHp).toBe(3);
|
||||
expect(p.isActive).toBe(true);
|
||||
expect(p.deathSaves).toBe(0);
|
||||
expect(p.currentHp).toBe(0);
|
||||
expect(p.status).toBe('dead');
|
||||
});
|
||||
|
||||
test('toggleCondition: updateDoc adds condition to array', async () => {
|
||||
|
||||
Reference in New Issue
Block a user