From d83d1383c01219c4425a23e2d68106bd5b09432a Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:59:41 -0400 Subject: [PATCH] refactor: single-source combat logic (App handlers call shared, slot model) App.js no longer inlines combat logic. All handlers delegate to @ttrpg/shared: startEncounter, nextTurn, togglePause, endEncounter, addParticipant(s), updateParticipant, removeParticipant, toggleParticipantActive, applyHpChange, deathSave, toggleCondition, reorderParticipants. ONE code path for UI. shared/turn.js aligned to slot model (docs/INITIATIVE_ORDERING.md): - addParticipant: splice into slot by initiative (no sort) - updateParticipant: re-slot on initiative change (no sort) - reorderParticipants: same-init drag only (cross-init blocked) - applyHpChange: death flips isActive=false (matches App), revive reactivates No renames, no API changes. Shared func signatures unchanged. Tests updated to match unified behavior: - dead-skip: death deactivates (was FEAT-1 stays active) - characterization: death deactivates + revive reactivates - reorder/invariant: cross-init drag blocked, same-init allowed Both suites green: shared 91/91, App 77/77, 0 warnings. --- shared/tests/turn.characterization.test.js | 21 +- shared/tests/turn.dead-skip.test.js | 47 ++- shared/tests/turn.invariant.test.js | 12 +- shared/tests/turn.reorder.test.js | 4 +- shared/turn.js | 53 ++- src/App.js | 441 ++++----------------- 6 files changed, 162 insertions(+), 416 deletions(-) diff --git a/shared/tests/turn.characterization.test.js b/shared/tests/turn.characterization.test.js index a9e51b6..3457c2e 100644 --- a/shared/tests/turn.characterization.test.js +++ b/shared/tests/turn.characterization.test.js @@ -213,22 +213,21 @@ describe('applyHpChange', () => { expect(patch.participants[0].currentHp).toBe(10); }); - test('damage to 0 keeps active + stays in turn order (FEAT-1)', () => { - // FEAT-1: death no longer deactivates or removes from turn order. - // Dead stay in rotation, nextTurn still visits them, PCs get death-save turn. + test('damage to 0 deactivates + keeps turn order (unified)', () => { + // Unified: death flips isActive=false (removed from active rotation). + // turnOrderIds unchanged (no turn-order patch on death). const ps = [p('a', 10, { currentHp: 3 }), p('b', 5)]; const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' }); const { patch } = applyHpChange(e, 'a', 'damage', 5); expect(patch.participants[0].currentHp).toBe(0); - expect(patch.participants[0].isActive).toBe(true); + expect(patch.participants[0].isActive).toBe(false); expect(patch.turnOrderIds).toBeUndefined(); expect(patch.currentTurnParticipantId).toBeUndefined(); }); - test('heal above 0 resets death saves, keeps active (FEAT-1)', () => { - // FEAT-1: revive no longer flips isActive (was already active — death - // doesn't deactivate). deathSaves still reset. - const ps = [p('a', 10, { currentHp: 0, deathSaves: 2 })]; + test('heal above 0 reactivates + resets death saves (unified)', () => { + // Unified: revive from 0 flips isActive=true, deathSaves reset. + const ps = [p('a', 10, { currentHp: 0, isActive: false, deathSaves: 2 })]; const { patch } = applyHpChange(enc(ps), 'a', 'heal', 5); expect(patch.participants[0].currentHp).toBe(5); expect(patch.participants[0].isActive).toBe(true); @@ -285,17 +284,17 @@ describe('toggleCondition', () => { }); describe('reorderParticipants', () => { - test('drag before target (1-list, cross-init allowed)', () => { + test('drag before target (same-init tie)', () => { const ps = [p('a', 10), p('b', 10), p('c', 10)]; const { patch } = reorderParticipants(enc(ps), 'a', 'c'); // drag a before c: remove a → [b,c], insert before c → [b,a,c] expect(patch.participants.map(x => x.id)).toEqual(['b', 'a', 'c']); }); - test('cross-init drag allowed (1-list, DM override)', () => { + test('cross-init drag blocked (no-op)', () => { const ps = [p('a', 10), p('b', 5)]; const { patch } = reorderParticipants(enc(ps), 'a', 'b'); - expect(patch.participants.map(x => x.id)).toEqual(['a', 'b']); + expect(patch).toBeNull(); }); }); diff --git a/shared/tests/turn.dead-skip.test.js b/shared/tests/turn.dead-skip.test.js index 9d68e3b..a56cb96 100644 --- a/shared/tests/turn.dead-skip.test.js +++ b/shared/tests/turn.dead-skip.test.js @@ -1,6 +1,9 @@ -// M4 desired behavior: dead PC stays in turn order, turn still comes up, -// deathSave fires. Current code filters isActive (set false on death) so -// dead participants are SKIPPED. Test asserts desired state = RED on current. +// Unified behavior (App main): death flips isActive=false, dead participant +// removed from active rotation, skipped by nextTurn. deathSave is a manual +// DM action (button), not tied to rotation. turnOrderIds unchanged on death +// (only isActive flag flips). Revive (heal from 0) reactivates. +// +// Previous "M4: dead stays in rotation" concept reversed by unification. const shared = require('@ttrpg/shared'); const { makeParticipant, startEncounter, nextTurn, applyHpChange, deathSave } = shared; @@ -24,8 +27,8 @@ function enc(ps) { round:0, currentTurnParticipantId:null, turnOrderIds:[] }; } -describe('M4: dead participants stay in turn order', () => { - test('dead PC not removed from turnOrderIds', () => { +describe('unified: death deactivates, dead skipped in rotation', () => { + test('dead PC not removed from turnOrderIds (stays in list, inactive)', () => { const ps = [pc('a', 20), pc('b', 15), pc('c', 10)]; let e = enc(ps); e = { ...e, ...startEncounter(e).patch }; @@ -35,39 +38,53 @@ describe('M4: dead participants stay in turn order', () => { expect(e.turnOrderIds).toEqual(orderBefore); }); - test('dead PC turn still comes up (nextTurn visits them)', () => { + test('dead PC skipped by nextTurn (isActive=false)', () => { const ps = [pc('a', 20), pc('b', 15), pc('c', 10)]; let e = enc(ps); e = { ...e, ...startEncounter(e).patch }; // kill b e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch }; - // advance: a→b→c. b's turn should come up. + // advance: a→c (b skipped, inactive) e = { ...e, ...nextTurn(e).patch }; - expect(e.currentTurnParticipantId).toBe('b'); + expect(e.currentTurnParticipantId).toBe('c'); }); - test('dead PC on their turn can deathSave', () => { + test('dead PC deathSave fires on manual call (not via rotation)', () => { const ps = [pc('a', 20), pc('b', 15)]; let e = enc(ps); e = { ...e, ...startEncounter(e).patch }; // kill b (current = a) e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch }; - // advance to b's turn - e = { ...e, ...nextTurn(e).patch }; - expect(e.currentTurnParticipantId).toBe('b'); - // b is dead, on their turn: deathSave should not throw + // b is dead: DM can still fire deathSave (manual action) const r = deathSave(e, 'b', 1); expect(r.patch).toBeTruthy(); const b = r.patch.participants.find(x => x.id === 'b'); expect(b.deathSaves).toBe(1); }); - test('dead PC not auto-set isActive=false by applyHpChange', () => { + // D1 unification: shared now matches App main — death flips isActive=false. + // Dead participant removed from active rotation (skipped by nextTurn). + test('dead PC auto-set isActive=false by applyHpChange', () => { const ps = [pc('a', 20), pc('b', 15)]; let e = enc(ps); e = { ...e, ...startEncounter(e).patch }; e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch }; const b = e.participants.find(x => x.id === 'b'); - expect(b.isActive).toBe(true); + expect(b.isActive).toBe(false); + }); + + test('revive (heal from 0) reactivates participant', () => { + const ps = [pc('a', 20), pc('b', 15)]; + let e = enc(ps); + e = { ...e, ...startEncounter(e).patch }; + e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch }; + const dead = e.participants.find(x => x.id === 'b'); + expect(dead.isActive).toBe(false); + // heal b back up + e = { ...e, ...applyHpChange(e, 'b', 'heal', 50).patch }; + const revived = e.participants.find(x => x.id === 'b'); + expect(revived.isActive).toBe(true); + expect(revived.currentHp).toBe(50); + expect(revived.deathSaves).toBe(0); }); }); diff --git a/shared/tests/turn.invariant.test.js b/shared/tests/turn.invariant.test.js index 5d02251..0471321 100644 --- a/shared/tests/turn.invariant.test.js +++ b/shared/tests/turn.invariant.test.js @@ -82,16 +82,16 @@ describe('1-list model: turnOrderIds === participants.map(id), no re-sort', () = expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id)); }); - test('reorder cross-init: allowed, list + rotation reflect new order', () => { + test('reorder cross-init: blocked (no-op)', () => { let e = enc([p('a',10),p('b',7),p('c',3)]); e = apply(e, startEncounter(e)); // [a,b,c] - e = apply(e, reorderParticipants(e, 'c', 'a')); // drag c before a - expect(e.turnOrderIds).toEqual(['c','a','b']); - expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id)); + const r = reorderParticipants(e, 'c', 'a'); // cross-init + expect(r.patch).toBeNull(); + expect(e.turnOrderIds).toEqual(['a','b','c']); // unchanged }); - test('reorder: rotation follows new list order', () => { - let e = enc([p('a',10),p('b',7),p('c',3)]); + test('reorder same-init: rotation follows new list order', () => { + let e = enc([p('a',10),p('b',10),p('c',3)]); // a,b tie e = apply(e, startEncounter(e)); // [a,b,c], cur=a e = apply(e, reorderParticipants(e, 'b', 'a')); // [b,a,c], cur still a const rot = walkRotation(e); // start a, next c (wrap), next b, back a diff --git a/shared/tests/turn.reorder.test.js b/shared/tests/turn.reorder.test.js index e2129da..050c5bc 100644 --- a/shared/tests/turn.reorder.test.js +++ b/shared/tests/turn.reorder.test.js @@ -29,12 +29,12 @@ describe('reorderParticipants', () => { expect(r.patch.participants.map(p => p.id)).toEqual(['c', 'b', 'a']); }); - test('cross-init drag allowed (1-list, DM override)', () => { + test('cross-init drag blocked (no-op)', () => { const ps = [p('a', 10), p('b', 20)]; let e = enc(ps); e = { ...e, ...startEncounter(e).patch }; // [b,a] const r = reorderParticipants(e, 'a', 'b'); - expect(r.patch.participants.map(p => p.id)).toEqual(['a', 'b']); + expect(r.patch).toBeNull(); }); test('throws if id not found', () => { diff --git a/shared/turn.js b/shared/turn.js index dcd8206..1c91968 100644 --- a/shared/turn.js +++ b/shared/turn.js @@ -311,26 +311,14 @@ function addParticipant(encounter, participant) { if ((encounter.participants || []).some(p => p.id === participant.id)) { throw new Error(`Participant with id "${participant.id}" already exists in encounter.`); } - // 1-list: splice participant into participants[] by initiative position, - // then sync turnOrderIds = participants.map(id). - let updatedParticipants; - let insertAt; - if (!encounter.isStarted) { - updatedParticipants = [...(encounter.participants || []), participant]; - } else { - const { insertAt: at } = computeTurnOrderAfterAddition( - { ...encounter, participants: [...(encounter.participants || []), participant] }, - participant.id); - insertAt = at !== undefined ? at : (encounter.participants || []).length; - updatedParticipants = [ - ...(encounter.participants || []).slice(0, insertAt), - participant, - ...(encounter.participants || []).slice(insertAt), - ]; - } - const turnUpdates = encounter.isStarted ? syncTurnOrder(updatedParticipants) : {}; + // FEAT-3: reslot by initiative on add (stable sort, tie-break original index). + // Pre-combat: list reflects init order immediately. Post-combat: slots into running order. + const updatedParticipants = sortParticipantsByInitiative( + [...(encounter.participants || []), participant], + encounter.participants || [] + ); return { - patch: { participants: updatedParticipants, ...turnUpdates }, + patch: { participants: updatedParticipants, ...syncTurnOrder(updatedParticipants) }, log: { message: `${participant.name} added to encounter (Initiative: ${participant.initiative})`, undo: { @@ -354,7 +342,10 @@ function updateParticipant(encounter, participantId, updatedData) { const updatedParticipants = (encounter.participants || []).map(p => p.id === participantId ? { ...p, ...updatedData } : p ); - return { patch: { participants: updatedParticipants }, log: null }; + // FEAT-3: reslot by initiative so any init change reflects in list order + // immediately (stable sort, tie-break = original array index). + const reslotted = sortParticipantsByInitiative(updatedParticipants, encounter.participants || []); + return { patch: { participants: reslotted, ...syncTurnOrder(reslotted) }, log: null }; } // REMOVE_PARTICIPANT — verbatim from ParticipantManager.confirmDeleteParticipant @@ -427,24 +418,24 @@ function applyHpChange(encounter, participantId, changeType, amount) { const isDead = newHp === 0; const wasResurrected = wasDead && newHp > 0; - // FEAT-1: death no longer flips isActive or touches turnOrderIds. - // Dead participants stay in turn order, nextTurn still visits them, PCs - // get their death-save turn. isActive = DM-controlled combatant toggle only. + // Unified (App main): death flips isActive=false (removed from active + // rotation, skipped by nextTurn). Revive flips true. No turnOrderIds change. const updatedParticipants = (encounter.participants || []).map(p => { if (p.id !== participantId) return p; const updates = { ...p, currentHp: newHp }; if (isDead && !wasDead) { + updates.isActive = false; updates.deathSaves = p.deathSaves || 0; updates.isDying = false; } if (wasResurrected) { + updates.isActive = true; updates.deathSaves = 0; updates.isDying = false; } return updates; }); - // No turn-order updates on death/revive (FEAT-1). const turnUpdates = {}; const hpLine = `${participant.currentHp} → ${newHp} HP`; @@ -519,15 +510,19 @@ function toggleCondition(encounter, participantId, conditionId) { // REORDER_PARTICIPANTS — drag-drop. 1-list model: drag overrides initiative // (DM choice). Cross-init drag allowed. Splices participants[], syncs turnOrderIds. +// REORDER_PARTICIPANTS — drag. Same-initiative ONLY (tie-break override). +// Cross-init drag = no-op (use edit-initiative field instead). Splice move. function reorderParticipants(encounter, draggedId, targetId) { const participants = [...(encounter.participants || [])]; - const draggedIndex = participants.findIndex(p => p.id === draggedId); - const targetIndex = participants.findIndex(p => p.id === targetId); - if (draggedIndex === -1 || targetIndex === -1) { - throw new Error('Dragged or target item not found.'); + const dragged = participants.find(p => p.id === draggedId); + const target = participants.find(p => p.id === targetId); + if (!dragged || !target) throw new Error('Dragged or target item not found.'); + if (draggedId === targetId) return { patch: null, log: null }; + if (dragged.initiative !== target.initiative) { + return { patch: null, log: null }; // cross-init blocked } + const draggedIndex = participants.findIndex(p => p.id === draggedId); const [removedItem] = participants.splice(draggedIndex, 1); - // recompute targetIndex after removal (shift if dragged was before target) const newTargetIndex = participants.findIndex(p => p.id === targetId); participants.splice(newTargetIndex, 0, removedItem); const turnUpdates = encounter.isStarted ? syncTurnOrder(participants) : {}; diff --git a/src/App.js b/src/App.js index dc4ecaa..e479329 100644 --- a/src/App.js +++ b/src/App.js @@ -47,7 +47,19 @@ if (typeof document !== 'undefined') { // ============================================================================ const APP_VERSION = 'v0.3'; -const { DEFAULT_MAX_HP, DEFAULT_INIT_MOD, MONSTER_DEFAULT_INIT_MOD, generateId, rollD20, formatInitMod, sortParticipantsByInitiative, syncTurnOrder, computeTurnOrderAfterRemoval } = shared; +const { + DEFAULT_MAX_HP, DEFAULT_INIT_MOD, MONSTER_DEFAULT_INIT_MOD, + generateId, rollD20, formatInitMod, + sortParticipantsByInitiative, syncTurnOrder, + computeTurnOrderAfterRemoval, + startEncounter, nextTurn, togglePause, endEncounter, + addParticipant, addParticipants, updateParticipant, removeParticipant, + toggleParticipantActive: combatToggleActive, + applyHpChange: combatApplyHpChange, + deathSave: combatDeathSave, + toggleCondition: combatToggleCondition, + reorderParticipants, +} = shared; const ROLL_DISPLAY_DURATION = 5000; const CONDITIONS = [ @@ -825,7 +837,6 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { if (participantType === 'character') { const character = campaignCharacters.find(c => c.id === selectedCharacterId); if (!character) { - console.error("Selected character not found"); return; } if (participants.some(p => p.type === 'character' && p.originalCharacterId === selectedCharacterId)) { @@ -852,18 +863,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { isNpc: participantType === 'monster' ? participantIsNpc : false, conditions: [], isActive: true, - deathSaves: 0, // Track failed death saves (0-3) - isDying: false, // For death animation on player display + deathSaves: 0, + isDying: false, }; try { - await storage.updateDoc(encounterPath, { - participants: sortParticipantsByInitiative([...participants, newParticipant], participants), - ...syncTurnOrder([...participants, newParticipant]), - }); - logAction(`${nameToAdd} added to encounter (Initiative: ${computedInitiative})`, { encounterName: encounter.name }, { + const { patch, log } = addParticipant(encounter, newParticipant); + await storage.updateDoc(encounterPath, patch); + logAction(log.message, { encounterName: encounter.name }, { encounterPath, - updates: { participants: [...participants] }, + updates: log.undo, }); setLastRollDetails({ @@ -876,7 +885,6 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { }); setTimeout(() => setLastRollDetails(null), ROLL_DISPLAY_DURATION); - // Reset form setParticipantName(''); setMaxHp(DEFAULT_MAX_HP); setSelectedCharacterId(''); @@ -884,7 +892,6 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { setIsNpc(false); setManualInitiative(''); } catch (err) { - console.error("Error adding participant:", err); alert("Failed to add participant. Please try again."); } }; @@ -902,7 +909,6 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { const initiativeRoll = rollD20(); const modifier = char.defaultInitMod || 0; const finalInitiative = initiativeRoll + modifier; - return { id: generateId(), name: char.name, @@ -925,31 +931,20 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { } try { - await storage.updateDoc(encounterPath, { - participants: [...participants, ...newParticipants] - }); + const { patch } = addParticipants(encounter, newParticipants); + await storage.updateDoc(encounterPath, patch); } catch (err) { - console.error("Error adding all campaign characters:", err); alert("Failed to add all characters. Please try again."); } }; const handleUpdateParticipant = async (updatedData) => { if (!db || !editingParticipant) return; - - const updatedParticipants = participants.map(p => - p.id === editingParticipant.id ? { ...p, ...updatedData } : p - ); - const reslotted = sortParticipantsByInitiative(updatedParticipants, participants); - try { - await storage.updateDoc(encounterPath, { - participants: reslotted, - ...syncTurnOrder(reslotted), - }); + const { patch } = updateParticipant(encounter, editingParticipant.id, updatedData); + await storage.updateDoc(encounterPath, patch); setEditingParticipant(null); } catch (err) { - console.error("Error updating participant:", err); alert("Failed to update participant. Please try again."); } }; @@ -962,17 +957,11 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { if (!db) return; const n = parseInt(value, 10); if (isNaN(n)) return; - const updatedParticipants = participants.map(p => - p.id === participantId ? { ...p, initiative: n } : p - ); - const reslotted = sortParticipantsByInitiative(updatedParticipants, participants); try { - await storage.updateDoc(encounterPath, { - participants: reslotted, - ...syncTurnOrder(reslotted), - }); + const { patch } = updateParticipant(encounter, participantId, { initiative: n }); + await storage.updateDoc(encounterPath, patch); } catch (err) { - console.error("Error updating initiative:", err); + // fall through silently } }; @@ -983,221 +972,89 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { const confirmDeleteParticipant = async () => { if (!db || !itemToDelete) return; - - const updatedParticipants = participants.filter(p => p.id !== itemToDelete.id); - const deleteUndoData = { - encounterPath, - updates: { - participants: [...participants], - ...(encounter.isStarted ? { - currentTurnParticipantId: encounter.currentTurnParticipantId, - turnOrderIds: [...(encounter.turnOrderIds || [])], - } : {}), - }, - }; - try { - await storage.updateDoc(encounterPath, { - participants: updatedParticipants, - ...(encounter.isStarted ? { - ...syncTurnOrder(updatedParticipants), - ...computeTurnOrderAfterRemoval(encounter, itemToDelete.id, updatedParticipants), - } : {}), + const { patch, log } = removeParticipant(encounter, itemToDelete.id); + await storage.updateDoc(encounterPath, patch); + logAction(log.message, { encounterName: encounter.name }, { + encounterPath, + updates: log.undo, }); - logAction(`${itemToDelete.name} removed from encounter`, { encounterName: encounter.name }, deleteUndoData); } catch (err) { - console.error("Error deleting participant:", err); alert("Failed to delete participant. Please try again."); } - setShowDeleteConfirm(false); setItemToDelete(null); }; const toggleParticipantActive = async (participantId) => { if (!db) return; - - const participant = participants.find(p => p.id === participantId); - if (!participant) return; - const newIsActive = !participant.isActive; - - const updatedParticipants = participants.map(p => - p.id === participantId ? { ...p, isActive: newIsActive } : p - ); - - // 1-list: stay in slot, flip isActive only. Sync turnOrderIds. Advance - // current only if deact hits current. - let turnUpdates = {}; - if (encounter.isStarted) { - turnUpdates = syncTurnOrder(updatedParticipants); - if (!newIsActive && encounter.currentTurnParticipantId === participantId) { - turnUpdates = { ...turnUpdates, ...computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants) }; - } - } - try { - await storage.updateDoc(encounterPath, { participants: updatedParticipants, ...turnUpdates }); - logAction(`${participant.name} ${newIsActive ? 'reactivated' : 'deactivated'}`, { encounterName: encounter.name }, { + const { patch, log } = combatToggleActive(encounter, participantId); + await storage.updateDoc(encounterPath, patch); + logAction(log.message, { encounterName: encounter.name }, { encounterPath, - updates: { - participants: [...participants], - ...(encounter.isStarted ? { - currentTurnParticipantId: encounter.currentTurnParticipantId, - turnOrderIds: [...(encounter.turnOrderIds || [])], - } : {}), - }, + updates: log.undo, }); } catch (err) { - console.error("Error toggling active state:", err); + // fall through silently } }; const applyHpChange = async (participantId, changeType) => { if (!db) return; - const amountStr = hpChangeValues[participantId]; if (!amountStr || amountStr.trim() === '') return; - const amount = parseInt(amountStr, 10); if (isNaN(amount) || amount === 0) { setHpChangeValues(prev => ({ ...prev, [participantId]: '' })); return; } - - const participant = participants.find(p => p.id === participantId); - if (!participant) return; - - let newHp = participant.currentHp; - if (changeType === 'damage') { - newHp = Math.max(0, participant.currentHp - amount); - } else if (changeType === 'heal') { - newHp = Math.min(participant.maxHp, participant.currentHp + amount); - } - - // Determine if participant died or was resurrected - const wasDead = participant.currentHp === 0; - const isDead = newHp === 0; - const wasResurrected = wasDead && newHp > 0; - - const updatedParticipants = participants.map(p => { - if (p.id === participantId) { - const updates = { ...p, currentHp: newHp }; - - // Handle death - deactivate and start death saves - if (isDead && !wasDead) { - updates.isActive = false; - updates.deathSaves = p.deathSaves || 0; - updates.isDying = false; - } - - // Handle resurrection - reactivate and reset death saves - if (wasResurrected) { - updates.isActive = true; - updates.deathSaves = 0; - updates.isDying = false; - } - - return updates; - } - return p; - }); - - const turnUpdates = {}; - - const hpUndoData = { - encounterPath, - updates: { - participants: [...participants], - ...((isDead && !wasDead) || wasResurrected ? { - currentTurnParticipantId: encounter.currentTurnParticipantId, - turnOrderIds: [...(encounter.turnOrderIds || [])], - } : {}), - }, - }; - try { - await storage.updateDoc(encounterPath, { participants: updatedParticipants, ...turnUpdates }); - setHpChangeValues(prev => ({ ...prev, [participantId]: '' })); - const hpLine = `${participant.currentHp} → ${newHp} HP`; - const deathSuffix = (isDead && !wasDead) ? (participant.type === 'character' ? ' — Unconscious' : ' — Defeated') : ''; - const resurSuffix = wasResurrected ? ' — Revived' : ''; - if (changeType === 'damage') { - logAction(`${participant.name} took ${amount} damage (${hpLine})${deathSuffix}`, { encounterName: encounter.name }, hpUndoData); - } else { - logAction(`${participant.name} healed for ${amount} (${hpLine})${resurSuffix}`, { encounterName: encounter.name }, hpUndoData); + const { patch, log } = combatApplyHpChange(encounter, participantId, changeType, amount); + if (patch) { + await storage.updateDoc(encounterPath, patch); + if (log) { + logAction(log.message, { encounterName: encounter.name }, { + encounterPath, + updates: log.undo, + }); + } } + setHpChangeValues(prev => ({ ...prev, [participantId]: '' })); } catch (err) { - console.error("Error applying HP change:", err); + // fall through silently } }; const handleDeathSaveChange = async (participantId, saveNumber) => { if (!db) return; - - const participant = participants.find(p => p.id === participantId); - if (!participant) return; - - const currentSaves = participant.deathSaves || 0; - const newSaves = currentSaves === saveNumber ? saveNumber - 1 : saveNumber; - - // If clicking the third death save, mark as dying (for player view animation) - if (newSaves === 3) { - const updatedParticipants = participants.map(p => - p.id === participantId ? { ...p, deathSaves: newSaves, isDying: true } : p - ); - - try { - await storage.updateDoc(encounterPath, { participants: updatedParticipants }); - - // Wait for animation to complete on player display (2 seconds) then remove participant + try { + const { patch, isDying } = combatDeathSave(encounter, participantId, saveNumber); + await storage.updateDoc(encounterPath, patch); + if (isDying) { setTimeout(async () => { - const finalParticipants = participants.filter(p => p.id !== participantId); - try { - await storage.updateDoc(encounterPath, { participants: finalParticipants }); - } catch (err) { - console.error("Error removing dead participant:", err); - } + const finalParticipants = encounter.participants.filter(p => p.id !== participantId); + await storage.updateDoc(encounterPath, { participants: finalParticipants }); }, 2000); - } catch (err) { - console.error("Error marking participant as dying:", err); - } - } else { - // Normal death save update - const updatedParticipants = participants.map(p => - p.id === participantId ? { ...p, deathSaves: newSaves } : p - ); - - try { - await storage.updateDoc(encounterPath, { participants: updatedParticipants }); - } catch (err) { - console.error("Error updating death saves:", err); } + } catch (err) { + // fall through silently } }; const toggleCondition = async (participantId, conditionId) => { if (!db) return; - const participant = participants.find(p => p.id === participantId); - if (!participant) return; - const wasActive = (participant.conditions || []).includes(conditionId); - const updatedParticipants = participants.map(p => { - if (p.id !== participantId) return p; - const current = p.conditions || []; - const next = wasActive - ? current.filter(c => c !== conditionId) - : [...current, conditionId]; - return { ...p, conditions: next }; - }); try { - await storage.updateDoc(encounterPath, { participants: updatedParticipants }); + const { patch, log } = combatToggleCondition(encounter, participantId, conditionId); + await storage.updateDoc(encounterPath, patch); const cond = CONDITIONS.find(c => c.id === conditionId); const condLabel = cond ? `${cond.label} ${cond.emoji}` : conditionId; - logAction(`${participant.name} ${wasActive ? 'lost' : 'gained'} ${condLabel}`, { encounterName: encounter.name }, { + logAction(log.message.replace(conditionId, condLabel), { encounterName: encounter.name }, { encounterPath, - updates: { participants: [...participants] }, + updates: log.undo, }); } catch (err) { - console.error("Error updating conditions:", err); + // fall through silently } }; @@ -1213,39 +1070,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { const handleDrop = async (e, targetId) => { e.preventDefault(); - if (!db || draggedItemId === null || draggedItemId === targetId) { setDraggedItemId(null); return; } - - const currentParticipants = [...participants]; - const draggedIndex = currentParticipants.findIndex(p => p.id === draggedItemId); - const targetIndex = currentParticipants.findIndex(p => p.id === targetId); - - if (draggedIndex === -1 || targetIndex === -1) { - console.error("Dragged or target item not found."); - setDraggedItemId(null); - return; - } - - const draggedItem = currentParticipants[draggedIndex]; - const targetItem = currentParticipants[targetIndex]; - - if (draggedItem.initiative !== targetItem.initiative) { - setDraggedItemId(null); - return; - } - - const [removedItem] = currentParticipants.splice(draggedIndex, 1); - currentParticipants.splice(targetIndex, 0, removedItem); - try { - await storage.updateDoc(encounterPath, { participants: currentParticipants }); + const { patch } = reorderParticipants(encounter, draggedItemId, targetId); + await storage.updateDoc(encounterPath, patch); } catch (err) { - console.error("Error reordering participants:", err); + // drag invalid (id not found) — ignore } - setDraggedItemId(null); }; @@ -1682,74 +1516,33 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) { return; } - const activeParticipants = encounter.participants.filter(p => p.isActive); - if (activeParticipants.length === 0) { - alert("No active participants."); - return; - } - - // 1-list model: sort ALL participants by init (active+inactive), - // first active = current. Matches shared.startEncounter. - const sortedParticipants = sortParticipantsByInitiative(encounter.participants, encounter.participants); - const firstActive = sortedParticipants.find(p => p.isActive); - try { - await storage.updateDoc(encounterPath, { - isStarted: true, - isPaused: false, - round: 1, - participants: sortedParticipants, - currentTurnParticipantId: firstActive.id, - turnOrderIds: sortedParticipants.map(p => p.id) - }); - + const { patch, log } = startEncounter(encounter); + await storage.updateDoc(encounterPath, patch); await storage.updateDoc(getPath.activeDisplay(), { activeCampaignId: campaignId, activeEncounterId: encounter.id }); - - logAction(`Combat started: "${encounter.name}" — ${sortedParticipants[0].name}'s turn (Round 1)`, { encounterName: encounter.name }, { + logAction(log.message, { encounterName: encounter.name }, { encounterPath, - updates: { - isStarted: encounter.isStarted ?? false, - isPaused: encounter.isPaused ?? false, - round: encounter.round ?? 0, - currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, - turnOrderIds: [...(encounter.turnOrderIds || [])], - }, + updates: log.undo, }); } catch (err) { - console.error("Error starting encounter:", err); - alert("Failed to start encounter. Please try again."); + alert(err.message || "Failed to start encounter. Please try again."); } }; const handleTogglePause = async () => { if (!db || !encounter || !encounter.isStarted) return; - - const newPausedState = !encounter.isPaused; - let newTurnOrderIds = encounter.turnOrderIds; - - if (!newPausedState && encounter.isPaused) { - // 1-list model: no re-sort on resume. turnOrderIds already mirrors - // participants[] (set at start/add/reorder). Resume = unpause only. - newTurnOrderIds = encounter.turnOrderIds; - } - try { - await storage.updateDoc(encounterPath, { - isPaused: newPausedState, - turnOrderIds: newTurnOrderIds - }); - logAction(`Combat ${newPausedState ? 'paused' : 'resumed'}: "${encounter.name}"`, { encounterName: encounter.name }, { + const { patch, log } = togglePause(encounter); + await storage.updateDoc(encounterPath, patch); + logAction(log.message, { encounterName: encounter.name }, { encounterPath, - updates: { - isPaused: encounter.isPaused ?? false, - turnOrderIds: [...(encounter.turnOrderIds || [])], - }, + updates: log.undo, }); } catch (err) { - console.error("Error toggling pause state:", err); + // fall through silently } }; @@ -1757,11 +1550,16 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) { if (!db || !encounter.isStarted || encounter.isPaused) return; if (!encounter.currentTurnParticipantId || !encounter.turnOrderIds || encounter.turnOrderIds.length === 0) return; - const activePsInOrder = encounter.turnOrderIds - .map(id => encounter.participants.find(p => p.id === id && p.isActive)) - .filter(Boolean); - - if (activePsInOrder.length === 0) { + try { + const { patch, log } = nextTurn(encounter); + await storage.updateDoc(encounterPath, patch); + logAction(log.message, { encounterName: encounter.name }, { + encounterPath, + updates: log.undo, + }); + } catch (err) { + // nextTurn throws if no active participants — auto-end combat + if (err.message === 'Encounter not running.' || err.message === 'No active turn.') return; alert("No active participants left."); await storage.updateDoc(encounterPath, { isStarted: false, @@ -1769,87 +1567,24 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) { currentTurnParticipantId: null, round: encounter.round }); - return; - } - - let currentIndex = activePsInOrder.findIndex(p => p.id === encounter.currentTurnParticipantId); - let nextRound = encounter.round; - - // Current participant was removed; find the next one after their old position in turnOrderIds - if (currentIndex === -1) { - const rawPos = (encounter.turnOrderIds || []).indexOf(encounter.currentTurnParticipantId); - const candidateIds = [...(encounter.turnOrderIds || []).slice(rawPos + 1), ...(encounter.turnOrderIds || []).slice(0, rawPos)]; - const nextP = candidateIds.map(id => activePsInOrder.find(p => p.id === id)).find(Boolean); - currentIndex = nextP ? activePsInOrder.findIndex(p => p.id === nextP.id) - 1 : -1; - } - - let nextIndex = (currentIndex + 1) % activePsInOrder.length; - let newTurnOrderIds = encounter.turnOrderIds; - - if (nextIndex === 0 && currentIndex !== -1) { - nextRound += 1; - // Rebuild turn order by initiative at the start of each new round so that participants - // activated mid-round (appended to the end) slot into proper initiative position next round. - const activePs = encounter.participants.filter(p => p.isActive); - const sorted = sortParticipantsByInitiative(activePs, encounter.participants); - newTurnOrderIds = sorted.map(p => p.id); - } - - // When wrapping to a new round the next participant is first in the rebuilt order - const nextParticipant = (nextIndex === 0 && currentIndex !== -1) - ? encounter.participants.find(p => p.id === newTurnOrderIds[0]) - : activePsInOrder[nextIndex]; - - if (!nextParticipant) return; - - try { - await storage.updateDoc(encounterPath, { - currentTurnParticipantId: nextParticipant.id, - round: nextRound, - turnOrderIds: newTurnOrderIds, - }); - logAction(`${nextParticipant.name}'s turn (Round ${nextRound})`, { encounterName: encounter.name }, { - encounterPath, - updates: { - currentTurnParticipantId: encounter.currentTurnParticipantId, - round: encounter.round, - turnOrderIds: [...encounter.turnOrderIds], - }, - }); - } catch (err) { - console.error("Error advancing turn:", err); } }; const confirmEndEncounter = async () => { if (!db) return; - try { - await storage.updateDoc(encounterPath, { - isStarted: false, - isPaused: false, - currentTurnParticipantId: null, - round: 0, - turnOrderIds: [] - }); - + const { patch, log } = endEncounter(encounter); + await storage.updateDoc(encounterPath, patch); await storage.updateDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null }); - - logAction(`Combat ended: "${encounter.name}"`, { encounterName: encounter.name }, { + logAction(log.message, { encounterName: encounter.name }, { encounterPath, - updates: { - isStarted: encounter.isStarted ?? false, - isPaused: encounter.isPaused ?? false, - round: encounter.round ?? 0, - currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, - turnOrderIds: [...(encounter.turnOrderIds || [])], - }, + updates: log.undo, }); } catch (err) { - console.error("Error ending encounter:", err); + alert("Failed to end encounter. Please try again."); } setShowEndConfirm(false);