From c2bb5cca29fe49eab491f731c0933cb726ee68ec Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:32:26 -0400 Subject: [PATCH] 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. --- shared/tests/turn.generic.test.js | 185 ++++++++++++++++++++++++++++++ shared/turn.js | 117 ++++++++++++++++++- src/App.js | 86 ++++++++++++-- 3 files changed, 374 insertions(+), 14 deletions(-) create mode 100644 shared/tests/turn.generic.test.js diff --git a/shared/tests/turn.generic.test.js b/shared/tests/turn.generic.test.js new file mode 100644 index 0000000..e4f537e --- /dev/null +++ b/shared/tests/turn.generic.test.js @@ -0,0 +1,185 @@ +// Generic (non-5e) ruleset: no death saves, negative HP allowed, down status. +const shared = require('@ttrpg/shared'); +const { mockCtx } = require('./_helpers'); +const { + buildCharacterParticipant, buildMonsterParticipant, + startEncounter, applyHpChange, deathSave, markDead, reviveParticipant, +} = shared; + +function char(id, hp = 100) { + const { participant } = buildCharacterParticipant({ id: `orig-${id}`, name: id, defaultMaxHp: hp, defaultInitMod: 2 }); + participant.id = id; + return participant; +} +function mon(id, hp = 100) { + const { participant } = buildMonsterParticipant({ name: id, maxHp: hp, initMod: 2 }); + participant.id = id; + return participant; +} +function enc(ps, ruleset = 'generic') { + return { + name: 't', participants: ps, isStarted: false, isPaused: false, + round: 0, currentTurnParticipantId: null, turnOrderIds: [], + ruleset, + }; +} +const snap = (e) => JSON.parse(JSON.stringify(e)); + +describe('generic ruleset', () => { + test('damage past 0 = negative HP, status down (character)', async () => { + const { ctx } = mockCtx(); + let e = enc([char('a', 10)]); + e = await startEncounter(e, ctx); + const newEnc = await applyHpChange(e, 'a', 'damage', 15, ctx); + const p = newEnc.participants.find(x => x.id === 'a'); + expect(p.currentHp).toBe(-5); + expect(p.status).toBe('down'); + }); + + test('damage exactly to 0 = status down', async () => { + const { ctx } = mockCtx(); + let e = enc([char('a', 10)]); + e = await startEncounter(e, ctx); + const newEnc = await applyHpChange(e, 'a', 'damage', 10, ctx); + const p = newEnc.participants.find(x => x.id === 'a'); + expect(p.currentHp).toBe(0); + expect(p.status).toBe('down'); + }); + + test('heal from negative = conscious', async () => { + const { ctx } = mockCtx(); + let e = enc([char('a', 10)]); + e = await startEncounter(e, ctx); + e = await applyHpChange(e, 'a', 'damage', 15, ctx); // -5 + const newEnc = await applyHpChange(e, 'a', 'heal', 10, ctx); + const p = newEnc.participants.find(x => x.id === 'a'); + expect(p.currentHp).toBe(5); + expect(p.status).toBe('conscious'); + }); + + test('heal caps at maxHp', async () => { + const { ctx } = mockCtx(); + let e = enc([char('a', 10)]); + e = await startEncounter(e, ctx); + e = await applyHpChange(e, 'a', 'damage', 5, ctx); // 5 + const newEnc = await applyHpChange(e, 'a', 'heal', 20, ctx); + const p = newEnc.participants.find(x => x.id === 'a'); + expect(p.currentHp).toBe(10); + expect(p.status).toBe('conscious'); + }); + + test('monster death = auto-inactive (generic)', async () => { + const { ctx } = mockCtx(); + let e = enc([mon('m', 10)]); + e = await startEncounter(e, ctx); + const newEnc = await applyHpChange(e, 'm', 'damage', 20, ctx); + const p = newEnc.participants.find(x => x.id.startsWith('m')); + expect(p.currentHp).toBe(-10); + expect(p.status).toBe('dead'); + expect(p.isActive).toBe(false); + }); + + test('monster at exactly 0 = dead + inactive (generic)', async () => { + const { ctx } = mockCtx(); + let e = enc([mon('m', 10)]); + e = await startEncounter(e, ctx); + const newEnc = await applyHpChange(e, 'm', 'damage', 10, ctx); + const p = newEnc.participants.find(x => x.id.startsWith('m')); + expect(p.currentHp).toBe(0); + expect(p.status).toBe('dead'); + expect(p.isActive).toBe(false); + }); + + test('deathSave throws in generic mode', async () => { + const { ctx } = mockCtx(); + let e = enc([char('a', 10)]); + e = await startEncounter(e, ctx); + e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down + await expect(deathSave(e, 'a', 'fail', ctx)).rejects.toThrow(); + }); + + test('revive in generic mode: dead→conscious (no stable)', async () => { + const { ctx } = mockCtx(); + let e = enc([char('a', 10)]); + e = await startEncounter(e, ctx); + e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down + e = await markDead(e, 'a', ctx); // dead + const newEnc = await reviveParticipant(e, 'a', ctx); + const p = newEnc.participants.find(x => x.id === 'a'); + expect(p.status).toBe('conscious'); + expect(p.currentHp).toBe(0); // HP unchanged from down + }); + + test('revive generic reactivates if inactive', async () => { + const { ctx } = mockCtx(); + let e = enc([mon('m', 10)]); + e = await startEncounter(e, ctx); + e = await markDead(e, e.participants[0].id, ctx); // dead+inactive + const newEnc = await reviveParticipant(e, e.participants[0].id, ctx); + const p = newEnc.participants[0]; + expect(p.status).toBe('conscious'); + expect(p.isActive).toBe(true); + }); + + test('markDead sets status dead (character, stays active)', async () => { + const { ctx } = mockCtx(); + let e = enc([char('a', 10)]); + e = await startEncounter(e, ctx); + e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down + const newEnc = await markDead(e, 'a', ctx); + const p = newEnc.participants.find(x => x.id === 'a'); + expect(p.status).toBe('dead'); + expect(p.isActive).toBe(true); + }); + + test('markDead on monster = dead + inactive', async () => { + const { ctx } = mockCtx(); + let e = enc([mon('m', 10)]); + e = await startEncounter(e, ctx); + e = await applyHpChange(e, 'm', 'damage', 5, ctx); // down (5hp) + const newEnc = await markDead(e, e.participants[0].id, ctx); + const p = newEnc.participants[0]; + expect(p.status).toBe('dead'); + expect(p.isActive).toBe(false); + }); + + test('markDead on conscious = dead', async () => { + const { ctx } = mockCtx(); + let e = enc([char('a', 10)]); + e = await startEncounter(e, ctx); + const newEnc = await markDead(e, 'a', ctx); + const p = newEnc.participants.find(x => x.id === 'a'); + expect(p.status).toBe('dead'); + }); + + test('damage while down = more negative (no death saves)', async () => { + const { ctx } = mockCtx(); + let e = enc([char('a', 10)]); + e = await startEncounter(e, ctx); + e = await applyHpChange(e, 'a', 'damage', 15, ctx); // -5 down + const newEnc = await applyHpChange(e, 'a', 'damage', 5, ctx); + const p = newEnc.participants.find(x => x.id === 'a'); + expect(p.currentHp).toBe(-10); + expect(p.status).toBe('down'); + }); + + test('no unconscious condition auto-added (generic)', async () => { + const { ctx } = mockCtx(); + let e = enc([char('a', 10)]); + e = await startEncounter(e, ctx); + const newEnc = await applyHpChange(e, 'a', 'damage', 10, ctx); + const p = newEnc.participants.find(x => x.id === 'a'); + expect(p.conditions).not.toContain('unconscious'); + }); + + test('down→conscious clears dead? no, dead is separate', async () => { + // sanity: down heal to conscious, dead untouched + const { ctx } = mockCtx(); + let e = enc([char('a', 10)]); + e = await startEncounter(e, ctx); + e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down + const newEnc = await applyHpChange(e, 'a', 'heal', 10, ctx); + const p = newEnc.participants.find(x => x.id === 'a'); + expect(p.status).toBe('conscious'); + }); +}); diff --git a/shared/turn.js b/shared/turn.js index 5ad7bf2..0aa485a 100644 --- a/shared/turn.js +++ b/shared/turn.js @@ -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, }; diff --git a/src/App.js b/src/App.js index b52f429..7ee76e2 100644 --- a/src/App.js +++ b/src/App.js @@ -125,6 +125,7 @@ const { deathSave: combatDeathSave, stabilizeParticipant, reviveParticipant, + markDead, toggleCondition: combatToggleCondition, reorderParticipants, } = shared; @@ -448,11 +449,12 @@ function ErrorDisplay({ message, critical = false }) { function CreateCampaignForm({ onCreate, onCancel }) { const [name, setName] = useState(''); const [backgroundUrl, setBackgroundUrl] = useState(''); + const [ruleset, setRuleset] = useState('5e'); const handleSubmit = (e) => { e.preventDefault(); if (name.trim()) { - onCreate(name, backgroundUrl); + onCreate(name, backgroundUrl, ruleset); } }; @@ -484,6 +486,19 @@ function CreateCampaignForm({ onCreate, onCancel }) { className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white" /> +