From c2bb5cca29fe49eab491f731c0933cb726ee68ec Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:32:26 -0400 Subject: [PATCH 01/24] Generic (non-5e) ruleset mode: negative HP, down status, mark dead/revive New campaign/encounter ruleset toggle: 5e (default, unchanged) vs generic. Generic mode: - No death saves (deathSave/stabilize throw) - Negative HP allowed (no clamp at 0) - <=0 HP = status 'down' (no pips, no unconscious auto-condition) - Monster death = dead + inactive (same as 5e) - markDead button: DM sets dead manually (both modes) - revive: dead->conscious (generic), dead->stable (5e) 5e mode: zero behavior change. shared/turn.js: - applyHpChangeGeneric: negative HP, down status, no death-save logic - markDead: status dead, monster auto-inactive - reviveParticipant: ruleset-aware (generic=conscious, 5e=stable) - deathSave/stabilize: throw in generic - expandUndo: mark_dead case added UI (src/App.js): - CreateCampaignForm + CreateEncounterForm: ruleset radio toggle - handleCreateCampaign/handleCreateEncounter: store ruleset field - EncounterManager: fetch campaignDoc for default ruleset inheritance - DM participant: down/dead labels, Mark Dead button (generic), Revive both - death-save pips/buttons gated 5e-only - DisplayView: down label, generic status derivation Tests: 15 generic cases (turn.generic.test.js). 186 shared total. --- shared/tests/turn.generic.test.js | 185 ++++++++++++++++++++++++++++++ shared/turn.js | 117 ++++++++++++++++++- src/App.js | 86 ++++++++++++-- 3 files changed, 374 insertions(+), 14 deletions(-) create mode 100644 shared/tests/turn.generic.test.js diff --git a/shared/tests/turn.generic.test.js b/shared/tests/turn.generic.test.js new file mode 100644 index 0000000..e4f537e --- /dev/null +++ b/shared/tests/turn.generic.test.js @@ -0,0 +1,185 @@ +// Generic (non-5e) ruleset: no death saves, negative HP allowed, down status. +const shared = require('@ttrpg/shared'); +const { mockCtx } = require('./_helpers'); +const { + buildCharacterParticipant, buildMonsterParticipant, + startEncounter, applyHpChange, deathSave, markDead, reviveParticipant, +} = shared; + +function char(id, hp = 100) { + const { participant } = buildCharacterParticipant({ id: `orig-${id}`, name: id, defaultMaxHp: hp, defaultInitMod: 2 }); + participant.id = id; + return participant; +} +function mon(id, hp = 100) { + const { participant } = buildMonsterParticipant({ name: id, maxHp: hp, initMod: 2 }); + participant.id = id; + return participant; +} +function enc(ps, ruleset = 'generic') { + return { + name: 't', participants: ps, isStarted: false, isPaused: false, + round: 0, currentTurnParticipantId: null, turnOrderIds: [], + ruleset, + }; +} +const snap = (e) => JSON.parse(JSON.stringify(e)); + +describe('generic ruleset', () => { + test('damage past 0 = negative HP, status down (character)', async () => { + const { ctx } = mockCtx(); + let e = enc([char('a', 10)]); + e = await startEncounter(e, ctx); + const newEnc = await applyHpChange(e, 'a', 'damage', 15, ctx); + const p = newEnc.participants.find(x => x.id === 'a'); + expect(p.currentHp).toBe(-5); + expect(p.status).toBe('down'); + }); + + test('damage exactly to 0 = status down', async () => { + const { ctx } = mockCtx(); + let e = enc([char('a', 10)]); + e = await startEncounter(e, ctx); + const newEnc = await applyHpChange(e, 'a', 'damage', 10, ctx); + const p = newEnc.participants.find(x => x.id === 'a'); + expect(p.currentHp).toBe(0); + expect(p.status).toBe('down'); + }); + + test('heal from negative = conscious', async () => { + const { ctx } = mockCtx(); + let e = enc([char('a', 10)]); + e = await startEncounter(e, ctx); + e = await applyHpChange(e, 'a', 'damage', 15, ctx); // -5 + const newEnc = await applyHpChange(e, 'a', 'heal', 10, ctx); + const p = newEnc.participants.find(x => x.id === 'a'); + expect(p.currentHp).toBe(5); + expect(p.status).toBe('conscious'); + }); + + test('heal caps at maxHp', async () => { + const { ctx } = mockCtx(); + let e = enc([char('a', 10)]); + e = await startEncounter(e, ctx); + e = await applyHpChange(e, 'a', 'damage', 5, ctx); // 5 + const newEnc = await applyHpChange(e, 'a', 'heal', 20, ctx); + const p = newEnc.participants.find(x => x.id === 'a'); + expect(p.currentHp).toBe(10); + expect(p.status).toBe('conscious'); + }); + + test('monster death = auto-inactive (generic)', async () => { + const { ctx } = mockCtx(); + let e = enc([mon('m', 10)]); + e = await startEncounter(e, ctx); + const newEnc = await applyHpChange(e, 'm', 'damage', 20, ctx); + const p = newEnc.participants.find(x => x.id.startsWith('m')); + expect(p.currentHp).toBe(-10); + expect(p.status).toBe('dead'); + expect(p.isActive).toBe(false); + }); + + test('monster at exactly 0 = dead + inactive (generic)', async () => { + const { ctx } = mockCtx(); + let e = enc([mon('m', 10)]); + e = await startEncounter(e, ctx); + const newEnc = await applyHpChange(e, 'm', 'damage', 10, ctx); + const p = newEnc.participants.find(x => x.id.startsWith('m')); + expect(p.currentHp).toBe(0); + expect(p.status).toBe('dead'); + expect(p.isActive).toBe(false); + }); + + test('deathSave throws in generic mode', async () => { + const { ctx } = mockCtx(); + let e = enc([char('a', 10)]); + e = await startEncounter(e, ctx); + e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down + await expect(deathSave(e, 'a', 'fail', ctx)).rejects.toThrow(); + }); + + test('revive in generic mode: dead→conscious (no stable)', async () => { + const { ctx } = mockCtx(); + let e = enc([char('a', 10)]); + e = await startEncounter(e, ctx); + e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down + e = await markDead(e, 'a', ctx); // dead + const newEnc = await reviveParticipant(e, 'a', ctx); + const p = newEnc.participants.find(x => x.id === 'a'); + expect(p.status).toBe('conscious'); + expect(p.currentHp).toBe(0); // HP unchanged from down + }); + + test('revive generic reactivates if inactive', async () => { + const { ctx } = mockCtx(); + let e = enc([mon('m', 10)]); + e = await startEncounter(e, ctx); + e = await markDead(e, e.participants[0].id, ctx); // dead+inactive + const newEnc = await reviveParticipant(e, e.participants[0].id, ctx); + const p = newEnc.participants[0]; + expect(p.status).toBe('conscious'); + expect(p.isActive).toBe(true); + }); + + test('markDead sets status dead (character, stays active)', async () => { + const { ctx } = mockCtx(); + let e = enc([char('a', 10)]); + e = await startEncounter(e, ctx); + e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down + const newEnc = await markDead(e, 'a', ctx); + const p = newEnc.participants.find(x => x.id === 'a'); + expect(p.status).toBe('dead'); + expect(p.isActive).toBe(true); + }); + + test('markDead on monster = dead + inactive', async () => { + const { ctx } = mockCtx(); + let e = enc([mon('m', 10)]); + e = await startEncounter(e, ctx); + e = await applyHpChange(e, 'm', 'damage', 5, ctx); // down (5hp) + const newEnc = await markDead(e, e.participants[0].id, ctx); + const p = newEnc.participants[0]; + expect(p.status).toBe('dead'); + expect(p.isActive).toBe(false); + }); + + test('markDead on conscious = dead', async () => { + const { ctx } = mockCtx(); + let e = enc([char('a', 10)]); + e = await startEncounter(e, ctx); + const newEnc = await markDead(e, 'a', ctx); + const p = newEnc.participants.find(x => x.id === 'a'); + expect(p.status).toBe('dead'); + }); + + test('damage while down = more negative (no death saves)', async () => { + const { ctx } = mockCtx(); + let e = enc([char('a', 10)]); + e = await startEncounter(e, ctx); + e = await applyHpChange(e, 'a', 'damage', 15, ctx); // -5 down + const newEnc = await applyHpChange(e, 'a', 'damage', 5, ctx); + const p = newEnc.participants.find(x => x.id === 'a'); + expect(p.currentHp).toBe(-10); + expect(p.status).toBe('down'); + }); + + test('no unconscious condition auto-added (generic)', async () => { + const { ctx } = mockCtx(); + let e = enc([char('a', 10)]); + e = await startEncounter(e, ctx); + const newEnc = await applyHpChange(e, 'a', 'damage', 10, ctx); + const p = newEnc.participants.find(x => x.id === 'a'); + expect(p.conditions).not.toContain('unconscious'); + }); + + test('down→conscious clears dead? no, dead is separate', async () => { + // sanity: down heal to conscious, dead untouched + const { ctx } = mockCtx(); + let e = enc([char('a', 10)]); + e = await startEncounter(e, ctx); + e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down + const newEnc = await applyHpChange(e, 'a', 'heal', 10, ctx); + const p = newEnc.participants.find(x => x.id === 'a'); + expect(p.status).toBe('conscious'); + }); +}); diff --git a/shared/turn.js b/shared/turn.js index 5ad7bf2..0aa485a 100644 --- a/shared/turn.js +++ b/shared/turn.js @@ -207,6 +207,7 @@ function expandUndo(entry, currentEnc) { case 'death_save': case 'stabilize': case 'revive': + case 'mark_dead': case 'deactivate_dead_monster': { // Full death-state restore: currentHp, status, death-save counters, // isActive, conditions (unconscious added/removed by withDeathStatusConditions). @@ -574,6 +575,7 @@ async function toggleParticipantActive(encounter, participantId, ctx) { async function applyHpChange(encounter, participantId, changeType, amount, optionsOrCtx, maybeCtx) { const ctx = maybeCtx || optionsOrCtx; const options = maybeCtx ? (optionsOrCtx || {}) : {}; + const ruleset = encounter.ruleset || '5e'; const participant = (encounter.participants || []).find(p => p.id === participantId); if (!participant) throw new Error('Participant not found.'); if (isNaN(amount) || amount === 0) return encounter; @@ -596,6 +598,12 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio let logDelta = { amount, from: participant.currentHp }; const status = oldValues.status; + // GENERIC ruleset: no death saves, negative HP allowed, down status at <=0. + // Monster death = dead + inactive. No unconscious auto-condition. + if (ruleset === 'generic') { + return applyHpChangeGeneric(encounter, participant, changeType, amount, oldValues, ctx); + } + if (changeType === 'damage') { if (participant.currentHp === 0) { if (status === 'dead') { @@ -683,7 +691,76 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio return commit(encounter, patch, log, ctx); } +// GENERIC ruleset HP change: no death saves, negative HP, down status at <=0. +// Monster death = dead + inactive. No unconscious auto-condition. +async function applyHpChangeGeneric(encounter, participant, changeType, amount, oldValues, ctx) { + const participantId = participant.id; + let updates, message, logDelta = { amount, from: participant.currentHp }; + + if (changeType === 'damage') { + if (oldValues.status === 'dead') { + if (participant.type === 'monster' && participant.isActive !== false) { + const updatedParticipants = (encounter.participants || []).map(p => + p.id === participantId ? { ...p, isActive: false } : p + ); + const newP = updatedParticipants.find(p => p.id === participantId); + const newValues = { + currentHp: newP.currentHp, status: newP.status, + deathSaveSuccesses: 0, deathSaveFailures: 0, + isActive: newP.isActive, conditions: [...(newP.conditions || [])], + }; + return commit(encounter, { participants: updatedParticipants }, { + type: 'deactivate_dead_monster', participantId, participantName: participant.name, + message: `${participant.name} marked inactive (dead monster)`, + delta: { status: 'dead', isActive: false }, + undo: { oldValues, newValues }, + }, ctx); + } + return encounter; + } + const newHp = participant.currentHp - amount; + const isMonster = participant.type === 'monster'; + if (newHp <= 0 && isMonster) { + updates = { currentHp: newHp, status: 'dead', isActive: false }; + } else if (newHp <= 0) { + updates = { currentHp: newHp, status: 'down' }; + } else { + updates = { currentHp: newHp, status: 'conscious' }; + } + message = `${participant.name} took ${amount} damage (${participant.currentHp} → ${newHp} HP)`; + logDelta = { ...logDelta, to: newHp, status: updates.status }; + } else if (changeType === 'heal') { + if (oldValues.status === 'dead') return encounter; + const newHp = Math.min(participant.maxHp, participant.currentHp + amount); + updates = { currentHp: newHp, status: 'conscious' }; + message = `${participant.name} healed for ${amount} (${participant.currentHp} → ${newHp} HP)`; + logDelta = { ...logDelta, to: newHp, status: 'conscious' }; + } else { + throw new Error(`Unknown HP change type: ${changeType}`); + } + + const updatedParticipants = (encounter.participants || []).map(p => + p.id === participantId ? { ...p, ...updates } : p + ); + const newP = updatedParticipants.find(p => p.id === participantId); + const newValues = { + currentHp: newP.currentHp, status: newP.status, + deathSaveSuccesses: newP.deathSaveSuccesses || 0, + deathSaveFailures: newP.deathSaveFailures || 0, + isActive: newP.isActive, conditions: [...(newP.conditions || [])], + }; + const undoAmount = changeType === 'damage' ? amount : -amount; + return commit(encounter, { participants: updatedParticipants }, { + type: changeType, participantId, participantName: participant.name, + message, delta: logDelta, + undo: { amount: undoAmount, oldValues, newValues }, + }, ctx); +} + async function deathSave(encounter, participantId, outcome, ctx) { + if ((encounter.ruleset || '5e') === 'generic') { + throw new Error('Death saves not available in generic ruleset.'); + } const participant = (encounter.participants || []).find(p => p.id === participantId); if (!participant) throw new Error('Participant not found.'); const statusBefore = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious'); @@ -764,6 +841,9 @@ async function toggleCondition(encounter, participantId, conditionId, ctx) { } async function stabilizeParticipant(encounter, participantId, ctx) { + if ((encounter.ruleset || '5e') === 'generic') { + throw new Error('Stabilize not available in generic ruleset.'); + } const participant = (encounter.participants || []).find(p => p.id === participantId); if (!participant) throw new Error('Participant not found.'); const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious'); @@ -806,12 +886,17 @@ async function stabilizeParticipant(encounter, participantId, ctx) { } async function reviveParticipant(encounter, participantId, ctx) { + const ruleset = encounter.ruleset || '5e'; const participant = (encounter.participants || []).find(p => p.id === participantId); if (!participant) throw new Error('Participant not found.'); const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious'); if (status !== 'dead') return encounter; - const updates = { currentHp: 0, status: 'stable', deathSaveSuccesses: 0, deathSaveFailures: 0, isActive: true }; + // Generic: dead→conscious (no stable state). HP unchanged (may be negative). + // 5e: dead→stable at 0 HP, unconscious, clears death-save counters. + const updates = ruleset === 'generic' + ? { status: 'conscious', ...(participant.type === 'monster' ? {} : {}), ...(participant.isActive === false ? { isActive: true } : {}) } + : { currentHp: 0, status: 'stable', deathSaveSuccesses: 0, deathSaveFailures: 0, isActive: true }; const updatedParticipants = (encounter.participants || []).map(p => p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p ); @@ -845,6 +930,34 @@ async function reviveParticipant(encounter, participantId, ctx) { return commit(encounter, patch, log, ctx); } +// markDead: DM manually sets status dead. Both rulesets. Monster = auto-inactive. +async function markDead(encounter, participantId, ctx) { + const participant = (encounter.participants || []).find(p => p.id === participantId); + if (!participant) throw new Error('Participant not found.'); + if (participant.status === 'dead') return encounter; + const oldValues = { + currentHp: participant.currentHp, + status: participant.status || 'conscious', + isActive: participant.isActive, + conditions: [...(participant.conditions || [])], + }; + const updates = { status: 'dead', ...(participant.type === 'monster' ? { isActive: false } : {}) }; + const updatedParticipants = (encounter.participants || []).map(p => + p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p + ); + const newP = updatedParticipants.find(p => p.id === participantId); + const newValues = { + currentHp: newP.currentHp, status: newP.status, + isActive: newP.isActive, conditions: [...(newP.conditions || [])], + }; + return commit(encounter, { participants: updatedParticipants }, { + type: 'mark_dead', participantId, participantName: participant.name, + message: `${participant.name} marked dead`, + delta: { status: 'dead' }, + undo: { oldValues, newValues }, + }, ctx); +} + async function reorderParticipants(encounter, draggedId, targetId, ctx) { const participants = [...(encounter.participants || [])]; const dragged = participants.find(p => p.id === draggedId); @@ -919,7 +1032,7 @@ module.exports = { makeParticipant, buildCharacterParticipant, buildMonsterParticipant, startEncounter, nextTurn, togglePause, addParticipant, addParticipants, updateParticipant, removeParticipant, - toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, toggleCondition, + toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, markDead, toggleCondition, reorderParticipants, endEncounter, activateDisplay, clearDisplay, toggleHidePlayerHp, }; diff --git a/src/App.js b/src/App.js index b52f429..7ee76e2 100644 --- a/src/App.js +++ b/src/App.js @@ -125,6 +125,7 @@ const { deathSave: combatDeathSave, stabilizeParticipant, reviveParticipant, + markDead, toggleCondition: combatToggleCondition, reorderParticipants, } = shared; @@ -448,11 +449,12 @@ function ErrorDisplay({ message, critical = false }) { function CreateCampaignForm({ onCreate, onCancel }) { const [name, setName] = useState(''); const [backgroundUrl, setBackgroundUrl] = useState(''); + const [ruleset, setRuleset] = useState('5e'); const handleSubmit = (e) => { e.preventDefault(); if (name.trim()) { - onCreate(name, backgroundUrl); + onCreate(name, backgroundUrl, ruleset); } }; @@ -484,6 +486,19 @@ function CreateCampaignForm({ onCreate, onCancel }) { 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-amber-600 focus:border-amber-600 sm:text-sm text-white" /> +
Loading encounters...
; } @@ -2166,9 +2175,9 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac >- Participants: {encounter.participants?.length || 0} + {encounter.createdAt && `${new Date(encounter.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })} · `}Participants: {encounter.participants?.length || 0}
{isLive && ( @@ -2265,6 +2274,19 @@ function AdminView({ userId }) { const [campaignsWithDetails, setCampaignsWithDetails] = useState([]); const [selectedCampaignId, setSelectedCampaignId] = useState(null); + const encounterStartedRef = useRef(false); + const encounterActiveRef = useRef(false); + const manualSelectRef = useRef(false); + const prevDisplayCampaignRef = useRef(null); + + // External display change (replay/other DM) = clear manual override, allow follow. + useEffect(() => { + const cur = initialActiveInfo?.activeCampaignId || null; + if (cur !== prevDisplayCampaignRef.current) { + manualSelectRef.current = false; + prevDisplayCampaignRef.current = cur; + } + }, [initialActiveInfo]); const [showCreateModal, setShowCreateModal] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [itemToDelete, setItemToDelete] = useState(null); @@ -2324,10 +2346,15 @@ function AdminView({ userId }) { }, [campaignsData]); useEffect(() => { + // Skip follow only if user manually selected AND display hasn't changed. + // (external display change clears manualSelectRef via prevDisplay effect) + if (manualSelectRef.current && selectedCampaignId !== initialActiveInfo?.activeCampaignId) return; if ( initialActiveInfo && initialActiveInfo.activeCampaignId && - campaignsWithDetails.length > 0 + campaignsWithDetails.length > 0 && + !encounterStartedRef.current && + !encounterActiveRef.current ) { const campaignExists = campaignsWithDetails.some(c => c.id === initialActiveInfo.activeCampaignId); if (campaignExists && selectedCampaignId !== initialActiveInfo.activeCampaignId) { @@ -2500,7 +2527,14 @@ function AdminView({ userId }) { return ({encounter.createdAt && `${new Date(encounter.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })} · `}Participants: {encounter.participants?.length || 0}
+ {(encounter.startedAt || encounter.endedAt) && ( ++ {encounter.startedAt && `⚔ Started: ${new Date(encounter.startedAt).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })}`}{encounter.startedAt && encounter.endedAt && ' · '}{encounter.endedAt && `Ended: ${new Date(encounter.endedAt).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })}`} +
+ )} {isLive && ( LIVE ON PLAYER DISPLAY -- 2.34.1 From 2dfa155469338ef219aa3bd642e0a11320d2f189 Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:44:29 -0400 Subject: [PATCH 11/24] Add /display manifest for Android home-screen install Android Chrome only reads manifest at install time from current page. Root manifest has start_url '.' (root). Installing from /display launched root. Fix: separate display-manifest.json (start_url /display, scope /, landscape). App.js swaps link[rel=manifest] href when on /display path. Install from /display now launches /display standalone. --- public/display-manifest.json | 27 +++++++++++++++++++++++++++ src/App.js | 6 +++++- 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 public/display-manifest.json diff --git a/public/display-manifest.json b/public/display-manifest.json new file mode 100644 index 0000000..369bc38 --- /dev/null +++ b/public/display-manifest.json @@ -0,0 +1,27 @@ +{ + "short_name": "TTRPG Display", + "name": "TTRPG Initiative Tracker — Player Display", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": "/display", + "scope": "/", + "display": "standalone", + "orientation": "landscape", + "theme_color": "#1A202C", + "background_color": "#1A202C" +} diff --git a/src/App.js b/src/App.js index 7be5c77..4082b06 100644 --- a/src/App.js +++ b/src/App.js @@ -3519,8 +3519,12 @@ function App() { useEffect(() => { const queryParams = new URLSearchParams(window.location.search); - if (queryParams.get('playerView') === 'true' || window.location.pathname === '/display') { + const isDisplay = queryParams.get('playerView') === 'true' || window.location.pathname === '/display'; + if (isDisplay) { setIsPlayerViewOnlyMode(true); + // swap manifest so Android home-screen installs launch /display + const link = document.querySelector('link[rel="manifest"]'); + if (link) link.href = `${process.env.PUBLIC_URL || ''}/display-manifest.json`; } if (window.location.pathname === '/logs') { setIsLogsMode(true); -- 2.34.1 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 12/24] 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 }) { />