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" /> +
+ +
+ + +
+
+
+ +
+ + +
+
)} + {isGeneric && !isDead && ( + + )} + {hasDeathSaves && participantStatus === 'stable' && (
Stable — regains 1 HP after 1d4 hours
)} @@ -1950,6 +1999,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac campaignId ? getPath.encounters(campaignId) : null ); const { data: activeDisplayInfo } = useFirestoreDocument(getPath.activeDisplay()); + const { data: campaignDoc } = useFirestoreDocument(campaignId ? getPath.campaign(campaignId) : null); const [encounters, setEncounters] = useState([]); const [selectedEncounterId, setSelectedEncounterId] = useState(null); @@ -1992,7 +2042,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac } }, [campaignId, initialActiveEncounterId, activeDisplayInfo, encounters]); - const handleCreateEncounter = async (name) => { + const handleCreateEncounter = async (name, ruleset = '5e') => { if (!db || !name.trim() || !campaignId) return; const newEncounterId = generateId(); @@ -2005,7 +2055,8 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac round: 0, currentTurnParticipantId: null, isStarted: false, - isPaused: false + isPaused: false, + ruleset: ruleset || '5e', }); setShowCreateModal(false); @@ -2153,8 +2204,10 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac {showCreateModal && ( setShowCreateModal(false)} title="Create New Encounter"> setShowCreateModal(false)} + defaultRuleset={campaignDoc?.ruleset || '5e'} /> )} @@ -2283,7 +2336,7 @@ function AdminView({ userId }) { } }, [initialActiveInfo, campaignsWithDetails, selectedCampaignId]); - const handleCreateCampaign = async (name, backgroundUrl) => { + const handleCreateCampaign = async (name, backgroundUrl, ruleset = '5e') => { if (!db || !name.trim()) return; const newCampaignId = generateId(); @@ -2295,6 +2348,7 @@ function AdminView({ userId }) { ownerId: userId, createdAt: new Date().toISOString(), players: [], + ruleset: ruleset || '5e', }); setShowCreateModal(false); @@ -2478,6 +2532,12 @@ function AdminView({ userId }) { > Delete + + {campaign.ruleset === 'generic' ? 'GEN' : '5e'} +
); @@ -2847,8 +2907,9 @@ function DisplayView() {
{participantsToRender.map(p => { - const status = p.status || (p.currentHp === 0 ? 'dying' : 'conscious'); - const isZeroHp = p.currentHp === 0; + const isGeneric = (activeEncounterData.ruleset || '5e') === 'generic'; + const status = p.status || (isGeneric ? (p.currentHp <= 0 ? 'down' : 'conscious') : (p.currentHp === 0 ? 'dying' : 'conscious')); + const isZeroHp = p.currentHp <= 0; let participantBgColor = p.type === 'monster' || p.type === 'npc' ? 'bg-[#8e351c]' : 'bg-indigo-950'; @@ -2882,6 +2943,7 @@ function DisplayView() { )} {status === 'dying' && (Dying)} {status === 'stable' && (p.conditions || []).includes('unconscious') && (Unconscious)} + {status === 'down' && (Down)} {status === 'dead' && (Dead)} Date: Tue, 7 Jul 2026 14:28:32 -0400 Subject: [PATCH 02/24] Generic ruleset UI + campaign/encounter tags + switch-during-combat fix Campaign + encounter cards: ruleset tag (5e/GEN), create date visible. CreateEncounterForm keyed by campaignId so default ruleset syncs on campaign switch. Campaign switch during active combat: - encounterStartedRef (unpaused) blocks switch + toast - encounterActiveRef (started paused-or-not) gates display-follow effect - manualSelectRef tracks user clicks; external display change clears it (BUG-12 follow still works for replay/other-DM) - Prevents revert race when EncounterManager unmounts and refs go false UI: - campaign card: ruleset tag bottom-right, opposite delete - encounter card: tag inline title, date left of participants count - EncounterManager fetches campaignDoc for default ruleset inheritance Tests green: app 100, shared 186, server 40. --- src/App.js | 46 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/src/App.js b/src/App.js index 7ee76e2..63fbbf4 100644 --- a/src/App.js +++ b/src/App.js @@ -1993,7 +1993,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) { // ENCOUNTER MANAGER COMPONENT // ============================================================================ -function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters }) { +function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters, encounterStartedRef, encounterActiveRef }) { const { showToast } = useUIFeedback(); const { data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection( campaignId ? getPath.encounters(campaignId) : null @@ -2130,6 +2130,15 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac const selectedEncounter = encounters?.find(e => e.id === selectedEncounterId); + useEffect(() => { + if (encounterStartedRef) { + encounterStartedRef.current = !!(selectedEncounter && selectedEncounter.isStarted && !selectedEncounter.isPaused); + } + if (encounterActiveRef) { + encounterActiveRef.current = !!(selectedEncounter && selectedEncounter.isStarted); + } + }, [selectedEncounter, encounterStartedRef, encounterActiveRef]); + if (isLoadingEncounters && campaignId) { return

Loading encounters...

; } @@ -2166,9 +2175,9 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac >
setSelectedEncounterId(encounter.id)} className="cursor-pointer flex-grow"> -

{encounter.name}

+

{encounter.name} {encounter.ruleset === 'generic' ? 'GEN' : '5e'}

- 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 (
setSelectedCampaignId(campaign.id)} + onClick={() => { + if (encounterStartedRef.current) { + showToast('End or pause active encounter before switching campaigns.'); + return; + } + manualSelectRef.current = true; + setSelectedCampaignId(campaign.id); + }} className={cardClasses} style={cardStyle} > @@ -2574,6 +2608,8 @@ function AdminView({ userId }) { : null } campaignCharacters={selectedCampaign.characters || []} + encounterStartedRef={encounterStartedRef} + encounterActiveRef={encounterActiveRef} />
)} -- 2.34.1 From 95db6fdfdc6aad269e1e067ee8a4c5aeec2faca5 Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:31:04 -0400 Subject: [PATCH 03/24] Character section rollup: clickable title, count, persisted collapse Match campaigns rollup pattern. Title button toggles collapse (chevron left, like campaigns). Character count in header. Collapse state persisted to localStorage key ttrpg.charactersCollapsed. --- TODO.md | 13 ------------- src/App.js | 22 ++++++++++++++-------- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/TODO.md b/TODO.md index 529c687..68d6594 100644 --- a/TODO.md +++ b/TODO.md @@ -50,19 +50,6 @@ lots of updates - `isActive = id => updatedParticipants.find(p => p.id === id && p.isActive)` - Returns participant obj (truthy) not boolean. Works by accident, fragile. -### BUG: select campaign during active combat = screen flicker, no action -- In active encounter, click different campaign → flicker, no nav, no end. -- Either block (toast: end encounter first) or auto-end current + switch. -- Decide UX before fix. - - -### FEAT: generic/non-5e rules mode -- Campaign/encounter ruleset toggle: `5e` vs `generic`. -- Generic mode turns off death saves/state machine. -- Generic mode allows negative HP. -- 5e mode rejects negative damage/heal and clamps HP at 0. - - ### FEAT: clarify "Is NPC" in add-participant - Ambiguous label. May expand work based on what NPC means here (ally? monster? diff --git a/src/App.js b/src/App.js index 63fbbf4..84ff2aa 100644 --- a/src/App.js +++ b/src/App.js @@ -5,7 +5,7 @@ import { PlusCircle, Users, Swords, Trash2, Eye, Edit3, Save, XCircle, ChevronsUpDown, UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle, Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon, - StopCircle as StopCircleIcon, Users2, Dices, ChevronUp, ChevronDown, ScrollText, + StopCircle as StopCircleIcon, Users2, Dices, ChevronDown, ScrollText, Maximize2, Minimize2, Moon, Coffee, Clock, ChevronRight, X, Undo2, Redo2, Crosshair } from 'lucide-react'; @@ -682,7 +682,14 @@ function CharacterManager({ campaignId, campaignCharacters }) { const [editingCharacter, setEditingCharacter] = useState(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [itemToDelete, setItemToDelete] = useState(null); - const [isOpen, setIsOpen] = useState(true); + const [isOpen, setIsOpen] = useState(() => { + try { return localStorage.getItem('ttrpg.charactersCollapsed') !== 'true'; } + catch { return true; } + }); + useEffect(() => { + try { localStorage.setItem('ttrpg.charactersCollapsed', String(!isOpen)); } + catch {} + }, [isOpen]); const handleAddCharacter = async () => { if (!db || !characterName.trim() || !campaignId) return; @@ -769,15 +776,14 @@ function CharacterManager({ campaignId, campaignCharacters }) { <>
-

