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:
+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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user