From b5b339a5dd05a1c8b6dd323454c74e8d1a4aa69c Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:36:26 -0400 Subject: [PATCH] feat: temp HP, character isNpc, initiative box Temp HP: - setTempHp() in shared/turn.js (replaces, no stacking) - Damage absorbs temp HP first (both 5e + generic rulesets) - Inline temp HP input on participant card (cyan when active) - Temp HP field in edit modal - makeParticipant tempHp field (default 0) - 8 shared tests Character isNpc: - isNpc field on character roster model - buildCharacterParticipant: type 'npc' when isNpc - Add form checkbox + edit form checkbox - NPC badge on character list row - Writeback preserves isNpc Initiative box: - Amber-bordered box around initiative input - Gold text (amber-300), disabled stays gold - Stone-950 bg matches page - Bumped maxHp (stone-200) + tempHp (stone-300/cyan) visibility --- shared/tests/turn.temphp.test.js | 102 +++++++++++++++++++++++++++++++ shared/turn.js | 92 +++++++++++++++++++++++++--- src/App.js | 77 ++++++++++++++++++++--- 3 files changed, 254 insertions(+), 17 deletions(-) create mode 100644 shared/tests/turn.temphp.test.js 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 }) { /> +
+
+ + setTempHp(e.target.value)} + min="0" + placeholder="0" + 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-cyan-600 focus:border-cyan-600 sm:text-sm text-white" + /> +
+
+
{(participant.type === 'monster' || participant.type === 'npc') && (
@@ -736,6 +752,7 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) { const [defaultMaxHp, setDefaultMaxHp] = useState(DEFAULT_MAX_HP); const [defaultInitMod, setDefaultInitMod] = useState(DEFAULT_INIT_MOD); const [defaultAc, setDefaultAc] = useState(''); + const [isCharacterNpc, setIsCharacterNpc] = useState(false); const [editingCharacter, setEditingCharacter] = useState(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [itemToDelete, setItemToDelete] = useState(null); @@ -782,7 +799,8 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) { name: characterName.trim(), defaultMaxHp: hp, defaultInitMod: initMod, - defaultAc: defaultAc !== '' ? parseInt(defaultAc, 10) : null + defaultAc: defaultAc !== '' ? parseInt(defaultAc, 10) : null, + isNpc: isCharacterNpc || false, }; try { @@ -793,12 +811,13 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) { setDefaultMaxHp(DEFAULT_MAX_HP); setDefaultInitMod(DEFAULT_INIT_MOD); setDefaultAc(''); + setIsCharacterNpc(false); } catch (err) { console.error("Error adding character:", err); showToast("Failed to add character. Please try again."); } }; - const handleUpdateCharacter = async (characterId, newName, newDefaultMaxHp, newDefaultInitMod, newDefaultAc, newDefaultCurrentHp) => { + const handleUpdateCharacter = async (characterId, newName, newDefaultMaxHp, newDefaultInitMod, newDefaultAc, newDefaultCurrentHp, newIsNpc) => { if (!db || !newName.trim() || !campaignId) return; const hp = parseInt(newDefaultMaxHp, 10); @@ -815,7 +834,7 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) { const updatedCharacters = campaignCharacters.map(c => c.id === characterId - ? { ...c, name: newName.trim(), defaultMaxHp: hp, defaultInitMod: initMod, defaultAc: newDefaultAc !== undefined && newDefaultAc !== '' ? parseInt(newDefaultAc, 10) : null, defaultCurrentHp: newDefaultCurrentHp !== undefined && newDefaultCurrentHp !== '' ? parseInt(newDefaultCurrentHp, 10) : null } + ? { ...c, name: newName.trim(), defaultMaxHp: hp, defaultInitMod: initMod, defaultAc: newDefaultAc !== undefined && newDefaultAc !== '' ? parseInt(newDefaultAc, 10) : null, defaultCurrentHp: newDefaultCurrentHp !== undefined && newDefaultCurrentHp !== '' ? parseInt(newDefaultCurrentHp, 10) : null, isNpc: newIsNpc != null ? newIsNpc : (c.isNpc || false) } : c ); @@ -924,6 +943,15 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) { className="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" />
+
+ @@ -1028,6 +1066,9 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) { <>
{character.name} + {character.isNpc && ( + NPC + )}
HP {character.defaultMaxHp || '?'} {character.defaultCurrentHp != null && character.defaultCurrentHp !== character.defaultMaxHp && ( @@ -1376,6 +1417,15 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp } }; + const handleInlineTempHp = async (participantId, value) => { + const n = Math.max(0, parseInt(value, 10) || 0); + try { + await updateParticipant(encounter, participantId, { tempHp: n }, buildCtx(encounterPath)); + } catch (err) { + showToast('Failed to update Temp HP.'); + } + }; + const handleDeathSaveChange = async (participantId, outcome) => { if (!db) return; try { @@ -1778,7 +1828,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp {isDead && DEAD}

- + { e.target.select(); handleFieldFocus(p.id, 'Initiative'); }} onBlur={(e) => { if (e.target.value !== String(p.initiative)) handleInlineInitiative(p.id, e.target.value); handleFieldBlur(); }} onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); }} - className="w-7 bg-transparent text-white text-base text-center border-b border-transparent hover:border-stone-500 focus:border-amber-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + className="w-7 bg-transparent text-amber-300 text-lg font-bold text-center focus:outline-none disabled:text-amber-300 disabled:opacity-100 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" aria-label={`Initiative for ${p.name}`} /> @@ -1816,10 +1866,23 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'Max HP'); }} onBlur={(e) => { if (e.target.value !== String(p.maxHp)) handleInlineMaxHp(p.id, e.target.value); handleFieldBlur(); }} onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.maxHp; }} - className="w-10 bg-transparent text-stone-400 text-base text-center border-b border-transparent hover:border-stone-500 focus:border-red-500 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + className="w-10 bg-transparent text-stone-200 text-base text-center border-b border-transparent hover:border-stone-400 focus:border-red-500 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" aria-label={`Max HP for ${p.name}`} /> + + + + { e.target.select(); handleFieldFocus(p.id, 'Temp HP'); }} + onBlur={(e) => { if (e.target.value !== String(p.tempHp || 0)) handleInlineTempHp(p.id, e.target.value); handleFieldBlur(); }} + onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.tempHp || 0; }} + className={`w-7 bg-transparent text-base text-center border-b focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none ${(p.tempHp || 0) > 0 ? 'text-cyan-200 border-cyan-500' : 'text-stone-300 border-transparent hover:border-stone-400'}`} + aria-label={`Temp HP for ${p.name}`} + /> + {participantStatus === 'dead' && (