- Campaign Characters -

-- 2.34.1 From 94126567e742b62f0dd52523032cc6ad59af1fa8 Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:18:08 -0400 Subject: [PATCH 04/24] Encounter drag reorder via order field Encounter cards draggable (ChevronsUpDown handle). Drop reorders, batch updates order field on affected encounter docs. Sort by order (fallback createdAt). New encounter gets order = max+1. --- src/App.js | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/src/App.js b/src/App.js index 84ff2aa..f9f3497 100644 --- a/src/App.js +++ b/src/App.js @@ -2012,6 +2012,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac const [showCreateModal, setShowCreateModal] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [itemToDelete, setItemToDelete] = useState(null); + const [draggedEncounterId, setDraggedEncounterId] = useState(null); const selectedEncounterIdRef = useRef(selectedEncounterId); @@ -2019,6 +2020,45 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac if (encountersData) setEncounters(encountersData); }, [encountersData]); + // Sort by order field (fallback createdAt). Drag reorder writes order. + const sortedEncounters = [...(encounters || [])].sort((a, b) => { + const ao = a.order ?? null, bo = b.order ?? null; + if (ao !== null && bo !== null) return ao - bo; + if (ao !== null) return -1; + if (bo !== null) return 1; + return (a.createdAt || '').localeCompare(b.createdAt || ''); + }); + + const handleEncounterDragStart = (e, id) => { + setDraggedEncounterId(id); + e.dataTransfer.effectAllowed = 'move'; + }; + const handleEncounterDragOver = (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }; + const handleEncounterDrop = async (e, targetId) => { + e.preventDefault(); + if (!db || !draggedEncounterId || draggedEncounterId === targetId) { + setDraggedEncounterId(null); + return; + } + const reordered = [...sortedEncounters]; + const fromIdx = reordered.findIndex(x => x.id === draggedEncounterId); + const toIdx = reordered.findIndex(x => x.id === targetId); + if (fromIdx === -1 || toIdx === -1) { setDraggedEncounterId(null); return; } + const [moved] = reordered.splice(fromIdx, 1); + reordered.splice(toIdx, 0, moved); + // assign order = index, batch update + const ops = reordered.map((enc, i) => ({ + type: 'update', + path: `${getPath.encounters(campaignId)}/${enc.id}`, + data: { order: i }, + })); + try { await storage.batchWrite(ops); } catch (err) {} + setDraggedEncounterId(null); + }; + useEffect(() => { selectedEncounterIdRef.current = selectedEncounterId; }, [selectedEncounterId]); @@ -2063,6 +2103,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac isStarted: false, isPaused: false, ruleset: ruleset || '5e', + order: (encounters || []).reduce((max, e) => Math.max(max, e.order ?? -1), -1) + 1, }); setShowCreateModal(false); @@ -2169,7 +2210,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac )}
- {encounters?.map(encounter => { + {sortedEncounters.map(encounter => { const isLive = activeDisplayInfo && activeDisplayInfo.activeCampaignId === campaignId && activeDisplayInfo.activeEncounterId === encounter.id; @@ -2177,7 +2218,12 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac return (
handleEncounterDragStart(e, encounter.id)} + onDragOver={handleEncounterDragOver} + onDrop={(e) => handleEncounterDrop(e, encounter.id)} + onDragEnd={() => setDraggedEncounterId(null)} + className={`p-3 rounded-md shadow transition-all ${selectedEncounterId === encounter.id ? 'bg-amber-900 ring-2 ring-amber-500' : 'bg-stone-800 hover:bg-stone-700'} ${isLive ? 'ring-2 ring-green-500 shadow-md shadow-green-500/30' : ''} ${draggedEncounterId === encounter.id ? 'opacity-50 ring-2 ring-yellow-400' : ''} cursor-grab`} >
setSelectedEncounterId(encounter.id)} className="cursor-pointer flex-grow"> @@ -2192,6 +2238,11 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac )}
+ +
+
+ )} {(participant.type === 'monster' || participant.type === 'npc') && (
setLastRollDetails(null), ROLL_DISPLAY_DURATION); setParticipantName(''); - setMaxHp(DEFAULT_MAX_HP); + setMaxHp(''); + setHpFormula(''); setSelectedCharacterId(''); setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD); setAsNpc(false); @@ -1332,7 +1377,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp e.preventDefault(); 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" >
@@ -1352,7 +1397,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp {participantType === 'monster' ? ( <> -
+
@@ -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" />
-
+
@@ -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" />
-
+
@@ -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" />
-
+
@@ -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" />
-
+
+ + 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" + /> +
+
)} -
+
+ + {isDevToolsEnabled() && campaignsWithDetails.length > 0 && ( +
+ +
+ )} )}
@@ -2800,18 +2813,6 @@ function AdminView({ userId }) { message={`Are you sure you want to delete the campaign "${itemToDelete?.name}" and all its encounters? This action cannot be undone.`} /> - {process.env.NODE_ENV === 'development' && campaignsWithDetails.length > 0 && ( -
- -
- )} - setShowDeleteAllConfirm(false)} diff --git a/src/config/devTools.js b/src/config/devTools.js new file mode 100644 index 0000000..ba3241c --- /dev/null +++ b/src/config/devTools.js @@ -0,0 +1,6 @@ +// Dev-tools gate. Explicit opt-in via REACT_APP_DEV_TOOLS=1. +// Safe default: any value other than exactly '1' = off. +// Dynamic key access so react-scripts DefinePlugin does NOT inline the value +// at build time — allows tests + runtime env changes to take effect. +const KEY = 'REACT_APP_' + 'DEV_TOOLS'; +export const isDevToolsEnabled = () => process.env[KEY] === '1'; diff --git a/src/tests/BulkDelete.gate.test.js b/src/tests/BulkDelete.gate.test.js new file mode 100644 index 0000000..ea2c9dc --- /dev/null +++ b/src/tests/BulkDelete.gate.test.js @@ -0,0 +1,30 @@ +import { isDevToolsEnabled } from '../config/devTools'; + +describe('Bulk-delete-all dev-tools gate logic', () => { + const orig = process.env.REACT_APP_DEV_TOOLS; + + afterEach(() => { + if (orig === undefined) delete process.env.REACT_APP_DEV_TOOLS; + else process.env.REACT_APP_DEV_TOOLS = orig; + }); + + test('false when unset (safe default)', () => { + delete process.env.REACT_APP_DEV_TOOLS; + expect(isDevToolsEnabled()).toBe(false); + }); + + test('false when 0', () => { + process.env.REACT_APP_DEV_TOOLS = '0'; + expect(isDevToolsEnabled()).toBe(false); + }); + + test('false when arbitrary string', () => { + process.env.REACT_APP_DEV_TOOLS = 'true'; + expect(isDevToolsEnabled()).toBe(false); + }); + + test('true only when exactly 1', () => { + process.env.REACT_APP_DEV_TOOLS = '1'; + expect(isDevToolsEnabled()).toBe(true); + }); +}); diff --git a/src/tests/BulkDelete.render-hidden.test.js b/src/tests/BulkDelete.render-hidden.test.js new file mode 100644 index 0000000..518de89 --- /dev/null +++ b/src/tests/BulkDelete.render-hidden.test.js @@ -0,0 +1,36 @@ +import React from 'react'; +import { render, screen, waitFor, fireEvent, cleanup, act } from '@testing-library/react'; +import App from '../App'; + +describe('Bulk-delete-all button render gating', () => { + const orig = process.env.REACT_APP_DEV_TOOLS; + + afterEach(() => { + cleanup(); + if (orig === undefined) delete process.env.REACT_APP_DEV_TOOLS; + else process.env.REACT_APP_DEV_TOOLS = orig; + }); + + async function renderAndCreateCampaign() { + window.history.replaceState({}, '', '/'); + global.alert = jest.fn(); + global.window.open = jest.fn(); + render(); + await waitFor(() => screen.getByRole('button', { name: /Create Campaign/i })); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Create Campaign/i })); + }); + await waitFor(() => screen.getByLabelText(/Campaign Name/i)); + await act(async () => { + fireEvent.change(screen.getByLabelText(/Campaign Name/i), { target: { value: 'Gate Render Test' } }); + fireEvent.click(screen.getByRole('button', { name: /^Create$/i })); + await new Promise(r => setTimeout(r, 200)); + }); + } + + test('prod safety: button absent when DEV_TOOLS unset', async () => { + delete process.env.REACT_APP_DEV_TOOLS; + await renderAndCreateCampaign(); + expect(screen.queryByText(/Delete All Campaigns/i)).not.toBeInTheDocument(); + }); +}); diff --git a/src/tests/BulkDelete.render-shown.test.js b/src/tests/BulkDelete.render-shown.test.js new file mode 100644 index 0000000..b54df40 --- /dev/null +++ b/src/tests/BulkDelete.render-shown.test.js @@ -0,0 +1,36 @@ +import React from 'react'; +import { render, screen, waitFor, fireEvent, cleanup, act } from '@testing-library/react'; +import App from '../App'; + +describe('Bulk-delete-all button dev feature', () => { + const orig = process.env.REACT_APP_DEV_TOOLS; + + afterEach(() => { + cleanup(); + if (orig === undefined) delete process.env.REACT_APP_DEV_TOOLS; + else process.env.REACT_APP_DEV_TOOLS = orig; + }); + + async function renderAndCreateCampaign() { + window.history.replaceState({}, '', '/'); + global.alert = jest.fn(); + global.window.open = jest.fn(); + render(); + await waitFor(() => screen.getByRole('button', { name: /Create Campaign/i })); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Create Campaign/i })); + }); + await waitFor(() => screen.getByLabelText(/Campaign Name/i)); + await act(async () => { + fireEvent.change(screen.getByLabelText(/Campaign Name/i), { target: { value: 'Gate Render Test' } }); + fireEvent.click(screen.getByRole('button', { name: /^Create$/i })); + await new Promise(r => setTimeout(r, 200)); + }); + } + + test('dev: button present when DEV_TOOLS=1 and campaigns exist', async () => { + process.env.REACT_APP_DEV_TOOLS = '1'; + await renderAndCreateCampaign(); + await waitFor(() => screen.getByText(/Delete All Campaigns/i), { timeout: 5000 }); + }); +}); -- 2.34.1 From 863e8b3719c8878c948d0b25d02a1cc7991d922a Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:23:19 -0400 Subject: [PATCH 14/24] Add AC field (character, monster/npc, participant edit); layout polish AC (Armor Class) optional field across all participant entry points: - shared: ac field on makeParticipant + buildMonsterParticipant + buildCharacterParticipant, defaults null - CharacterManager: defaultAc state + add form field + inline edit field + display in character list - Monster add form: AC field - EditParticipantModal: AC field next to Initiative - ParticipantManager (DM list): AC badge on name row (sky-blue, stylized, large value, small label) for at-a-glance reading - Player display: no AC (DM only) Layout polish: - Add participants form: 12-col grid, 5 fields single row (Init Mod, Initiative, AC, Max HP, HP Formula), shrunk from oversized fields - Character add form: 12-col grid, name grows (col-span-6), Init Mod/AC/HP small right-aligned, order matches add participants - Character inline edit: labels added (Name/HP/Init Mod/AC), name flex-grows - HP Formula: label trimmed (example moved to placeholder 'e.g. 2d6+9'), Reroll button in edit modal - ParticipantManager init input shrunk (w-8, centered) Tests: 6 new AC builder tests (turn.ac.test.js). Existing test labels updated for renamed fields. --- TODO.md | 5 +- shared/tests/turn.ac.test.js | 35 +++++ shared/turn.js | 5 +- src/App.js | 187 ++++++++++++++++-------- src/tests/App.characterization.test.js | 2 +- src/tests/Logs.characterization.test.js | 2 +- 6 files changed, 172 insertions(+), 64 deletions(-) create mode 100644 shared/tests/turn.ac.test.js diff --git a/TODO.md b/TODO.md index f04ce7a..cbd8a64 100644 --- a/TODO.md +++ b/TODO.md @@ -9,12 +9,13 @@ fullscreen and dont lock on main app dm view and the no-game-player view also better vert tab layout - labelt friendly -needs AC for players dude +x needs AC for players dude and quick entry hp hp do not carry from encounter to ecnounter!!! - +^feature to add back to campaign character after encounter? +maybe campaign toggle in charc section (choosing each end is DM overload will be missed forgotten done wront) hp wont go over max and no temp hp support diff --git a/shared/tests/turn.ac.test.js b/shared/tests/turn.ac.test.js new file mode 100644 index 0000000..4ae1ab8 --- /dev/null +++ b/shared/tests/turn.ac.test.js @@ -0,0 +1,35 @@ +// AC field on participant + builders. +const shared = require('@ttrpg/shared'); +const { makeParticipant, buildMonsterParticipant, buildCharacterParticipant, rollHpFormula } = shared; + +describe('AC field', () => { + test('makeParticipant accepts ac', () => { + const p = makeParticipant({ name: 'Orc', type: 'monster', initiative: 10, maxHp: 15, currentHp: 15, ac: 13 }); + expect(p.ac).toBe(13); + }); + + test('makeParticipant ac defaults null', () => { + const p = makeParticipant({ name: 'Orc', type: 'monster', initiative: 10, maxHp: 15, currentHp: 15 }); + expect(p.ac).toBeNull(); + }); + + test('buildMonsterParticipant accepts ac', () => { + const { participant } = buildMonsterParticipant({ name: 'Goblin', maxHp: 7, initMod: 2, ac: 15 }); + expect(participant.ac).toBe(15); + }); + + test('buildMonsterParticipant ac defaults null', () => { + const { participant } = buildMonsterParticipant({ name: 'Goblin', maxHp: 7 }); + expect(participant.ac).toBeNull(); + }); + + test('buildCharacterParticipant accepts character ac', () => { + const { participant } = buildCharacterParticipant({ id: 'c1', name: 'Fighter', defaultMaxHp: 30, defaultInitMod: 2, defaultAc: 18 }); + expect(participant.ac).toBe(18); + }); + + test('buildCharacterParticipant ac defaults null when unset', () => { + const { participant } = buildCharacterParticipant({ id: 'c1', name: 'Fighter', defaultMaxHp: 30 }); + expect(participant.ac).toBeNull(); + }); +}); diff --git a/shared/turn.js b/shared/turn.js index 1046286..d2a1672 100644 --- a/shared/turn.js +++ b/shared/turn.js @@ -361,6 +361,7 @@ function makeParticipant(opts) { deathSaveSuccesses: opts.deathSaveSuccesses || 0, deathSaveFailures: opts.deathSaveFailures || 0, hpFormula: opts.hpFormula || null, + ac: opts.ac !== undefined ? opts.ac : null, }; } @@ -377,12 +378,13 @@ function buildCharacterParticipant(character) { initiative: finalInitiative, maxHp, currentHp: maxHp, + ac: character.defaultAc !== undefined ? character.defaultAc : null, }), roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative }, }; } -function buildMonsterParticipant({ name, maxHp, initMod, asNpc, hpFormula }) { +function buildMonsterParticipant({ name, maxHp, initMod, asNpc, hpFormula, ac }) { const initiativeRoll = rollD20(); const modifier = initMod !== undefined ? initMod : MONSTER_DEFAULT_INIT_MOD; const finalInitiative = initiativeRoll + modifier; @@ -406,6 +408,7 @@ function buildMonsterParticipant({ name, maxHp, initMod, asNpc, hpFormula }) { maxHp: hp, currentHp: hp, hpFormula: hpFormula || null, + ac: ac !== undefined ? ac : null, }), roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative, hpRoll }, }; diff --git a/src/App.js b/src/App.js index 07a2559..7d10402 100644 --- a/src/App.js +++ b/src/App.js @@ -583,6 +583,7 @@ function EditParticipantModal({ participant, onClose, onSave }) { const [currentHp, setCurrentHp] = useState(participant.currentHp); const [maxHp, setMaxHp] = useState(participant.maxHp); const [hpFormula, setHpFormula] = useState(participant.hpFormula || ''); + const [ac, setAc] = useState(participant.ac != null ? participant.ac : ''); const [asNpc, setAsNpc] = useState(participant.type === 'npc'); const handleSubmit = (e) => { @@ -602,6 +603,7 @@ function EditParticipantModal({ participant, onClose, onSave }) { currentHp: finalCurrentHp, maxHp: finalMaxHp, hpFormula: (participant.type === 'monster' || participant.type === 'npc') ? (hpFormula.trim() || null) : null, + ac: ac !== '' ? parseInt(ac, 10) : null, type: participant.type === 'monster' || participant.type === 'npc' ? (asNpc ? 'npc' : 'monster') : participant.type, }); }; @@ -618,15 +620,27 @@ function EditParticipantModal({ participant, onClose, onSave }) { 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" />
-
- - setInitiative(e.target.value)} - 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" - /> +
+
+ + setInitiative(e.target.value)} + 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" + /> +
+
+ + setAc(e.target.value)} + placeholder="optional" + 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" + /> +
@@ -656,7 +670,7 @@ function EditParticipantModal({ participant, onClose, onSave }) { type="text" value={hpFormula} onChange={(e) => setHpFormula(e.target.value)} - placeholder="2d6+9" + placeholder="e.g. 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" /> @@ -914,31 +943,51 @@ function CharacterManager({ campaignId, campaignCharacters }) { character.id, editingCharacter.name, editingCharacter.defaultMaxHp, - editingCharacter.defaultInitMod + editingCharacter.defaultInitMod, + editingCharacter.defaultAc ); }} className="flex-grow flex flex-wrap gap-2 items-center" > - setEditingCharacter({ ...editingCharacter, name: e.target.value })} - className="flex-grow min-w-[100px] px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white" - /> - setEditingCharacter({ ...editingCharacter, defaultMaxHp: e.target.value })} - className="w-20 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white" - title="Default Max HP" - /> - setEditingCharacter({ ...editingCharacter, defaultInitMod: e.target.value })} - className="w-20 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white" - title="Default Init Mod" - /> +
+ + setEditingCharacter({ ...editingCharacter, name: e.target.value })} + className="w-full px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white" + /> +
+
+ + setEditingCharacter({ ...editingCharacter, defaultMaxHp: e.target.value })} + className="w-20 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white" + title="Default Max HP" + /> +
+
+ + setEditingCharacter({ ...editingCharacter, defaultInitMod: e.target.value })} + className="w-20 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white" + title="Default Init Mod" + /> +
+
+ + setEditingCharacter({ ...editingCharacter, defaultAc: e.target.value })} + className="w-16 px-2 py-1 bg-stone-700 border border-stone-600 rounded-md text-white" + title="AC" + /> +
@@ -955,7 +1004,7 @@ function CharacterManager({ campaignId, campaignCharacters }) { {character.name}{' '} - (HP: {character.defaultMaxHp || 'N/A'}, Init Mod: {formatInitMod(character.defaultInitMod)}) + (HP: {character.defaultMaxHp || 'N/A'}, Init Mod: {formatInitMod(character.defaultInitMod)}{character.defaultAc != null ? `, AC: ${character.defaultAc}` : ''})
@@ -964,7 +1013,8 @@ function CharacterManager({ campaignId, campaignCharacters }) { id: character.id, name: character.name, defaultMaxHp: character.defaultMaxHp || DEFAULT_MAX_HP, - defaultInitMod: character.defaultInitMod || DEFAULT_INIT_MOD + defaultInitMod: character.defaultInitMod || DEFAULT_INIT_MOD, + defaultAc: character.defaultAc ?? '' })} className="p-1 rounded transition-colors text-yellow-400 hover:text-yellow-300 bg-stone-700 hover:bg-stone-600" aria-label="Edit character" @@ -1013,6 +1063,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp const [maxHp, setMaxHp] = useState(''); const [hpFormula, setHpFormula] = useState(''); const [manualInitiative, setManualInitiative] = useState(''); + const [monsterAc, setMonsterAc] = useState(''); const [asNpc, setAsNpc] = useState(false); const [editingParticipant, setEditingParticipant] = useState(null); const [hpChangeValues, setHpChangeValues] = useState({}); @@ -1095,6 +1146,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp maxHp: currentMaxHp, currentHp: currentMaxHp, hpFormula: participantType === 'monster' ? (hpFormula.trim() || null) : null, + ac: participantType === 'monster' && monsterAc !== '' ? parseInt(monsterAc, 10) : (participantType === 'character' ? (campaignCharacters.find(c => c.id === selectedCharacterId)?.defaultAc ?? null) : null), conditions: [], isActive: true, status: 'conscious', @@ -1120,6 +1172,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp setHpFormula(''); setSelectedCharacterId(''); setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD); + setMonsterAc(''); setAsNpc(false); setManualInitiative(''); } catch (err) { @@ -1411,7 +1464,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" />
-
+
@@ -1423,7 +1476,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" />
-
+
@@ -1436,7 +1489,17 @@ 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" />
-
+
+ + setMonsterAc(e.target.value)} + 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" + /> +
+
@@ -1448,16 +1511,16 @@ 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" />
-
+
setHpFormula(e.target.value)} - placeholder="2d6+9" + placeholder="e.g. 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" />
@@ -1579,6 +1642,12 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp

