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:
david raistrick
2026-07-06 16:48:50 -04:00
parent 2569cc4497
commit 1d4ec873dc
22 changed files with 1617 additions and 531 deletions
+195 -64
View File
@@ -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,
};