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:
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user