From 6f11edab94be7b9b90c3dca7176b5b1cd72d14ca Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:24:36 -0400 Subject: [PATCH] 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. --- TODO.md | 5 ++ shared/tests/turn.hpformula.test.js | 60 +++++++++++++++++++++ shared/turn.js | 37 +++++++++++-- src/App.js | 84 ++++++++++++++++++++++++----- 4 files changed, 169 insertions(+), 17 deletions(-) create mode 100644 shared/tests/turn.hpformula.test.js diff --git a/TODO.md b/TODO.md index 68d6594..b08d6a4 100644 --- a/TODO.md +++ b/TODO.md @@ -14,6 +14,11 @@ not sure good way to do this lots of updates +## monsters per campaign and npcs +## or/and ....copy from encoubnter? + + + ### TEST GAP: current branch changes need focused coverage - Storage `where()` contract: firebase + server adapters should honor `[where('encounterPath','==',x), orderBy('ts','desc'), limit(n)]`. diff --git a/shared/tests/turn.hpformula.test.js b/shared/tests/turn.hpformula.test.js new file mode 100644 index 0000000..9da04bc --- /dev/null +++ b/shared/tests/turn.hpformula.test.js @@ -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(); + }); +}); diff --git a/shared/turn.js b/shared/turn.js index 1352209..1046286 100644 --- a/shared/turn.js +++ b/shared/turn.js @@ -28,6 +28,22 @@ const generateId = () => 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) => { if (mod === undefined || mod === null) return 'N/A'; return mod >= 0 ? `+${mod}` : `${mod}`; @@ -344,6 +360,7 @@ function makeParticipant(opts) { status: opts.status || (opts.currentHp === 0 ? 'dying' : 'conscious'), deathSaveSuccesses: opts.deathSaveSuccesses || 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 modifier = initMod !== undefined ? initMod : MONSTER_DEFAULT_INIT_MOD; 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 { participant: makeParticipant({ name, @@ -377,8 +405,9 @@ function buildMonsterParticipant({ name, maxHp, initMod, asNpc }) { initiative: finalInitiative, maxHp: 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 = { DEFAULT_MAX_HP, DEFAULT_INIT_MOD, MONSTER_DEFAULT_INIT_MOD, - generateId, rollD20, formatInitMod, + generateId, rollD20, rollHpFormula, formatInitMod, sortParticipantsByInitiative, syncTurnOrder, computeTurnOrderAfterRemoval, snapshotOf, buildEntry, expandUndo, makeParticipant, buildCharacterParticipant, buildMonsterParticipant, diff --git a/src/App.js b/src/App.js index 4082b06..f6fc7c0 100644 --- a/src/App.js +++ b/src/App.js @@ -581,15 +581,26 @@ function EditParticipantModal({ participant, onClose, onSave }) { const [initiative, setInitiative] = useState(participant.initiative); const [currentHp, setCurrentHp] = useState(participant.currentHp); const [maxHp, setMaxHp] = useState(participant.maxHp); + const [hpFormula, setHpFormula] = useState(participant.hpFormula || ''); const [asNpc, setAsNpc] = useState(participant.type === 'npc'); const handleSubmit = (e) => { 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({ name: name.trim(), initiative: parseInt(initiative, 10), - currentHp: parseInt(currentHp, 10), - maxHp: parseInt(maxHp, 10), + currentHp: finalCurrentHp, + 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, }); }; @@ -636,6 +647,32 @@ function EditParticipantModal({ participant, onClose, onSave }) { /> + {(participant.type === 'monster' || participant.type === 'npc') && ( +