{isZeroHp && ☠️} {p.name} ({participantDisplayType}) + {p.ac != null && ( + + AC + {p.ac} + + )} {isCurrentTurn && !encounter.isPaused && ( CURRENT @@ -1604,7 +1673,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp onFocus={(e) => e.target.select()} onBlur={(e) => { if (e.target.value !== String(p.initiative)) handleInlineInitiative(p.id, e.target.value); }} onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); }} - className="w-10 px-1 py-0.5 bg-stone-800 border border-stone-700 rounded-md shadow-sm text-white text-sm focus:outline-none focus:ring-1 focus:ring-amber-600 focus:border-amber-600 disabled:opacity-50 disabled:cursor-not-allowed" + className="w-8 px-1 py-0.5 bg-stone-800 border border-stone-700 rounded-md shadow-sm text-white text-sm text-center focus:outline-none focus:ring-1 focus:ring-amber-600 focus:border-amber-600 disabled:opacity-50 disabled:cursor-not-allowed" aria-label={`Initiative for ${p.name}`} /> diff --git a/src/tests/App.characterization.test.js b/src/tests/App.characterization.test.js index 83d5f2a..93a64c1 100644 --- a/src/tests/App.characterization.test.js +++ b/src/tests/App.characterization.test.js @@ -62,7 +62,7 @@ describe('Campaign -> Firebase', () => { // CharacterManager form fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Brog' } }); - fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: '25' } }); + fireEvent.change(screen.getByLabelText(/^HP$/i), { target: { value: '25' } }); fireEvent.change(screen.getByLabelText(/Init Mod/i), { target: { value: '3' } }); fireEvent.click(screen.getByRole('button', { name: /Add Character/i })); diff --git a/src/tests/Logs.characterization.test.js b/src/tests/Logs.characterization.test.js index 6ddd0be..316cc7f 100644 --- a/src/tests/Logs.characterization.test.js +++ b/src/tests/Logs.characterization.test.js @@ -20,7 +20,7 @@ async function addCharacterToEncounter(name = 'Hero', hp = 10) { await selectCampaignByName(`DS-${name}`); fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: name } }); - fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: String(hp) } }); + fireEvent.change(screen.getByLabelText(/^HP$/i), { target: { value: String(hp) } }); fireEvent.click(screen.getByRole('button', { name: /Add Character/i })); await waitFor(() => findCalls('updateDoc', '/campaigns/').find(c => c.data.players?.length === 1)); const charId = findCalls('updateDoc', '/campaigns/').find(c => c.data.players?.length === 1).data.players[0].id; -- 2.34.1 From 41c1e48874698c196297467ce23462243f9dafc4 Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:19:10 -0400 Subject: [PATCH 15/24] Wake lock persistence, button reposition, docs, dev LAN support Wake lock (Prevent Sleep) toggles now persist across reloads via localStorage in both AdminView and DisplayView. Buttons repositioned inline in AdminView campaigns header bar (was floating overlay causing overlap on tablets). DisplayView buttons persist localStorage too. Wake lock acquire failure now shows toast with fix hint (HTTPS or Chrome flag). Fullscreenchange listener re-acquires wake lock (Android discards on screen off). dev-start.sh: auto-detects LAN IP (en0/en1), frontend binds 0.0.0.0, backend URL inlined as LAN IP so phones reach backend. DANGEROUSLY_DISABLE_HOST_CHECK for LAN access. Outputs LAN URL + wake lock flag instructions. Docs: README 'Prevent Sleep (Wake Lock)' section covering secure context requirement, Android Chrome flag workaround for LAN testing, iOS Safari standalone PWA bug. DEVELOPMENT.md LAN access + wake lock note. --- README.md | 20 ++++++++++ TODO.md | 2 +- docs/DEVELOPMENT.md | 8 ++++ scripts/dev-start.sh | 16 ++++++-- src/App.js | 90 +++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 130 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4382857..fb24170 100644 --- a/README.md +++ b/README.md @@ -307,6 +307,26 @@ ttrpg-initiative-tracker/ * `docs/GLOSSARY.md` — domain terms (turn vs. round, etc.) * `TODO.md` — known bugs and backlog +## Prevent Sleep (Wake Lock) + +DM and Player views have a Coffee/Moon toggle to keep screen awake during combat. + +**Requires secure context** (HTTPS or localhost). The Screen Wake Lock API refuses +on plain HTTP. + +- **Prod (HTTPS):** works automatically on all devices. +- **Local dev (localhost):** works automatically — localhost is a trusted origin. +- **LAN IP (phones, plain HTTP):** wake lock fails silently. Two options: + 1. **Android Chrome flag (dev testing):** `chrome://flags/#unsafely-treat-insecure-origin-as-secure` → add full URL with port, e.g. `http://10.0.0.5:3999`. Enable flag, relaunch. + 2. **mkcert local CA:** generate trusted cert for LAN IP. Heavier setup. + +**iOS Safari:** Wake Lock API broken in standalone PWA mode (iOS 16.4–18.4, WebKit bug 254545). Fixed in 18.4+. Works in browser tab (not installed PWA). + +**Troubleshooting:** if toggle shows active but screen still sleeps, check: +- Battery saver / power save mode (browser refuses) +- Chrome devtools console for `WakeLockError` +- Secure context: `window.isSecureContext` must be `true` + ## Contributing If you want to contribute, send me a message here: [https://discourse.draft13.com/c/ttrpg-initiative-tracker/16](https://discourse.draft13.com/c/ttrpg-initiative-tracker/16), and I can add to this Gitea instance and you can feel free to fork the repository and submit pull requests. For major changes, please pose a topic to the Discourse instance above linked above first to discuss what you would like to change. diff --git a/TODO.md b/TODO.md index cbd8a64..ee69e02 100644 --- a/TODO.md +++ b/TODO.md @@ -5,7 +5,7 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md. ## Open -fullscreen and dont lock on main app dm view and the no-game-player view +x fullscreen and dont lock on main app dm view and the no-game-player view ...and doesnt actually prevent lock on android also better vert tab layout - labelt friendly diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index b26deef..4c9ad6f 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -63,6 +63,14 @@ git config core.hooksPath .githooks # enable pre-push test gate Idempotent: if port busy, leaves existing proc as-is. +**LAN access (phones):** frontend binds 0.0.0.0, LAN IP auto-detected. +Other devices hit `http://:3999`. + +**Wake Lock (Prevent Sleep) on LAN IP:** requires secure context. +Plain HTTP on LAN IP = wake lock silently fails. Android Chrome flag workaround: +`chrome://flags/#unsafely-treat-insecure-origin-as-secure` → add +`http://:3999` (with port), enable, relaunch. See README for full details. + Smoke check: ```bash curl http://127.0.0.1:4001/health # -> {"ok":true} diff --git a/scripts/dev-start.sh b/scripts/dev-start.sh index e87855b..7e1a47c 100755 --- a/scripts/dev-start.sh +++ b/scripts/dev-start.sh @@ -27,11 +27,15 @@ fi # frontend: server storage, :3999 if ! lsof -ti :3999 >/dev/null 2>&1; then - echo "starting frontend :3999..." + # detect LAN ip so other devices (phones) can reach backend + LAN_IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo 127.0.0.1) + echo "starting frontend :3999 (lan ip: $LAN_IP)..." NODE_ENV=development REACT_APP_DEV_TOOLS=1 \ REACT_APP_STORAGE=server \ - REACT_APP_BACKEND_URL=http://127.0.0.1:4001 \ - REACT_APP_BACKEND_REALTIME_URL=ws://127.0.0.1:4001/ws \ + REACT_APP_BACKEND_URL=http://${LAN_IP}:4001 \ + REACT_APP_BACKEND_REALTIME_URL=ws://${LAN_IP}:4001/ws \ + DANGEROUSLY_DISABLE_HOST_CHECK=true \ + HOST=0.0.0.0 \ BROWSER=none PORT=3999 \ nohup npm start > tmp/fe.log 2>&1 & echo $! > tmp/fe.pid @@ -51,5 +55,11 @@ done echo "" echo "backend : http://127.0.0.1:4001 (curl http://127.0.0.1:4001/health)" echo "frontend : http://127.0.0.1:3999 (admin / player /display)" +echo "lan url : http://${LAN_IP}:3999 (phones/other devices on network)" echo "logs : tmp/server.log tmp/fe.log" echo "stop : ./scripts/dev-stop.sh" +echo "" +echo "NOTE: Prevent Sleep (wake lock) requires secure context (HTTPS/localhost)." +echo " LAN IP (http) won't work unless Android Chrome flag set:" +echo " chrome://flags/#unsafely-treat-insecure-origin-as-secure" +echo " Add: http://${LAN_IP}:3999 (include port)" diff --git a/src/App.js b/src/App.js index 7d10402..844689e 100644 --- a/src/App.js +++ b/src/App.js @@ -2489,6 +2489,53 @@ function AdminView({ userId }) { ); const { data: initialActiveInfo } = useFirestoreDocument(getPath.activeDisplay()); + const [isFullscreen, setIsFullscreen] = useState(false); + const [wakeLockEnabled, setWakeLockEnabled] = useState(() => { + try { return localStorage.getItem('ttrpg.wakeLock') === 'true'; } + catch { return false; } + }); + const wakeLockRef = useRef(null); + + const toggleFullscreen = () => { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen(); + } else { + document.exitFullscreen(); + } + }; + + useEffect(() => { + const onFsChange = () => setIsFullscreen(!!document.fullscreenElement); + document.addEventListener('fullscreenchange', onFsChange); + return () => document.removeEventListener('fullscreenchange', onFsChange); + }, []); + + useEffect(() => { + if (!wakeLockEnabled) { + wakeLockRef.current?.release(); + wakeLockRef.current = null; + return; + } + const acquire = async () => { + try { wakeLockRef.current = await navigator.wakeLock.request('screen'); } + catch (e) { + console.error('Wake lock failed:', e); + showToast('Prevent Sleep failed. Requires HTTPS or Chrome flag: chrome://flags/#unsafely-treat-insecure-origin-as-secure'); + } + }; + acquire(); + const onVisChange = () => { if (document.visibilityState === 'visible') acquire(); }; + const onFsChange = () => { if (document.fullscreenElement) acquire(); }; + document.addEventListener('visibilitychange', onVisChange); + document.addEventListener('fullscreenchange', onFsChange); + return () => { + document.removeEventListener('visibilitychange', onVisChange); + document.removeEventListener('fullscreenchange', onFsChange); + wakeLockRef.current?.release(); + wakeLockRef.current = null; + }; + }, [wakeLockEnabled, showToast]); + const [campaignsWithDetails, setCampaignsWithDetails] = useState([]); const [draggedCampaignId, setDraggedCampaignId] = useState(null); const [selectedCampaignId, setSelectedCampaignId] = useState(null); @@ -2735,6 +2782,22 @@ function AdminView({ userId }) { > Create Campaign +

+ + +
{!campaignsCollapsed && ( @@ -2963,7 +3026,10 @@ function DisplayView() { const [campaignBackgroundUrl, setCampaignBackgroundUrl] = useState(''); const [isPlayerDisplayActive, setIsPlayerDisplayActive] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); - const [wakeLockEnabled, setWakeLockEnabled] = useState(false); + const [wakeLockEnabled, setWakeLockEnabled] = useState(() => { + try { return localStorage.getItem('ttrpg.wakeLock') === 'true'; } + catch { return false; } + }); const [displayParticipants, setDisplayParticipants] = useState([]); const wakeLockRef = useRef(null); const currentParticipantRef = useRef(null); @@ -3029,8 +3095,12 @@ function DisplayView() { // Re-acquire after tab becomes visible again (browser auto-releases on hide) const onVisChange = () => { if (document.visibilityState === 'visible') acquire(); }; document.addEventListener('visibilitychange', onVisChange); + // Re-acquire on fullscreen change (Android discards wakeLock on screen off) + const onFsChange = () => { if (document.fullscreenElement) acquire(); }; + document.addEventListener('fullscreenchange', onFsChange); return () => { document.removeEventListener('visibilitychange', onVisChange); + document.removeEventListener('fullscreenchange', onFsChange); wakeLockRef.current?.release(); wakeLockRef.current = null; }; @@ -3121,6 +3191,22 @@ function DisplayView() { if (!isPlayerDisplayActive || !activeEncounterData) { return (
+
+ + +

Game Session Paused

The Dungeon Master has not activated an encounter for display.

@@ -3153,7 +3239,7 @@ function DisplayView() { >
+
-
+
, + document.body ); } @@ -846,7 +849,7 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) { return ( <> -
+
-
+ {(() => { + const open = addSectionOpen && !combatActive; + return ( +
+ { if (!combatActive) { e.preventDefault(); setAddSectionOpen(v => !v); } else { e.preventDefault(); } }} + className="cursor-pointer text-lg font-medium text-amber-200 font-cinzel tracking-wide flex items-center gap-2 select-none" + > + {open ? : } + Add Participants + {combatActive && (pause to add)} + + {open && ( + <> +
+ +
{/* Warning when combat is active */} {encounter.isStarted && !encounter.isPaused && ( @@ -1678,15 +1696,11 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
- - {lastRollDetails && ( -

- {lastRollDetails.manual - ? `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type === 'npc' ? 'NPC' : lastRollDetails.type}): Set initiative ${lastRollDetails.total}` - : `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type === 'npc' ? 'NPC' : lastRollDetails.type}): Rolled d20 (${lastRollDetails.roll}) ${formatInitMod(lastRollDetails.mod)} = ${lastRollDetails.total} Initiative` - } -

- )} + + )} + + ); + })()} {participants.length === 0 &&

No participants added yet.

} @@ -2017,6 +2031,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp })} + {lastRollDetails && ( +

+ {lastRollDetails.manual + ? `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type === 'npc' ? 'NPC' : lastRollDetails.type}): Set initiative ${lastRollDetails.total}` + : `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type === 'npc' ? 'NPC' : lastRollDetails.type}): Rolled d20 (${lastRollDetails.roll}) ${formatInitMod(lastRollDetails.mod)} = ${lastRollDetails.total} Initiative` + } +

+ )} + + {editingParticipant && ( -
-

Combat Controls

-
+
+

Combat Controls

+
{!encounter.isStarted ? ( ) : ( <> {/* Round Counter */} -
-

Round: {encounter.round}

+
+

Round: {encounter.round}

{encounter.isPaused && ( -

(Paused)

+

(Paused)

)}
)}
- {/* Undo / Redo — queried on click (no live logs subscription during combat) */} -
+ {/* Undo / Redo */} +
{/* Display Settings */} -
+
Player Display
@@ -2317,6 +2341,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac catch { return null; } }); const [showCreateModal, setShowCreateModal] = useState(false); + const [encounterFullscreen, setEncounterFullscreen] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [itemToDelete, setItemToDelete] = useState(null); const [draggedEncounterId, setDraggedEncounterId] = useState(null); @@ -2504,7 +2529,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac return ( <> -
+

Encounters @@ -2596,13 +2621,20 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac )} {selectedEncounter && ( -
-

- Managing Encounter: {selectedEncounter.name} +
+

+ Managing Encounter: {selectedEncounter.name} +

-
- {/* Combat Controls - Left Side (Sticky on large screens) */} -
+
+ {/* Combat Controls - always left */} +
-
+
+ )} + {isGeneric && !isDead && ( + + )} +
- {participantStatus === 'dead' && ( - - )} - - {isGeneric && !isDead && ( - - )} - {hasDeathSaves && participantStatus === 'stable' && (
Stable — regains 1 HP after 1d4 hours
)} -- 2.34.1 From c05a283cf049bf914dda5974c487b7510baed524 Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:41:35 -0400 Subject: [PATCH 21/24] feat: encounter card 'IN PROGRESS' badge + red ring for active combat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pulsing red ⚔ IN PROGRESS badge on encounter card title - Red ring around card when combat started and not ended --- src/App.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/App.js b/src/App.js index f1dba70..8ea85e0 100644 --- a/src/App.js +++ b/src/App.js @@ -2560,11 +2560,11 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac onDragOver={handleEncounterDragOver} onDrop={(e) => handleEncounterDrop(e, encounter.id)} onDragEnd={() => setDraggedEncounterId(null)} - className={`p-3 rounded-md shadow transition-all ${selectedEncounterId === encounter.id ? 'bg-amber-900 ring-2 ring-amber-500' : 'bg-stone-800 hover:bg-stone-700'} ${isLive ? 'ring-2 ring-green-500 shadow-md shadow-green-500/30' : ''} ${draggedEncounterId === encounter.id ? 'opacity-50 ring-2 ring-yellow-400' : ''} cursor-grab`} + className={`p-3 rounded-md shadow transition-all ${selectedEncounterId === encounter.id ? 'bg-amber-900 ring-2 ring-amber-500' : 'bg-stone-800 hover:bg-stone-700'} ${isLive ? 'ring-2 ring-green-500 shadow-md shadow-green-500/30' : ''} ${encounter.isStarted && !encounter.endedAt ? 'ring-2 ring-red-500' : ''} ${draggedEncounterId === encounter.id ? 'opacity-50 ring-2 ring-yellow-400' : ''} cursor-grab`} >
setSelectedEncounterId(encounter.id)} className="cursor-pointer flex-grow"> -

{encounter.name} {encounter.ruleset === 'generic' ? 'GEN' : '5e'}

+

{encounter.name} {encounter.ruleset === 'generic' ? 'GEN' : '5e'}{encounter.isStarted && !encounter.endedAt && ⚔ IN PROGRESS}

{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}

-- 2.34.1 From 2c6dfdafc8428767a02ebc52503fba53deb3f2d6 Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:56:00 -0400 Subject: [PATCH 22/24] fix: guard player display from cross-encounter conflicts - Start encounter only claims display if slot empty or already ours (prevents stealing display from another live encounter) - End encounter only clears display if THIS encounter is the one showing (prevents killing display for a different live encounter) - Use unwrapped activeDisplayData (not snapshot wrapper activeDisplayInfo) - Tests: 4 display guard cases (claim empty, no-steal busy, clear own, no-clear other) --- src/App.js | 16 +++++- src/tests/DisplayGuard.test.js | 94 ++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 src/tests/DisplayGuard.test.js diff --git a/src/App.js b/src/App.js index 8ea85e0..50292ea 100644 --- a/src/App.js +++ b/src/App.js @@ -2165,7 +2165,13 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) { try { await startEncounter(encounter, buildCtx(encounterPath)); - await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: campaignId, activeEncounterId: encounter.id }, { merge: true }); + // Only claim player display if slot is empty or already showing this encounter. + // Don't steal display from another live encounter. + const displayEmpty = !activeDisplayData || !activeDisplayData.activeEncounterId; + const displayIsOurs = activeDisplayData && activeDisplayData.activeCampaignId === campaignId && activeDisplayData.activeEncounterId === encounter.id; + if (displayEmpty || displayIsOurs) { + await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: campaignId, activeEncounterId: encounter.id }, { merge: true }); + } } catch (err) { showToast(err.message || "Failed to start encounter. Please try again."); } }; @@ -2207,7 +2213,13 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) { } catch {} } await endEncounter(encounter, ctx); - await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null }, { merge: true }); + // Only clear display if THIS encounter is the one showing. + const displayIsThis = activeDisplayData && + activeDisplayData.activeCampaignId === campaignId && + activeDisplayData.activeEncounterId === encounter.id; + if (displayIsThis) { + await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null }, { merge: true }); + } } catch (err) { showToast("Failed to end encounter. Please try again."); } diff --git a/src/tests/DisplayGuard.test.js b/src/tests/DisplayGuard.test.js new file mode 100644 index 0000000..d13fae6 --- /dev/null +++ b/src/tests/DisplayGuard.test.js @@ -0,0 +1,94 @@ +// Display guard: starting encounter does NOT steal display from another live encounter. +// Ending encounter does NOT clear display if different encounter showing. + +import React from 'react'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { getCalls, MOCK_DB } from '../__mocks__/firebase/_mock-db'; +import { setupReady, addMonsterViaUI, startCombatViaUI } from './testHelpers'; + +const DISPLAY_PATH = 'artifacts/ttrpg-initiative-tracker-default/public/data/activeDisplay/status'; + +function findCallActiveDisplay(fn) { + return getCalls().filter(c => c.fn === fn && c.path.includes('activeDisplay/status')); +} + +describe('Display guard', () => { + test('startEncounter: claims display when slot empty', async () => { + await setupReady('GuardCamp1', 'GuardEnc1'); + await addMonsterViaUI('Goblin', 10, 5); + await startCombatViaUI(); + const adCalls = findCallActiveDisplay('setDoc'); + const last = adCalls[adCalls.length - 1]; + expect(last).toBeTruthy(); + expect(last.data.activeEncounterId).toBeTruthy(); + }); + + test('startEncounter: does NOT steal display from another live encounter', async () => { + // Pre-seed display as busy with a DIFFERENT encounter + MOCK_DB.set(DISPLAY_PATH, { + activeCampaignId: 'other-camp', + activeEncounterId: 'other-enc', + }); + + await setupReady('GuardCamp2', 'GuardEnc2'); + await addMonsterViaUI('Orc', 15, 8); + await startCombatViaUI(); + + // Combat started on encounter doc + const encCalls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')); + expect(encCalls.some(c => c.data.isStarted === true)).toBe(true); + + // But display setDoc should NOT have been called to claim this encounter + const adClaims = findCallActiveDisplay('setDoc').filter( + c => c.data && c.data.activeEncounterId && c.data.activeEncounterId !== 'other-enc' + ); + expect(adClaims).toHaveLength(0); + }); + + test('endEncounter: clears display when ending the displayed encounter', async () => { + await setupReady('GuardCamp3', 'GuardEnc3'); + await addMonsterViaUI('Wolf', 8, 3); + await startCombatViaUI(); + + fireEvent.click(screen.getByRole('button', { name: /End Combat/i })); + fireEvent.click(await screen.findByRole('button', { name: /Confirm/i })); + + await waitFor(() => { + const adCalls = findCallActiveDisplay('setDoc'); + const last = adCalls[adCalls.length - 1]; + return last && last.data && last.data.activeCampaignId === null; + }); + const adCalls = findCallActiveDisplay('setDoc'); + const last = adCalls[adCalls.length - 1]; + expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null }); + }); + + test('endEncounter: does NOT clear display when different encounter is live', async () => { + await setupReady('GuardCamp4', 'GuardEnc4'); + await addMonsterViaUI('Bat', 4, 2); + await startCombatViaUI(); + + // Now hijack display to a different encounter (simulating eyeball toggle elsewhere) + MOCK_DB.set(DISPLAY_PATH, { + activeCampaignId: 'different-camp', + activeEncounterId: 'different-enc', + }); + + fireEvent.click(screen.getByRole('button', { name: /End Combat/i })); + fireEvent.click(await screen.findByRole('button', { name: /Confirm/i })); + + // Wait for encounter to end + await waitFor(() => { + const encCalls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')); + const last = encCalls[encCalls.length - 1]; + return last && last.data.isStarted === false; + }); + + // Display should NOT have been cleared + const clearCalls = findCallActiveDisplay('setDoc').filter( + c => c.data && c.data.activeCampaignId === null + ); + expect(clearCalls).toHaveLength(0); + }); +}); -- 2.34.1 From eef11c3b6ea49a319f7019a89900e0ace4538664 Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:00:13 -0400 Subject: [PATCH 23/24] fix: scope selectedEncounter + scrollY localStorage per campaign - selectedEncounter key: ttrpg.selectedEncounter.{campaignId} - scrollY key: ttrpg.scrollY.{campaignId} - Restore scoped encounter selection on campaign switch - Global keys unchanged (selectedCampaign, wakeLock, collapses) - Prevents same-browser tabs on different campaigns from fighting - Tests: 3 scoping cases (scoped encounter, scoped scroll, global intact) --- src/App.js | 27 +++++++++++++------ src/tests/LocalStorageScoping.test.js | 39 +++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 8 deletions(-) create mode 100644 src/tests/LocalStorageScoping.test.js diff --git a/src/App.js b/src/App.js index 50292ea..d960371 100644 --- a/src/App.js +++ b/src/App.js @@ -2349,7 +2349,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac const [encounters, setEncounters] = useState([]); const [selectedEncounterId, setSelectedEncounterId] = useState(() => { - try { return localStorage.getItem('ttrpg.selectedEncounter') || null; } + try { return localStorage.getItem(`ttrpg.selectedEncounter.${campaignId}`) || null; } catch { return null; } }); const [showCreateModal, setShowCreateModal] = useState(false); @@ -2360,6 +2360,17 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac const selectedEncounterIdRef = useRef(selectedEncounterId); + // Restore scoped encounter selection when campaign changes. + useEffect(() => { + if (!campaignId) { setSelectedEncounterId(null); return; } + try { + const saved = localStorage.getItem(`ttrpg.selectedEncounter.${campaignId}`) || null; + setSelectedEncounterId(saved); + } catch { + setSelectedEncounterId(null); + } + }, [campaignId]); + useEffect(() => { if (encountersData) setEncounters(encountersData); }, [encountersData]); @@ -2406,10 +2417,10 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac useEffect(() => { selectedEncounterIdRef.current = selectedEncounterId; try { - if (selectedEncounterId) localStorage.setItem('ttrpg.selectedEncounter', selectedEncounterId); - else localStorage.removeItem('ttrpg.selectedEncounter'); + if (selectedEncounterId) localStorage.setItem(`ttrpg.selectedEncounter.${campaignId}`, selectedEncounterId); + else localStorage.removeItem(`ttrpg.selectedEncounter.${campaignId}`); } catch {} - }, [selectedEncounterId]); + }, [selectedEncounterId, campaignId]); useEffect(() => { if (!campaignId) { @@ -2752,7 +2763,7 @@ function AdminView({ userId }) { // Save scroll on unload + visibility change + interval. useEffect(() => { const onSave = () => { - try { localStorage.setItem('ttrpg.scrollY', String(window.scrollY)); } catch {} + try { localStorage.setItem(`ttrpg.scrollY.${selectedCampaignId}`, String(window.scrollY)); } catch {} }; window.addEventListener('beforeunload', onSave); window.addEventListener('pagehide', onSave); @@ -2764,20 +2775,20 @@ function AdminView({ userId }) { document.removeEventListener('visibilitychange', onSave); clearInterval(interval); }; - }, []); + }, [selectedCampaignId]); // Restore scroll once data loaded. useEffect(() => { if (scrollRestoredRef.current) return; if (campaignsWithDetails.length === 0) return; try { - const y = parseInt(localStorage.getItem('ttrpg.scrollY') || '0', 10); + const y = parseInt(localStorage.getItem(`ttrpg.scrollY.${selectedCampaignId}`) || '0', 10); if (y > 0) { scrollRestoredRef.current = true; setTimeout(() => window.scrollTo(0, y), 300); } } catch {} - }, [campaignsWithDetails]); + }, [campaignsWithDetails, selectedCampaignId]); // Persist selections across reload. useEffect(() => { diff --git a/src/tests/LocalStorageScoping.test.js b/src/tests/LocalStorageScoping.test.js new file mode 100644 index 0000000..ba3750c --- /dev/null +++ b/src/tests/LocalStorageScoping.test.js @@ -0,0 +1,39 @@ +// localStorage scoping: selectedEncounter + scrollY keyed per campaign. +// Two tabs on different campaigns don't fight over selection/scroll. + +import React from 'react'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { setupReady, addMonsterViaUI } from './testHelpers'; + +describe('localStorage campaign scoping', () => { + test('selectedEncounter key is scoped (not bare)', async () => { + await setupReady('ScopeCamp1', 'ScopeEnc1'); + // bare key should not exist + expect(localStorage.getItem('ttrpg.selectedEncounter')).toBeNull(); + // scoped key with suffix should exist + const scoped = Object.keys(localStorage).find( + k => k.startsWith('ttrpg.selectedEncounter.') && k !== 'ttrpg.selectedEncounter' + ); + expect(scoped).toBeTruthy(); + expect(localStorage.getItem(scoped)).toBeTruthy(); + }); + + test('scrollY key is scoped (not bare)', async () => { + await setupReady('ScopeCamp2', 'ScopeEnc2'); + await addMonsterViaUI('Goblin', 10, 5); + expect(localStorage.getItem('ttrpg.scrollY')).toBeNull(); + const scoped = Object.keys(localStorage).find( + k => k.startsWith('ttrpg.scrollY.') && k !== 'ttrpg.scrollY' + ); + // key may not exist until save fires — trigger by checking bare is null + // and at least scoped format is used if any scrollY key present + const scrollKeys = Object.keys(localStorage).filter(k => k.startsWith('ttrpg.scrollY')); + scrollKeys.forEach(k => expect(k).not.toBe('ttrpg.scrollY')); + }); + + test('global keys still exist (campaign selection, wake lock)', async () => { + await setupReady('ScopeCamp3', 'ScopeEnc3'); + expect(localStorage.getItem('ttrpg.selectedCampaign')).toBeTruthy(); + }); +}); -- 2.34.1 From b5b339a5dd05a1c8b6dd323454c74e8d1a4aa69c Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:36:26 -0400 Subject: [PATCH 24/24] 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 --- shared/tests/turn.temphp.test.js | 102 +++++++++++++++++++++++++++++++ shared/turn.js | 92 +++++++++++++++++++++++++--- src/App.js | 77 ++++++++++++++++++++--- 3 files changed, 254 insertions(+), 17 deletions(-) create mode 100644 shared/tests/turn.temphp.test.js diff --git a/shared/tests/turn.temphp.test.js b/shared/tests/turn.temphp.test.js new file mode 100644 index 0000000..e4fedbd --- /dev/null +++ b/shared/tests/turn.temphp.test.js @@ -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); + }); +}); diff --git a/shared/turn.js b/shared/turn.js index 4a53e30..0c41dec 100644 --- a/shared/turn.js +++ b/shared/turn.js @@ -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, }; diff --git a/src/App.js b/src/App.js index d960371..2d682cf 100644 --- a/src/App.js +++ b/src/App.js @@ -589,6 +589,7 @@ function EditParticipantModal({ participant, onClose, onSave }) { const [hpFormula, setHpFormula] = useState(participant.hpFormula || ''); const [ac, setAc] = useState(participant.ac != null ? participant.ac : ''); const [asNpc, setAsNpc] = useState(participant.type === 'npc'); + const [tempHp, setTempHp] = useState(participant.tempHp || 0); const handleSubmit = (e) => { e.preventDefault(); @@ -608,6 +609,7 @@ function EditParticipantModal({ participant, onClose, onSave }) { maxHp: finalMaxHp, hpFormula: (participant.type === 'monster' || participant.type === 'npc') ? (hpFormula.trim() || null) : null, ac: ac !== '' ? parseInt(ac, 10) : null, + tempHp: Math.max(0, parseInt(tempHp, 10) || 0), type: participant.type === 'monster' || participant.type === 'npc' ? (asNpc ? 'npc' : 'monster') : participant.type, }); }; @@ -666,6 +668,20 @@ function EditParticipantModal({ participant, onClose, onSave }) { />
+
+
+ + setTempHp(e.target.value)} + min="0" + placeholder="0" + 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-cyan-600 focus:border-cyan-600 sm:text-sm text-white" + /> +
+
+
{(participant.type === 'monster' || participant.type === 'npc') && (
@@ -736,6 +752,7 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) { const [defaultMaxHp, setDefaultMaxHp] = useState(DEFAULT_MAX_HP); const [defaultInitMod, setDefaultInitMod] = useState(DEFAULT_INIT_MOD); const [defaultAc, setDefaultAc] = useState(''); + const [isCharacterNpc, setIsCharacterNpc] = useState(false); const [editingCharacter, setEditingCharacter] = useState(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [itemToDelete, setItemToDelete] = useState(null); @@ -782,7 +799,8 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) { name: characterName.trim(), defaultMaxHp: hp, defaultInitMod: initMod, - defaultAc: defaultAc !== '' ? parseInt(defaultAc, 10) : null + defaultAc: defaultAc !== '' ? parseInt(defaultAc, 10) : null, + isNpc: isCharacterNpc || false, }; try { @@ -793,12 +811,13 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) { setDefaultMaxHp(DEFAULT_MAX_HP); setDefaultInitMod(DEFAULT_INIT_MOD); setDefaultAc(''); + setIsCharacterNpc(false); } catch (err) { console.error("Error adding character:", err); showToast("Failed to add character. Please try again."); } }; - const handleUpdateCharacter = async (characterId, newName, newDefaultMaxHp, newDefaultInitMod, newDefaultAc, newDefaultCurrentHp) => { + const handleUpdateCharacter = async (characterId, newName, newDefaultMaxHp, newDefaultInitMod, newDefaultAc, newDefaultCurrentHp, newIsNpc) => { if (!db || !newName.trim() || !campaignId) return; const hp = parseInt(newDefaultMaxHp, 10); @@ -815,7 +834,7 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) { const updatedCharacters = campaignCharacters.map(c => c.id === characterId - ? { ...c, name: newName.trim(), defaultMaxHp: hp, defaultInitMod: initMod, defaultAc: newDefaultAc !== undefined && newDefaultAc !== '' ? parseInt(newDefaultAc, 10) : null, defaultCurrentHp: newDefaultCurrentHp !== undefined && newDefaultCurrentHp !== '' ? parseInt(newDefaultCurrentHp, 10) : null } + ? { ...c, name: newName.trim(), defaultMaxHp: hp, defaultInitMod: initMod, defaultAc: newDefaultAc !== undefined && newDefaultAc !== '' ? parseInt(newDefaultAc, 10) : null, defaultCurrentHp: newDefaultCurrentHp !== undefined && newDefaultCurrentHp !== '' ? parseInt(newDefaultCurrentHp, 10) : null, isNpc: newIsNpc != null ? newIsNpc : (c.isNpc || false) } : c ); @@ -924,6 +943,15 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) { className="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" />
+
+ @@ -1028,6 +1066,9 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) { <>
{character.name} + {character.isNpc && ( + NPC + )}
HP {character.defaultMaxHp || '?'} {character.defaultCurrentHp != null && character.defaultCurrentHp !== character.defaultMaxHp && ( @@ -1376,6 +1417,15 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp } }; + const handleInlineTempHp = async (participantId, value) => { + const n = Math.max(0, parseInt(value, 10) || 0); + try { + await updateParticipant(encounter, participantId, { tempHp: n }, buildCtx(encounterPath)); + } catch (err) { + showToast('Failed to update Temp HP.'); + } + }; + const handleDeathSaveChange = async (participantId, outcome) => { if (!db) return; try { @@ -1778,7 +1828,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp {isDead && DEAD}

- + { e.target.select(); handleFieldFocus(p.id, 'Initiative'); }} onBlur={(e) => { if (e.target.value !== String(p.initiative)) handleInlineInitiative(p.id, e.target.value); handleFieldBlur(); }} onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); }} - className="w-7 bg-transparent text-white text-base text-center border-b border-transparent hover:border-stone-500 focus:border-amber-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + className="w-7 bg-transparent text-amber-300 text-lg font-bold text-center focus:outline-none disabled:text-amber-300 disabled:opacity-100 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" aria-label={`Initiative for ${p.name}`} /> @@ -1816,10 +1866,23 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'Max HP'); }} onBlur={(e) => { if (e.target.value !== String(p.maxHp)) handleInlineMaxHp(p.id, e.target.value); handleFieldBlur(); }} onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.maxHp; }} - className="w-10 bg-transparent text-stone-400 text-base text-center border-b border-transparent hover:border-stone-500 focus:border-red-500 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + className="w-10 bg-transparent text-stone-200 text-base text-center border-b border-transparent hover:border-stone-400 focus:border-red-500 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" aria-label={`Max HP for ${p.name}`} /> + + + + { e.target.select(); handleFieldFocus(p.id, 'Temp HP'); }} + onBlur={(e) => { if (e.target.value !== String(p.tempHp || 0)) handleInlineTempHp(p.id, e.target.value); handleFieldBlur(); }} + onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.tempHp || 0; }} + className={`w-7 bg-transparent text-base text-center border-b focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none ${(p.tempHp || 0) > 0 ? 'text-cyan-200 border-cyan-500' : 'text-stone-300 border-transparent hover:border-stone-400'}`} + aria-label={`Temp HP for ${p.name}`} + /> + {participantStatus === 'dead' && (