diff --git a/shared/tests/turn.temphp.test.js b/shared/tests/turn.temphp.test.js new file mode 100644 index 0000000..e4fedbd --- /dev/null +++ b/shared/tests/turn.temphp.test.js @@ -0,0 +1,102 @@ +// Temp HP: damage hits temp first, then regular. +// setTempHp replaces (no stacking). Healing regular HP ignores temp. +const shared = require('@ttrpg/shared'); +const { makeParticipant, applyHpChange, setTempHp, addParticipant } = shared; +const { mockCtx } = require('./_helpers'); + +function setupEnc() { + const { ctx } = mockCtx(); + return { + enc: { id: 'enc1', name: 'TempEnc', participants: [], isStarted: false, isPaused: false, round: 0 }, + ctx, + }; +} + +describe('temp HP', () => { + test('setTempHp sets tempHp on participant', async () => { + const { enc, ctx } = setupEnc(); + const p = makeParticipant({ + name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20, + }); + let e = await addParticipant(enc, p, ctx); + e = await setTempHp(e, p.id, 5, ctx); + expect(e.participants[0].tempHp).toBe(5); + }); + + test('setTempHp replaces existing temp (no stacking)', async () => { + const { enc, ctx } = setupEnc(); + const p = makeParticipant({ + name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20, + }); + let e = await addParticipant(enc, p, ctx); + e = await setTempHp(e, p.id, 5, ctx); + e = await setTempHp(e, p.id, 3, ctx); + expect(e.participants[0].tempHp).toBe(3); + }); + + test('setTempHp to 0 clears temp', async () => { + const { enc, ctx } = setupEnc(); + const p = makeParticipant({ + name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20, + }); + let e = await addParticipant(enc, p, ctx); + e = await setTempHp(e, p.id, 5, ctx); + e = await setTempHp(e, p.id, 0, ctx); + expect(e.participants[0].tempHp).toBe(0); + }); + + test('damage: temp absorbs before regular HP', async () => { + const { enc, ctx } = setupEnc(); + const p = makeParticipant({ + name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20, + }); + let e = await addParticipant(enc, p, ctx); + e = await setTempHp(e, p.id, 5, ctx); + e = await applyHpChange(e, p.id, 'damage', 8, ctx); + expect(e.participants[0].tempHp).toBe(0); + expect(e.participants[0].currentHp).toBe(17); + }); + + test('damage less than temp: only temp reduced', async () => { + const { enc, ctx } = setupEnc(); + const p = makeParticipant({ + name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20, + }); + let e = await addParticipant(enc, p, ctx); + e = await setTempHp(e, p.id, 10, ctx); + e = await applyHpChange(e, p.id, 'damage', 4, ctx); + expect(e.participants[0].tempHp).toBe(6); + expect(e.participants[0].currentHp).toBe(20); + }); + + test('heal: does not affect temp HP', async () => { + const { enc, ctx } = setupEnc(); + const p = makeParticipant({ + name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 10, + }); + let e = await addParticipant(enc, p, ctx); + e = await setTempHp(e, p.id, 5, ctx); + e = await applyHpChange(e, p.id, 'heal', 8, ctx); + expect(e.participants[0].tempHp).toBe(5); + expect(e.participants[0].currentHp).toBe(18); + }); + + test('damage exactly equals temp: temp gone, regular intact', async () => { + const { enc, ctx } = setupEnc(); + const p = makeParticipant({ + name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20, + }); + let e = await addParticipant(enc, p, ctx); + e = await setTempHp(e, p.id, 5, ctx); + e = await applyHpChange(e, p.id, 'damage', 5, ctx); + expect(e.participants[0].tempHp).toBe(0); + expect(e.participants[0].currentHp).toBe(20); + }); + + test('makeParticipant: tempHp defaults to 0', async () => { + const p = makeParticipant({ + name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20, + }); + expect(p.tempHp).toBe(0); + }); +}); diff --git a/shared/turn.js b/shared/turn.js index 4a53e30..0c41dec 100644 --- a/shared/turn.js +++ b/shared/turn.js @@ -362,6 +362,7 @@ function makeParticipant(opts) { deathSaveFailures: opts.deathSaveFailures || 0, hpFormula: opts.hpFormula || null, ac: opts.ac !== undefined ? opts.ac : null, + tempHp: opts.tempHp !== undefined ? opts.tempHp : 0, }; } @@ -374,7 +375,7 @@ function buildCharacterParticipant(character) { return { participant: makeParticipant({ name: character.name, - type: 'character', + type: character.isNpc ? 'npc' : 'character', originalCharacterId: character.id, initiative: finalInitiative, maxHp, @@ -623,6 +624,25 @@ async function toggleParticipantActive(encounter, participantId, ctx) { return commit(encounter, patch, log, ctx); } +async function setTempHp(encounter, participantId, tempHp, ctx) { + const participant = (encounter.participants || []).find(p => p.id === participantId); + if (!participant) throw new Error('Participant not found.'); + const value = Math.max(0, tempHp || 0); + const oldValues = { tempHp: participant.tempHp || 0 }; + const updatedParticipants = (encounter.participants || []).map(p => + p.id === participantId ? { ...p, tempHp: value } : p + ); + const log = { + type: 'set_temp_hp', + participantId, + participantName: participant.name, + message: `${participant.name} temp HP set to ${value}`, + delta: { tempHp: value, oldTempHp: oldValues.tempHp }, + undo: { oldValues, newValues: { tempHp: value } }, + }; + return commit(encounter, { participants: updatedParticipants }, log, ctx); +} + async function applyHpChange(encounter, participantId, changeType, amount, optionsOrCtx, maybeCtx) { const ctx = maybeCtx || optionsOrCtx; const options = maybeCtx ? (optionsOrCtx || {}) : {}; @@ -637,6 +657,7 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio const oldValues = { currentHp: participant.currentHp, + tempHp: participant.tempHp || 0, status: participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious'), deathSaveSuccesses: participant.deathSaveSuccesses || 0, deathSaveFailures: participant.deathSaveFailures || 0, @@ -648,6 +669,7 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio let message = ''; let logDelta = { amount, from: participant.currentHp }; const status = oldValues.status; + const currentTemp = participant.tempHp || 0; // GENERIC ruleset: no death saves, negative HP allowed, down status at <=0. // Monster death = dead + inactive. No unconscious auto-condition. @@ -656,6 +678,33 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio } if (changeType === 'damage') { + // Temp HP absorbs damage first. + let remainingDmg = amount; + let tempAfter = currentTemp; + if (tempAfter > 0) { + const absorbed = Math.min(tempAfter, remainingDmg); + tempAfter -= absorbed; + remainingDmg -= absorbed; + updates.tempHp = tempAfter; + } + // If fully absorbed by temp, no regular HP change. + if (remainingDmg === 0 && participant.currentHp > 0) { + const updatedParticipants0 = (encounter.participants || []).map(p => + p.id === participantId ? { ...p, tempHp: tempAfter } : p + ); + const log0 = { + type: 'hp_change', + participantId, + participantName: participant.name, + message: `${participant.name} took ${amount} damage (absorbed by temp HP, ${tempAfter} temp remaining)`, + delta: { amount, from: participant.currentHp, to: participant.currentHp, tempHp: tempAfter }, + undo: { oldValues, newValues: { ...oldValues, tempHp: tempAfter } }, + }; + return commit(encounter, { participants: updatedParticipants0 }, log0, ctx); + } + amount = remainingDmg; + const tempBefore = currentTemp; + const tempFinal = updates.tempHp !== undefined ? updates.tempHp : tempBefore; if (participant.currentHp === 0) { if (status === 'dead') { if (participant.type === 'monster' && participant.isActive !== false) { @@ -687,23 +736,23 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio 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 } : {}) }; + updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, tempHp: tempFinal, ...(participant.type === 'monster' ? { isActive: false } : {}) }; } else { - updates = { currentHp: 0, status: 'dying', deathSaveSuccesses: 0, deathSaveFailures: failures }; + updates = { currentHp: 0, status: 'dying', deathSaveSuccesses: 0, deathSaveFailures: failures, tempHp: tempFinal }; } 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 } : {}) }; + updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, tempHp: tempFinal, ...(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 } : {}) }; + updates = { currentHp: 0, status: zeroStatus, deathSaveSuccesses: 0, deathSaveFailures: 0, tempHp: tempFinal, ...(zeroStatus === 'dead' ? { isActive: false } : {}) }; } else { - updates = { currentHp: newHp, status: 'conscious' }; + updates = { currentHp: newHp, status: 'conscious', tempHp: tempFinal }; } message = `${participant.name} took ${amount} damage (${participant.currentHp} → ${newHp} HP)`; logDelta = { ...logDelta, to: newHp, status: updates.status }; @@ -747,8 +796,31 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio async function applyHpChangeGeneric(encounter, participant, changeType, amount, oldValues, ctx) { const participantId = participant.id; let updates, message, logDelta = { amount, from: participant.currentHp }; + const currentTemp = participant.tempHp || 0; if (changeType === 'damage') { + // Temp HP absorbs damage first. + let remainingDmg = amount; + let tempAfter = currentTemp; + if (tempAfter > 0) { + const absorbed = Math.min(tempAfter, remainingDmg); + tempAfter -= absorbed; + remainingDmg -= absorbed; + } + if (remainingDmg === 0) { + const updatedParticipants0 = (encounter.participants || []).map(p => + p.id === participantId ? { ...p, tempHp: tempAfter } : p + ); + const log0 = { + type: 'hp_change', participantId, participantName: participant.name, + message: `${participant.name} took ${amount} damage (absorbed by temp HP, ${tempAfter} temp remaining)`, + delta: { amount, from: participant.currentHp, to: participant.currentHp, tempHp: tempAfter }, + undo: { oldValues, newValues: { ...oldValues, tempHp: tempAfter } }, + }; + return commit(encounter, { participants: updatedParticipants0 }, log0, ctx); + } + amount = remainingDmg; + const tempFinal = tempAfter; if (oldValues.status === 'dead') { if (participant.type === 'monster' && participant.isActive !== false) { const updatedParticipants = (encounter.participants || []).map(p => @@ -772,11 +844,11 @@ async function applyHpChangeGeneric(encounter, participant, changeType, amount, const newHp = participant.currentHp - amount; const isMonster = participant.type === 'monster'; if (newHp <= 0 && isMonster) { - updates = { currentHp: newHp, status: 'dead', isActive: false }; + updates = { currentHp: newHp, status: 'dead', isActive: false, tempHp: tempFinal }; } else if (newHp <= 0) { - updates = { currentHp: newHp, status: 'down' }; + updates = { currentHp: newHp, status: 'down', tempHp: tempFinal }; } else { - updates = { currentHp: newHp, status: 'conscious' }; + updates = { currentHp: newHp, status: 'conscious', tempHp: tempFinal }; } message = `${participant.name} took ${amount} damage (${participant.currentHp} → ${newHp} HP)`; logDelta = { ...logDelta, to: newHp, status: updates.status }; @@ -1115,7 +1187,7 @@ module.exports = { makeParticipant, buildCharacterParticipant, buildMonsterParticipant, startEncounter, nextTurn, togglePause, addParticipant, addParticipants, updateParticipant, removeParticipant, - toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, markDead, toggleCondition, + toggleParticipantActive, applyHpChange, setTempHp, deathSave, stabilizeParticipant, reviveParticipant, markDead, toggleCondition, reorderParticipants, endEncounter, activateDisplay, clearDisplay, toggleHidePlayerHp, }; diff --git a/src/App.js b/src/App.js index d960371..2d682cf 100644 --- a/src/App.js +++ b/src/App.js @@ -589,6 +589,7 @@ function EditParticipantModal({ participant, onClose, onSave }) { const [hpFormula, setHpFormula] = useState(participant.hpFormula || ''); const [ac, setAc] = useState(participant.ac != null ? participant.ac : ''); const [asNpc, setAsNpc] = useState(participant.type === 'npc'); + const [tempHp, setTempHp] = useState(participant.tempHp || 0); const handleSubmit = (e) => { e.preventDefault(); @@ -608,6 +609,7 @@ function EditParticipantModal({ participant, onClose, onSave }) { maxHp: finalMaxHp, hpFormula: (participant.type === 'monster' || participant.type === 'npc') ? (hpFormula.trim() || null) : null, ac: ac !== '' ? parseInt(ac, 10) : null, + tempHp: Math.max(0, parseInt(tempHp, 10) || 0), type: participant.type === 'monster' || participant.type === 'npc' ? (asNpc ? 'npc' : 'monster') : participant.type, }); }; @@ -666,6 +668,20 @@ function EditParticipantModal({ participant, onClose, onSave }) { /> +