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.
This commit is contained in:
david raistrick
2026-07-03 17:59:41 -04:00
parent c3d89554e8
commit d83d1383c0
6 changed files with 162 additions and 416 deletions
+88 -353
View File
@@ -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);