Generic (non-5e) ruleset mode: negative HP, down status, mark dead/revive

New campaign/encounter ruleset toggle: 5e (default, unchanged) vs generic.

Generic mode:
- No death saves (deathSave/stabilize throw)
- Negative HP allowed (no clamp at 0)
- <=0 HP = status 'down' (no pips, no unconscious auto-condition)
- Monster death = dead + inactive (same as 5e)
- markDead button: DM sets dead manually (both modes)
- revive: dead->conscious (generic), dead->stable (5e)

5e mode: zero behavior change.

shared/turn.js:
- applyHpChangeGeneric: negative HP, down status, no death-save logic
- markDead: status dead, monster auto-inactive
- reviveParticipant: ruleset-aware (generic=conscious, 5e=stable)
- deathSave/stabilize: throw in generic
- expandUndo: mark_dead case added

UI (src/App.js):
- CreateCampaignForm + CreateEncounterForm: ruleset radio toggle
- handleCreateCampaign/handleCreateEncounter: store ruleset field
- EncounterManager: fetch campaignDoc for default ruleset inheritance
- DM participant: down/dead labels, Mark Dead button (generic), Revive both
- death-save pips/buttons gated 5e-only
- DisplayView: down label, generic status derivation

Tests: 15 generic cases (turn.generic.test.js). 186 shared total.
This commit is contained in:
david raistrick
2026-07-07 13:36:16 -04:00
parent 7955bed4a9
commit c2bb5cca29
3 changed files with 374 additions and 14 deletions
+115 -2
View File
@@ -207,6 +207,7 @@ function expandUndo(entry, currentEnc) {
case 'death_save':
case 'stabilize':
case 'revive':
case 'mark_dead':
case 'deactivate_dead_monster': {
// Full death-state restore: currentHp, status, death-save counters,
// isActive, conditions (unconscious added/removed by withDeathStatusConditions).
@@ -574,6 +575,7 @@ async function toggleParticipantActive(encounter, participantId, ctx) {
async function applyHpChange(encounter, participantId, changeType, amount, optionsOrCtx, maybeCtx) {
const ctx = maybeCtx || optionsOrCtx;
const options = maybeCtx ? (optionsOrCtx || {}) : {};
const ruleset = encounter.ruleset || '5e';
const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.');
if (isNaN(amount) || amount === 0) return encounter;
@@ -596,6 +598,12 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
let logDelta = { amount, from: participant.currentHp };
const status = oldValues.status;
// GENERIC ruleset: no death saves, negative HP allowed, down status at <=0.
// Monster death = dead + inactive. No unconscious auto-condition.
if (ruleset === 'generic') {
return applyHpChangeGeneric(encounter, participant, changeType, amount, oldValues, ctx);
}
if (changeType === 'damage') {
if (participant.currentHp === 0) {
if (status === 'dead') {
@@ -683,7 +691,76 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
return commit(encounter, patch, log, ctx);
}
// GENERIC ruleset HP change: no death saves, negative HP, down status at <=0.
// Monster death = dead + inactive. No unconscious auto-condition.
async function applyHpChangeGeneric(encounter, participant, changeType, amount, oldValues, ctx) {
const participantId = participant.id;
let updates, message, logDelta = { amount, from: participant.currentHp };
if (changeType === 'damage') {
if (oldValues.status === 'dead') {
if (participant.type === 'monster' && participant.isActive !== false) {
const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, isActive: false } : p
);
const newP = updatedParticipants.find(p => p.id === participantId);
const newValues = {
currentHp: newP.currentHp, status: newP.status,
deathSaveSuccesses: 0, deathSaveFailures: 0,
isActive: newP.isActive, conditions: [...(newP.conditions || [])],
};
return commit(encounter, { participants: updatedParticipants }, {
type: 'deactivate_dead_monster', participantId, participantName: participant.name,
message: `${participant.name} marked inactive (dead monster)`,
delta: { status: 'dead', isActive: false },
undo: { oldValues, newValues },
}, ctx);
}
return encounter;
}
const newHp = participant.currentHp - amount;
const isMonster = participant.type === 'monster';
if (newHp <= 0 && isMonster) {
updates = { currentHp: newHp, status: 'dead', isActive: false };
} else if (newHp <= 0) {
updates = { currentHp: newHp, status: 'down' };
} else {
updates = { currentHp: newHp, status: 'conscious' };
}
message = `${participant.name} took ${amount} damage (${participant.currentHp}${newHp} HP)`;
logDelta = { ...logDelta, to: newHp, status: updates.status };
} else if (changeType === 'heal') {
if (oldValues.status === 'dead') return encounter;
const newHp = Math.min(participant.maxHp, participant.currentHp + amount);
updates = { currentHp: newHp, status: 'conscious' };
message = `${participant.name} healed for ${amount} (${participant.currentHp}${newHp} HP)`;
logDelta = { ...logDelta, to: newHp, status: 'conscious' };
} else {
throw new Error(`Unknown HP change type: ${changeType}`);
}
const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, ...updates } : p
);
const newP = updatedParticipants.find(p => p.id === participantId);
const newValues = {
currentHp: newP.currentHp, status: newP.status,
deathSaveSuccesses: newP.deathSaveSuccesses || 0,
deathSaveFailures: newP.deathSaveFailures || 0,
isActive: newP.isActive, conditions: [...(newP.conditions || [])],
};
const undoAmount = changeType === 'damage' ? amount : -amount;
return commit(encounter, { participants: updatedParticipants }, {
type: changeType, participantId, participantName: participant.name,
message, delta: logDelta,
undo: { amount: undoAmount, oldValues, newValues },
}, ctx);
}
async function deathSave(encounter, participantId, outcome, ctx) {
if ((encounter.ruleset || '5e') === 'generic') {
throw new Error('Death saves not available in generic ruleset.');
}
const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.');
const statusBefore = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
@@ -764,6 +841,9 @@ async function toggleCondition(encounter, participantId, conditionId, ctx) {
}
async function stabilizeParticipant(encounter, participantId, ctx) {
if ((encounter.ruleset || '5e') === 'generic') {
throw new Error('Stabilize not available in generic ruleset.');
}
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');
@@ -806,12 +886,17 @@ async function stabilizeParticipant(encounter, participantId, ctx) {
}
async function reviveParticipant(encounter, participantId, ctx) {
const ruleset = encounter.ruleset || '5e';
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 };
// Generic: dead→conscious (no stable state). HP unchanged (may be negative).
// 5e: dead→stable at 0 HP, unconscious, clears death-save counters.
const updates = ruleset === 'generic'
? { status: 'conscious', ...(participant.type === 'monster' ? {} : {}), ...(participant.isActive === false ? { isActive: true } : {}) }
: { currentHp: 0, status: 'stable', deathSaveSuccesses: 0, deathSaveFailures: 0, isActive: true };
const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p
);
@@ -845,6 +930,34 @@ async function reviveParticipant(encounter, participantId, ctx) {
return commit(encounter, patch, log, ctx);
}
// markDead: DM manually sets status dead. Both rulesets. Monster = auto-inactive.
async function markDead(encounter, participantId, ctx) {
const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.');
if (participant.status === 'dead') return encounter;
const oldValues = {
currentHp: participant.currentHp,
status: participant.status || 'conscious',
isActive: participant.isActive,
conditions: [...(participant.conditions || [])],
};
const updates = { status: 'dead', ...(participant.type === 'monster' ? { isActive: false } : {}) };
const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p
);
const newP = updatedParticipants.find(p => p.id === participantId);
const newValues = {
currentHp: newP.currentHp, status: newP.status,
isActive: newP.isActive, conditions: [...(newP.conditions || [])],
};
return commit(encounter, { participants: updatedParticipants }, {
type: 'mark_dead', participantId, participantName: participant.name,
message: `${participant.name} marked dead`,
delta: { status: 'dead' },
undo: { oldValues, newValues },
}, ctx);
}
async function reorderParticipants(encounter, draggedId, targetId, ctx) {
const participants = [...(encounter.participants || [])];
const dragged = participants.find(p => p.id === draggedId);
@@ -919,7 +1032,7 @@ module.exports = {
makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
startEncounter, nextTurn, togglePause,
addParticipant, addParticipants, updateParticipant, removeParticipant,
toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, toggleCondition,
toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, markDead, toggleCondition,
reorderParticipants, endEncounter,
activateDisplay, clearDisplay, toggleHidePlayerHp,
};