HP formula stored on participant, loads in edit modal

hpFormula field persisted on participant doc (makeParticipant + builder).
Add handler stores formula. Edit modal loads participant.hpFormula, reroll
button sets maxHp field (no save until save). Edit submit persists formula.
Formula only for monster/npc types.
This commit is contained in:
david raistrick
2026-07-07 17:24:36 -04:00
parent 2dfa155469
commit 6f11edab94
4 changed files with 169 additions and 17 deletions
+5
View File
@@ -14,6 +14,11 @@ not sure good way to do this
lots of updates lots of updates
## monsters per campaign and npcs
## or/and ....copy from encoubnter?
### TEST GAP: current branch changes need focused coverage ### TEST GAP: current branch changes need focused coverage
- Storage `where()` contract: firebase + server adapters should honor `[where('encounterPath','==',x), orderBy('ts','desc'), limit(n)]`. - Storage `where()` contract: firebase + server adapters should honor `[where('encounterPath','==',x), orderBy('ts','desc'), limit(n)]`.
+60
View File
@@ -0,0 +1,60 @@
// HP formula roll: parse "2d6+9" style, roll, return total.
const shared = require('@ttrpg/shared');
const { rollHpFormula } = shared;
describe('rollHpFormula', () => {
test('basic NdM', () => {
const r = rollHpFormula('2d6');
expect(r).toBeGreaterThanOrEqual(2);
expect(r).toBeLessThanOrEqual(12);
});
test('NdM+bonus', () => {
const r = rollHpFormula('2d6+9');
expect(r).toBeGreaterThanOrEqual(11);
expect(r).toBeLessThanOrEqual(21);
});
test('NdM-bonus', () => {
const r = rollHpFormula('3d8-2');
expect(r).toBeGreaterThanOrEqual(1);
expect(r).toBeLessThanOrEqual(22);
});
test('single die', () => {
const r = rollHpFormula('1d20');
expect(r).toBeGreaterThanOrEqual(1);
expect(r).toBeLessThanOrEqual(20);
});
test('case insensitive', () => {
const r = rollHpFormula('2D6+5');
expect(r).toBeGreaterThanOrEqual(7);
expect(r).toBeLessThanOrEqual(17);
});
test('whitespace tolerant', () => {
const r = rollHpFormula(' 2d6 + 9 ');
expect(r).toBeGreaterThanOrEqual(11);
expect(r).toBeLessThanOrEqual(21);
});
test('large die', () => {
const r = rollHpFormula('4d12+10');
expect(r).toBeGreaterThanOrEqual(14);
expect(r).toBeLessThanOrEqual(58);
});
test('invalid returns null', () => {
expect(rollHpFormula('abc')).toBeNull();
expect(rollHpFormula('')).toBeNull();
expect(rollHpFormula(null)).toBeNull();
expect(rollHpFormula(undefined)).toBeNull();
expect(rollHpFormula('2d')).toBeNull();
expect(rollHpFormula('d6')).toBeNull();
});
test('zero dice invalid', () => {
expect(rollHpFormula('0d6')).toBeNull();
});
});
+33 -4
View File
@@ -28,6 +28,22 @@ const generateId = () =>
const rollD20 = () => Math.floor(Math.random() * 20) + 1; const rollD20 = () => Math.floor(Math.random() * 20) + 1;
// Parse HP formula like "2d6+9" or "3d8-2" or "1d20". Roll, return total.
// Returns null on invalid input. Whitespace/case tolerant.
function rollHpFormula(str) {
if (!str || typeof str !== 'string') return null;
const m = str.trim().toLowerCase().match(/^(\d+)d(\d+)\s*([+-]\s*\d+)?$/);
if (!m) return null;
const count = parseInt(m[1], 10);
const sides = parseInt(m[2], 10);
if (count === 0 || sides === 0) return null;
let bonus = 0;
if (m[3]) bonus = parseInt(m[3].replace(/\s/g, ''), 10);
let total = 0;
for (let i = 0; i < count; i++) total += Math.floor(Math.random() * sides) + 1;
return total + bonus;
}
const formatInitMod = (mod) => { const formatInitMod = (mod) => {
if (mod === undefined || mod === null) return 'N/A'; if (mod === undefined || mod === null) return 'N/A';
return mod >= 0 ? `+${mod}` : `${mod}`; return mod >= 0 ? `+${mod}` : `${mod}`;
@@ -344,6 +360,7 @@ function makeParticipant(opts) {
status: opts.status || (opts.currentHp === 0 ? 'dying' : 'conscious'), status: opts.status || (opts.currentHp === 0 ? 'dying' : 'conscious'),
deathSaveSuccesses: opts.deathSaveSuccesses || 0, deathSaveSuccesses: opts.deathSaveSuccesses || 0,
deathSaveFailures: opts.deathSaveFailures || 0, deathSaveFailures: opts.deathSaveFailures || 0,
hpFormula: opts.hpFormula || null,
}; };
} }
@@ -365,11 +382,22 @@ function buildCharacterParticipant(character) {
}; };
} }
function buildMonsterParticipant({ name, maxHp, initMod, asNpc }) { function buildMonsterParticipant({ name, maxHp, initMod, asNpc, hpFormula }) {
const initiativeRoll = rollD20(); const initiativeRoll = rollD20();
const modifier = initMod !== undefined ? initMod : MONSTER_DEFAULT_INIT_MOD; const modifier = initMod !== undefined ? initMod : MONSTER_DEFAULT_INIT_MOD;
const finalInitiative = initiativeRoll + modifier; const finalInitiative = initiativeRoll + modifier;
const hp = maxHp || DEFAULT_MAX_HP; // maxHp wins if both set; else roll formula; else default.
let hp;
let hpRoll = null;
if (maxHp) {
hp = maxHp;
} else if (hpFormula) {
const rolled = rollHpFormula(hpFormula);
hp = rolled || DEFAULT_MAX_HP;
hpRoll = rolled;
} else {
hp = DEFAULT_MAX_HP;
}
return { return {
participant: makeParticipant({ participant: makeParticipant({
name, name,
@@ -377,8 +405,9 @@ function buildMonsterParticipant({ name, maxHp, initMod, asNpc }) {
initiative: finalInitiative, initiative: finalInitiative,
maxHp: hp, maxHp: hp,
currentHp: hp, currentHp: hp,
hpFormula: hpFormula || null,
}), }),
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative }, roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative, hpRoll },
}; };
} }
@@ -1044,7 +1073,7 @@ async function toggleHidePlayerHp(currentValue, ctx) {
module.exports = { module.exports = {
DEFAULT_MAX_HP, DEFAULT_INIT_MOD, MONSTER_DEFAULT_INIT_MOD, DEFAULT_MAX_HP, DEFAULT_INIT_MOD, MONSTER_DEFAULT_INIT_MOD,
generateId, rollD20, formatInitMod, generateId, rollD20, rollHpFormula, formatInitMod,
sortParticipantsByInitiative, syncTurnOrder, computeTurnOrderAfterRemoval, sortParticipantsByInitiative, syncTurnOrder, computeTurnOrderAfterRemoval,
snapshotOf, buildEntry, expandUndo, snapshotOf, buildEntry, expandUndo,
makeParticipant, buildCharacterParticipant, buildMonsterParticipant, makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
+71 -13
View File
@@ -581,15 +581,26 @@ function EditParticipantModal({ participant, onClose, onSave }) {
const [initiative, setInitiative] = useState(participant.initiative); const [initiative, setInitiative] = useState(participant.initiative);
const [currentHp, setCurrentHp] = useState(participant.currentHp); const [currentHp, setCurrentHp] = useState(participant.currentHp);
const [maxHp, setMaxHp] = useState(participant.maxHp); const [maxHp, setMaxHp] = useState(participant.maxHp);
const [hpFormula, setHpFormula] = useState(participant.hpFormula || '');
const [asNpc, setAsNpc] = useState(participant.type === 'npc'); const [asNpc, setAsNpc] = useState(participant.type === 'npc');
const handleSubmit = (e) => { const handleSubmit = (e) => {
e.preventDefault(); e.preventDefault();
let finalMaxHp = parseInt(maxHp, 10);
// formula rolls only if maxHp empty. maxHp wins if both.
if (isNaN(finalMaxHp) && hpFormula.trim()) {
const rolled = shared.rollHpFormula(hpFormula);
if (rolled) finalMaxHp = rolled;
}
if (isNaN(finalMaxHp)) finalMaxHp = participant.maxHp;
let finalCurrentHp = parseInt(currentHp, 10);
if (isNaN(finalCurrentHp)) finalCurrentHp = finalMaxHp;
onSave({ onSave({
name: name.trim(), name: name.trim(),
initiative: parseInt(initiative, 10), initiative: parseInt(initiative, 10),
currentHp: parseInt(currentHp, 10), currentHp: finalCurrentHp,
maxHp: parseInt(maxHp, 10), maxHp: finalMaxHp,
hpFormula: (participant.type === 'monster' || participant.type === 'npc') ? (hpFormula.trim() || null) : null,
type: participant.type === 'monster' || participant.type === 'npc' ? (asNpc ? 'npc' : 'monster') : participant.type, type: participant.type === 'monster' || participant.type === 'npc' ? (asNpc ? 'npc' : 'monster') : participant.type,
}); });
}; };
@@ -636,6 +647,32 @@ function EditParticipantModal({ participant, onClose, onSave }) {
/> />
</div> </div>
</div> </div>
{(participant.type === 'monster' || participant.type === 'npc') && (
<div>
<label className="block text-sm font-medium text-stone-300 mb-1">HP Formula</label>
<div className="flex gap-2">
<input
type="text"
value={hpFormula}
onChange={(e) => setHpFormula(e.target.value)}
placeholder="2d6+9"
className="flex-1 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"
/>
<button
type="button"
disabled={!shared.rollHpFormula(hpFormula)}
onClick={() => {
const rolled = shared.rollHpFormula(hpFormula);
if (rolled) setMaxHp(rolled);
}}
className="px-3 py-2 text-sm font-medium text-white bg-violet-700 hover:bg-violet-800 rounded-md transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
title="Roll formula, set Max HP"
>
<Dices size={16} className="inline" /> Reroll
</button>
</div>
</div>
)}
{(participant.type === 'monster' || participant.type === 'npc') && ( {(participant.type === 'monster' || participant.type === 'npc') && (
<div className="flex items-center"> <div className="flex items-center">
<input <input
@@ -972,7 +1009,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
const [participantType, setParticipantType] = useState('monster'); const [participantType, setParticipantType] = useState('monster');
const [selectedCharacterId, setSelectedCharacterId] = useState(''); const [selectedCharacterId, setSelectedCharacterId] = useState('');
const [monsterInitMod, setMonsterInitMod] = useState(MONSTER_DEFAULT_INIT_MOD); const [monsterInitMod, setMonsterInitMod] = useState(MONSTER_DEFAULT_INIT_MOD);
const [maxHp, setMaxHp] = useState(DEFAULT_MAX_HP); const [maxHp, setMaxHp] = useState('');
const [hpFormula, setHpFormula] = useState('');
const [manualInitiative, setManualInitiative] = useState(''); const [manualInitiative, setManualInitiative] = useState('');
const [asNpc, setAsNpc] = useState(false); const [asNpc, setAsNpc] = useState(false);
const [editingParticipant, setEditingParticipant] = useState(null); const [editingParticipant, setEditingParticipant] = useState(null);
@@ -1002,11 +1040,11 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
if (selectedChar && selectedChar.defaultMaxHp) { if (selectedChar && selectedChar.defaultMaxHp) {
setMaxHp(selectedChar.defaultMaxHp); setMaxHp(selectedChar.defaultMaxHp);
} else { } else {
setMaxHp(DEFAULT_MAX_HP); setMaxHp('');
} }
setAsNpc(false); setAsNpc(false);
} else if (participantType === 'monster') { } else if (participantType === 'monster') {
setMaxHp(DEFAULT_MAX_HP); setMaxHp('');
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD); setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
} }
}, [selectedCharacterId, participantType, campaignCharacters]); }, [selectedCharacterId, participantType, campaignCharacters]);
@@ -1039,6 +1077,11 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
} else { } else {
modifier = parseInt(monsterInitMod, 10) || 0; modifier = parseInt(monsterInitMod, 10) || 0;
participantAsNpc = asNpc; participantAsNpc = asNpc;
// maxHp wins if both set; else roll formula
if (!maxHp && hpFormula.trim()) {
const rolled = shared.rollHpFormula(hpFormula);
if (rolled) currentMaxHp = rolled;
}
} }
const computedInitiative = manualInit ? finalInitiative : (initiativeRoll + modifier); const computedInitiative = manualInit ? finalInitiative : (initiativeRoll + modifier);
@@ -1050,6 +1093,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
initiative: computedInitiative, initiative: computedInitiative,
maxHp: currentMaxHp, maxHp: currentMaxHp,
currentHp: currentMaxHp, currentHp: currentMaxHp,
hpFormula: participantType === 'monster' ? (hpFormula.trim() || null) : null,
conditions: [], conditions: [],
isActive: true, isActive: true,
status: 'conscious', status: 'conscious',
@@ -1071,7 +1115,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
setTimeout(() => setLastRollDetails(null), ROLL_DISPLAY_DURATION); setTimeout(() => setLastRollDetails(null), ROLL_DISPLAY_DURATION);
setParticipantName(''); setParticipantName('');
setMaxHp(DEFAULT_MAX_HP); setMaxHp('');
setHpFormula('');
setSelectedCharacterId(''); setSelectedCharacterId('');
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD); setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
setAsNpc(false); setAsNpc(false);
@@ -1332,7 +1377,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
e.preventDefault(); e.preventDefault();
handleAddParticipant(); handleAddParticipant();
}} }}
className="grid grid-cols-1 md:grid-cols-6 gap-x-4 gap-y-2 mb-4 p-3 bg-stone-800 rounded items-end" className="grid grid-cols-1 md:grid-cols-12 gap-x-4 gap-y-2 mb-4 p-3 bg-stone-800 rounded items-end"
> >
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-sm font-medium text-stone-300">Type</label> <label className="block text-sm font-medium text-stone-300">Type</label>
@@ -1352,7 +1397,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
{participantType === 'monster' ? ( {participantType === 'monster' ? (
<> <>
<div className="md:col-span-4"> <div className="md:col-span-10">
<label htmlFor="monsterName" className="block text-sm font-medium text-stone-300"> <label htmlFor="monsterName" className="block text-sm font-medium text-stone-300">
Monster Name Monster Name
</label> </label>
@@ -1365,7 +1410,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
className="mt-1 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" className="mt-1 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"
/> />
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-3">
<label htmlFor="monsterInitMod" className="block text-sm font-medium text-stone-300"> <label htmlFor="monsterInitMod" className="block text-sm font-medium text-stone-300">
Init Mod Init Mod
</label> </label>
@@ -1377,7 +1422,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
className="mt-1 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" className="mt-1 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"
/> />
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-3">
<label htmlFor="manualInitiative" className="block text-sm font-medium text-stone-300"> <label htmlFor="manualInitiative" className="block text-sm font-medium text-stone-300">
Initiative <span className="text-xs text-stone-400">(blank=roll)</span> Initiative <span className="text-xs text-stone-400">(blank=roll)</span>
</label> </label>
@@ -1390,7 +1435,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
className="mt-1 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" className="mt-1 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"
/> />
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-3">
<label htmlFor="monsterMaxHp" className="block text-sm font-medium text-stone-300"> <label htmlFor="monsterMaxHp" className="block text-sm font-medium text-stone-300">
Max HP Max HP
</label> </label>
@@ -1402,7 +1447,20 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
className="mt-1 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" className="mt-1 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"
/> />
</div> </div>
<div className="md:col-span-2 flex items-center pt-5"> <div className="md:col-span-3">
<label htmlFor="monsterHpFormula" className="block text-sm font-medium text-stone-300">
HP Formula (e.g. 2d6+9)
</label>
<input
type="text"
id="monsterHpFormula"
value={hpFormula}
onChange={(e) => setHpFormula(e.target.value)}
placeholder="2d6+9"
className="mt-1 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"
/>
</div>
<div className="md:col-span-12 flex items-center pt-2">
<input <input
type="checkbox" type="checkbox"
id="asNpc" id="asNpc"
@@ -1457,7 +1515,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
</> </>
)} )}
<div className="md:col-span-6 flex justify-end mt-2"> <div className="md:col-span-12 flex justify-end mt-2">
<button <button
type="submit" type="submit"
disabled={encounter.isStarted && !encounter.isPaused} disabled={encounter.isStarted && !encounter.isPaused}