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
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
+82
-10
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user