feat(turn): D&D 5e death saves — success/fail tracking
Old model broken: single deathSaves counter, 3=revive. No fails tracked,
successes conflated with fails. DM couldn't model real death save outcomes.
New model (D&D 5e):
- deathSave(enc, id, type, n) — type='success'|'fail', n=1|2|3
- Participant fields: deathSaves (successes), deathFails, isStable, isDying
- 3 successes = stable (0hp unconscious, safe until healed)
- 3 failures = dead (isDying, caller animates removal)
- Toggling same pip = undo that pip
- Returns { patch, log, status } — status: 'stable'|'dead'|'pending'
isDying back-compat flag kept for App.js removal animation
App.js:
- UI split into green ✓ saves row + red ✕ fails row
- Status badges: Stabilized (emerald) / Dying (red pulse)
- handleDeathSaveChange(participantId, type, saveNumber) — new sig
- makeParticipant init: deathFails + isStable defaults
Data loss: old single-counter participants lose saves (feature didn't work,
no real state to preserve). User confirmed acceptable.
Tests:
- turn.deathsave.test.js: RED-then-GREEN, 3-success stable, 3-fail dead,
independent tracking, new signature
- Updated 6 callers across characterization/combat/dead-skip/logging/scenario
- Logs UI tests: new title selectors (Success N / Fail N)
243 tests green.
This commit is contained in:
+40
-15
@@ -873,6 +873,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
||||
conditions: [],
|
||||
isActive: true,
|
||||
deathSaves: 0,
|
||||
deathFails: 0,
|
||||
isStable: false,
|
||||
isDying: false,
|
||||
};
|
||||
|
||||
@@ -930,6 +932,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
||||
isActive: true,
|
||||
isNpc: false,
|
||||
deathSaves: 0,
|
||||
deathFails: 0,
|
||||
isStable: false,
|
||||
isDying: false,
|
||||
};
|
||||
});
|
||||
@@ -1053,10 +1057,10 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeathSaveChange = async (participantId, saveNumber) => {
|
||||
const handleDeathSaveChange = async (participantId, type, saveNumber) => {
|
||||
if (!db) return;
|
||||
try {
|
||||
const { patch, isDying } = combatDeathSave(encounter, participantId, saveNumber);
|
||||
const { patch, isDying } = combatDeathSave(encounter, participantId, type, saveNumber);
|
||||
await storage.updateDoc(encounterPath, patch);
|
||||
if (isDying) {
|
||||
setTimeout(async () => {
|
||||
@@ -1370,20 +1374,41 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
|
||||
<span>HP: {p.currentHp}/{p.maxHp}</span>
|
||||
</div>
|
||||
|
||||
{/* Death Saves - only player characters make death saving throws */}
|
||||
{/* Death saves - D&D 5e: successes + fails separate */}
|
||||
{isDead && encounter.isStarted && p.type === 'character' && (
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
<span className="text-xs text-red-300 font-medium">Death Saves:</span>
|
||||
{[1, 2, 3].map(saveNum => (
|
||||
<button
|
||||
key={saveNum}
|
||||
onClick={() => handleDeathSaveChange(p.id, saveNum)}
|
||||
className={`w-6 h-6 rounded border-2 transition-all ${(p.deathSaves || 0) >= saveNum ? 'bg-red-600 border-red-500' : 'bg-stone-800 border-stone-600 hover:border-red-400'} ${saveNum === 3 && (p.deathSaves || 0) === 3 ? 'animate-pulse' : ''}`}
|
||||
title={`Death save ${saveNum}`}
|
||||
>
|
||||
{(p.deathSaves || 0) >= saveNum && <span className="text-white text-sm">✕</span>}
|
||||
</button>
|
||||
))}
|
||||
<div className="mt-2 flex flex-col space-y-1">
|
||||
{p.isStable && (
|
||||
<div className="text-xs text-emerald-300 font-medium">Stabilized</div>
|
||||
)}
|
||||
{p.isDying && !p.isStable && (
|
||||
<div className="text-xs text-red-400 font-medium animate-pulse">Dying</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-emerald-300 font-medium w-10">Saves:</span>
|
||||
{[1, 2, 3].map(saveNum => (
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user