// @ttrpg/shared — turn.js // Turn-order + combat actions. WRITES OWN LOGS (single source of truth). // Every mutating func is async, takes ctx {storage, encPath, logPath, // displayPath}, persists encounter patch + log entry itself. UI, replay, // anything calling these funcs = identical writes. Impossible to forget log. // // Pure helpers (generateId, build*, sort, slot) stay sync — no I/O. // // return value: NEW encounter state (immutable). Callers use for local state // sync; persistence already done inside. // ---------------------------------------------------------------------------- // Constants // ---------------------------------------------------------------------------- const DEFAULT_MAX_HP = 10; const DEFAULT_INIT_MOD = 0; const MONSTER_DEFAULT_INIT_MOD = 2; // ---------------------------------------------------------------------------- // Pure utilities (no I/O) // ---------------------------------------------------------------------------- const generateId = () => (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; const rollD20 = () => Math.floor(Math.random() * 20) + 1; // Parse HP formula like "2d6+9" or "3d8-2" or "1d20". Roll, return total. // Returns null on invalid input. Whitespace/case tolerant. function rollHpFormula(str) { if (!str || typeof str !== 'string') return null; const m = str.trim().toLowerCase().match(/^(\d+)d(\d+)\s*([+-]\s*\d+)?$/); if (!m) return null; const count = parseInt(m[1], 10); const sides = parseInt(m[2], 10); if (count === 0 || sides === 0) return null; let bonus = 0; if (m[3]) bonus = parseInt(m[3].replace(/\s/g, ''), 10); let total = 0; for (let i = 0; i < count; i++) total += Math.floor(Math.random() * sides) + 1; return total + bonus; } const formatInitMod = (mod) => { if (mod === undefined || mod === null) return 'N/A'; return mod >= 0 ? `+${mod}` : `${mod}`; }; // SLOT, NEVER SORT. 1-list model (docs/INITIATIVE_ORDERING.md). const sortParticipantsByInitiative = (participants, originalOrder) => { return [...participants].sort((a, b) => { if (a.initiative === b.initiative) { const indexA = originalOrder.findIndex(p => p.id === a.id); const indexB = originalOrder.findIndex(p => p.id === b.id); return indexA - indexB; } return b.initiative - a.initiative; }); }; function slotIndexForInit(list, init) { for (let i = 0; i < list.length; i++) { if (init > list[i].initiative) return i; } return list.length; } function withDeathStatusConditions(participant, updates) { const status = updates.status !== undefined ? updates.status : participant.status; const current = participant.conditions || []; let conditions = current; if (status === 'dying' || status === 'stable') { conditions = current.includes('unconscious') ? current : [...current, 'unconscious']; } else if (status === 'conscious' || status === 'dead') { conditions = current.filter(c => c !== 'unconscious'); } return { ...updates, conditions }; } const syncTurnOrder = (participants) => ({ turnOrderIds: participants.map(p => p.id), }); const nextActiveAfter = (order, fromPos, isActive) => { const n = order.length; if (n === 0) return { nextId: null, wrapped: false }; for (let step = 1; step < n; step++) { const idx = (fromPos + step) % n; const id = order[idx]; if (isActive(id)) return { nextId: id, wrapped: idx <= fromPos }; } return { nextId: null, wrapped: false }; }; const computeTurnOrderAfterRemoval = (encounter, removedId, updatedParticipants) => { if (!encounter.isStarted) return {}; const updates = {}; if (encounter.currentTurnParticipantId === removedId) { const removedPos = (encounter.turnOrderIds || []).indexOf(removedId); const isActive = id => updatedParticipants.find(p => p.id === id && p.isActive); const { nextId, wrapped } = nextActiveAfter(encounter.turnOrderIds || [], removedPos, isActive); updates.currentTurnParticipantId = nextId; if (nextId && wrapped) updates.round = (encounter.round || 1) + 1; } return updates; }; // ---------------------------------------------------------------------------- // Snapshot helper (lean — analyzer + viewer use this only) // ---------------------------------------------------------------------------- function snapshotOf(enc) { return { round: enc.round ?? 0, currentTurnParticipantId: enc.currentTurnParticipantId ?? null, turnOrderIds: [...(enc.turnOrderIds || [])], activeIds: (enc.participants || []).filter(p => p.isActive).map(p => p.id), startedAt: enc.startedAt ?? null, endedAt: enc.endedAt ?? null, }; } // Build LEAN log entry. Stores mutation delta only — no roster arrays, // no payload duplication, no redo wrapper. Undo = id-based inverse. // log object now carries: { type, message, participantId, delta, undo }. // delta: type-specific scalars (amount, condition, init, changedFields...) // undo: type-specific inverse (full participant obj for remove; id scalars // for others). Client expandUndo() turns this into a patch at click. function buildEntry(enc, encPath, log) { if (!log) return null; const e = { ts: Date.now(), type: log.type, message: log.message || '', encounterId: enc.id || null, encounterName: enc.name || null, encounterPath: encPath, participantId: log.participantId || null, participantName: log.participantName || null, undone: false, snapshot: snapshotOf(enc), }; if (log.delta) e.delta = log.delta; if (log.undo) e.undo = log.undo; return e; } // Execute: apply patch to encounter doc + write lean log. Returns newEnc. async function commit(enc, patch, log, ctx) { const newEnc = { ...enc, ...(patch || {}) }; const logPromise = (log && ctx.logPath) ? ctx.storage.addDoc(ctx.logPath, buildEntry(newEnc, ctx.encPath, log)) : Promise.resolve(); await Promise.all([ ctx.storage.updateDoc(ctx.encPath, patch), logPromise, ]); return newEnc; } // expandUndo: turn lean `undo` (id-based) into full patch for server merge. // Caller passes CURRENT encounter doc (from subscription) so roster ops can // rebuild arrays. Returns { encounterPath, updates, redo } shaped for legacy // transactionalUndo contract. redo = forward delta applied fresh. function expandUndo(entry, currentEnc) { const u = entry.undo; if (!u || !currentEnc) return null; const encPath = entry.encounterPath; const parts = currentEnc.participants || []; const snap = entry.snapshot || {}; // snapshot captured POST-action state; redo = re-apply forward from pre. // pre snapshot derivable from undo when scalar; roster ops need stored obj. switch (entry.type) { case 'add_participant': case 'add_participants': { const added = Array.isArray(u.participants) ? u.participants : [u.participant]; const ids = new Set(added.filter(Boolean).map(p => p.id)); const turnState = {}; if (u.currentTurnParticipantId !== undefined) turnState.currentTurnParticipantId = u.currentTurnParticipantId; if (u.turnOrderIds) turnState.turnOrderIds = u.turnOrderIds; // redo: re-insert added into current parts, order by stored newTurnOrderIds. const redoOrder = u.newTurnOrderIds || null; let redoParts = null; if (redoOrder) { const pool = new Map([...parts, ...added].map(p => [p.id, p])); redoParts = redoOrder.map(id => pool.get(id)).filter(Boolean); } const redoTurnState = {}; if (u.newCurrentTurnParticipantId !== undefined) redoTurnState.currentTurnParticipantId = u.newCurrentTurnParticipantId; return { encounterPath: encPath, updates: { participants: parts.filter(p => !ids.has(p.id)), ...turnState }, redo: redoParts ? { participants: redoParts, turnOrderIds: redoOrder, ...redoTurnState } : { participants: [...parts, ...added.filter(p => !parts.some(x => x.id === p.id))] }, }; } case 'remove_participant': { const p = u.participant; const turnState = {}; if (u.currentTurnParticipantId !== undefined) turnState.currentTurnParticipantId = u.currentTurnParticipantId; if (u.turnOrderIds) turnState.turnOrderIds = u.turnOrderIds; let restoredParts = parts; if (p) { const idx = typeof u.slotIdx === 'number' ? Math.min(u.slotIdx, parts.length) : parts.length; restoredParts = [...parts.slice(0, idx), p, ...parts.slice(idx)]; } // redo: re-remove + restore stored forward turn state. const redoTurnState = {}; if (u.newCurrentTurnParticipantId !== undefined) redoTurnState.currentTurnParticipantId = u.newCurrentTurnParticipantId; if (u.newTurnOrderIds) redoTurnState.turnOrderIds = u.newTurnOrderIds; return { encounterPath: encPath, updates: { participants: restoredParts, ...turnState }, redo: u.newTurnOrderIds ? { participants: parts.filter(x => x.id !== (p && p.id)), ...redoTurnState } : { participants: parts.filter(x => x.id !== (p && p.id)) }, }; } case 'damage': case 'heal': 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). const pid = entry.participantId; const cur = parts.find(p => p.id === pid); if (!cur) return null; const oldVals = u.oldValues || {}; const newVals = u.newValues || {}; const restoreFields = (vals) => { const out = {}; if (vals.currentHp !== undefined) out.currentHp = vals.currentHp; if (vals.status !== undefined) out.status = vals.status; if (vals.deathSaveSuccesses !== undefined) out.deathSaveSuccesses = vals.deathSaveSuccesses; if (vals.deathSaveFailures !== undefined) out.deathSaveFailures = vals.deathSaveFailures; if (vals.isActive !== undefined) out.isActive = vals.isActive; if (vals.conditions !== undefined) out.conditions = vals.conditions; return out; }; return { encounterPath: encPath, updates: { participants: parts.map(p => p.id === pid ? { ...p, ...restoreFields(oldVals) } : p) }, redo: { participants: parts.map(p => p.id === pid ? { ...p, ...restoreFields(newVals) } : p) }, }; } case 'add_condition': case 'remove_condition': { const cond = u.condition; const pid = entry.participantId; const cur = parts.find(p => p.id === pid); if (!cur) return null; const has = (cur.conditions || []).includes(cond); const next = has ? (cur.conditions || []).filter(c => c !== cond) : [...(cur.conditions || []), cond]; return { encounterPath: encPath, updates: { participants: parts.map(p => p.id === pid ? { ...p, conditions: next } : p) }, redo: { participants: parts.map(p => p.id === pid ? { ...p, conditions: !has ? [...(p.conditions||[]), cond] : (p.conditions||[]).filter(c => c !== cond) } : p) }, }; } case 'reactivate': case 'deactivate': { const pid = entry.participantId; const wasActive = u.wasActive; const turnState = {}; if (u.currentTurnParticipantId !== undefined) turnState.currentTurnParticipantId = u.currentTurnParticipantId; if (u.turnOrderIds) turnState.turnOrderIds = u.turnOrderIds; return { encounterPath: encPath, updates: { participants: parts.map(p => p.id === pid ? { ...p, isActive: wasActive } : p), ...turnState }, redo: { participants: parts.map(p => p.id === pid ? { ...p, isActive: !wasActive } : p) }, }; } case 'reorder': { // undo: restore old order. redo: re-apply stored forward order. const redoParts = u.newParticipants || null; return { encounterPath: encPath, updates: { participants: u.participants || parts, ...(u.turnOrderIds ? { turnOrderIds: u.turnOrderIds } : {}), ...(u.currentTurnParticipantId !== undefined ? { currentTurnParticipantId: u.currentTurnParticipantId } : {}) }, redo: redoParts ? { participants: redoParts, turnOrderIds: u.newTurnOrderIds || redoParts.map(p => p.id) } : { participants: parts, turnOrderIds: snap.turnOrderIds || currentEnc.turnOrderIds || [] }, }; } case 'update_participant': { const pid = entry.participantId; const oldVals = u.oldValues || {}; const newVals = u.newValues || {}; // If initiative changed, restore array order too (undo by oldTurnOrderIds, // redo by newTurnOrderIds). Otherwise just patch fields in place. const applyFields = (vals) => parts.map(p => p.id === pid ? { ...p, ...vals } : p); const hasOrderChange = !!(u.oldTurnOrderIds && u.oldTurnOrderIds.length && u.newTurnOrderIds && u.newTurnOrderIds.length && JSON.stringify(u.oldTurnOrderIds) !== JSON.stringify(u.newTurnOrderIds)); if (hasOrderChange && u.oldTurnOrderIds && u.newTurnOrderIds) { const undoApplied = applyFields(oldVals); const undoPool = new Map(undoApplied.map(p => [p.id, p])); const undoOrdered = u.oldTurnOrderIds.map(id => undoPool.get(id)).filter(Boolean); const redoApplied = applyFields(newVals); const redoPool = new Map(redoApplied.map(p => [p.id, p])); const redoOrdered = u.newTurnOrderIds.map(id => redoPool.get(id)).filter(Boolean); return { encounterPath: encPath, updates: { participants: undoOrdered, turnOrderIds: u.oldTurnOrderIds }, redo: { participants: redoOrdered, turnOrderIds: u.newTurnOrderIds }, }; } return { encounterPath: encPath, updates: { participants: applyFields(oldVals) }, redo: { participants: applyFields(newVals), ...(u.newTurnOrderIds ? { turnOrderIds: u.newTurnOrderIds } : {}) }, }; } case 'next_turn': case 'auto_end': { return { encounterPath: encPath, updates: { currentTurnParticipantId: u.currentTurnParticipantId, round: u.round, ...(u.turnOrderIds ? { turnOrderIds: u.turnOrderIds } : {}) }, redo: { currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [] }, }; } case 'start_encounter': { return { encounterPath: encPath, updates: { ...u, startedAt: u.startedAt ?? null }, redo: { isStarted: true, isPaused: false, currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [], startedAt: snap.startedAt ?? Date.now() }, }; } case 'end_encounter': { return { encounterPath: encPath, updates: { ...u, endedAt: u.endedAt ?? null }, redo: { isStarted: false, isPaused: false, currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [], endedAt: snap.endedAt ?? Date.now() }, }; } default: return null; } } // ---------------------------------------------------------------------------- // Participant factories (pure — no write) // ---------------------------------------------------------------------------- function makeParticipant(opts) { return { id: opts.id || generateId(), name: opts.name, type: opts.type, originalCharacterId: opts.originalCharacterId || null, initiative: opts.initiative, maxHp: opts.maxHp, currentHp: opts.currentHp, conditions: opts.conditions || [], isActive: opts.isActive !== undefined ? opts.isActive : true, status: opts.status || (opts.currentHp === 0 ? 'dying' : 'conscious'), deathSaveSuccesses: opts.deathSaveSuccesses || 0, deathSaveFailures: opts.deathSaveFailures || 0, hpFormula: opts.hpFormula || null, ac: opts.ac !== undefined ? opts.ac : null, tempHp: opts.tempHp !== undefined ? opts.tempHp : 0, }; } function buildCharacterParticipant(character) { const initiativeRoll = rollD20(); const modifier = character.defaultInitMod || 0; const finalInitiative = initiativeRoll + modifier; const maxHp = character.defaultMaxHp || DEFAULT_MAX_HP; const currentHp = character.defaultCurrentHp != null ? character.defaultCurrentHp : maxHp; return { participant: makeParticipant({ name: character.name, type: character.isNpc ? 'npc' : 'character', originalCharacterId: character.id, initiative: finalInitiative, maxHp, currentHp, ac: character.defaultAc !== undefined ? character.defaultAc : null, }), roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative }, }; } function buildMonsterParticipant({ name, maxHp, initMod, asNpc, hpFormula, ac }) { const initiativeRoll = rollD20(); const modifier = initMod !== undefined ? initMod : MONSTER_DEFAULT_INIT_MOD; const finalInitiative = initiativeRoll + modifier; // maxHp wins if both set; else roll formula; else default. let hp; let hpRoll = null; if (maxHp) { hp = maxHp; } else if (hpFormula) { const rolled = rollHpFormula(hpFormula); hp = rolled || DEFAULT_MAX_HP; hpRoll = rolled; } else { hp = DEFAULT_MAX_HP; } return { participant: makeParticipant({ name, type: asNpc ? 'npc' : 'monster', initiative: finalInitiative, maxHp: hp, currentHp: hp, hpFormula: hpFormula || null, ac: ac !== undefined ? ac : null, }), roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative, hpRoll }, }; } // ---------------------------------------------------------------------------- // Combat actions — async. Write encounter + log inside. Return newEnc. // ctx = { storage, encPath, logPath, displayPath } // ---------------------------------------------------------------------------- async function startEncounter(encounter, ctx) { if (!encounter.participants || encounter.participants.length === 0) { throw new Error('Add participants first.'); } const sortedParticipants = sortParticipantsByInitiative(encounter.participants || [], encounter.participants); const firstActive = sortedParticipants.find(p => p.isActive); if (!firstActive) throw new Error('No active participants.'); const patch = { isStarted: true, isPaused: false, round: 1, participants: sortedParticipants, currentTurnParticipantId: firstActive.id, turnOrderIds: sortedParticipants.map(p => p.id), startedAt: Date.now(), endedAt: null, }; const log = { type: 'start_encounter', participantId: firstActive.id, participantName: firstActive.name, message: `Combat started: "${encounter.name}" — ${firstActive.name}'s turn (Round 1)`, delta: { round: 1, firstTurnId: firstActive.id }, undo: { isStarted: encounter.isStarted ?? false, isPaused: encounter.isPaused ?? false, round: encounter.round ?? 0, currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, turnOrderIds: [...(encounter.turnOrderIds || [])], startedAt: encounter.startedAt ?? null, endedAt: encounter.endedAt ?? null, }, }; return commit(encounter, patch, log, ctx); } async function nextTurn(encounter, ctx) { if (!encounter.isStarted || encounter.isPaused) { throw new Error('Encounter not running.'); } if (!encounter.currentTurnParticipantId || !encounter.turnOrderIds || encounter.turnOrderIds.length === 0) { throw new Error('No active turn.'); } const activePsInOrder = encounter.turnOrderIds .map(id => encounter.participants.find(p => p.id === id && p.isActive)) .filter(Boolean); if (activePsInOrder.length === 0) { const patch = { isStarted: false, isPaused: false, currentTurnParticipantId: null, round: encounter.round }; const log = { type: 'auto_end', message: `Combat auto-ended: no active participants`, undo: { currentTurnParticipantId: encounter.currentTurnParticipantId, round: encounter.round, isStarted: true, isPaused: false } }; return commit(encounter, patch, log, ctx); } let nextRound = encounter.round; const order = encounter.turnOrderIds || []; const fromPos = order.indexOf(encounter.currentTurnParticipantId); const isActive = id => { const p = encounter.participants.find(x => x.id === id); return !!p && p.isActive; }; const { nextId, wrapped } = nextActiveAfter(order, fromPos, isActive); if (!nextId) throw new Error('Could not determine next participant.'); if (wrapped) nextRound += 1; const nextParticipant = encounter.participants.find(p => p.id === nextId); const patch = { currentTurnParticipantId: nextParticipant.id, round: nextRound, turnOrderIds: encounter.turnOrderIds }; const log = { type: 'next_turn', participantId: nextParticipant.id, participantName: nextParticipant.name, message: `${nextParticipant.name}'s turn (Round ${nextRound})`, delta: { wrapped, prevRound: encounter.round }, undo: { currentTurnParticipantId: encounter.currentTurnParticipantId, round: encounter.round, turnOrderIds: [...encounter.turnOrderIds] }, }; return commit(encounter, patch, log, ctx); } async function togglePause(encounter, ctx) { if (!encounter || !encounter.isStarted) throw new Error('Encounter not started.'); const newPausedState = !encounter.isPaused; const patch = { isPaused: newPausedState, turnOrderIds: encounter.turnOrderIds }; // Lifecycle op: no log entry. Pause/resume excluded from undo stack so DM // undo targets the last player action (damage/condition/etc), not a flag flip. return commit(encounter, patch, null, ctx); } async function addParticipant(encounter, participant, ctx) { if ((encounter.participants || []).some(p => p.id === participant.id)) { throw new Error(`Participant with id "${participant.id}" already exists in encounter.`); } const base = [...(encounter.participants || [])]; const idx = slotIndexForInit(base, participant.initiative); base.splice(idx, 0, participant); const patch = { participants: base, ...syncTurnOrder(base) }; const log = { type: 'add_participant', participantId: participant.id, participantName: participant.name, message: `${participant.name} added to encounter (Initiative: ${participant.initiative})`, delta: { init: participant.initiative, maxHp: participant.maxHp, ptype: participant.type }, undo: { participant, currentTurnParticipantId: encounter.currentTurnParticipantId, turnOrderIds: [...(encounter.turnOrderIds || [])], newTurnOrderIds: patch.turnOrderIds, newCurrentTurnParticipantId: patch.currentTurnParticipantId, }, }; return commit(encounter, patch, log, ctx); } async function addParticipants(encounter, newParticipants, ctx) { // Slot each new participant into list by initiative (desc), preserve // existing order + drag-established ties. Matches addParticipant semantics. let base = [...(encounter.participants || [])]; for (const np of newParticipants) { const idx = slotIndexForInit(base, np.initiative); base.splice(idx, 0, np); } const names = newParticipants.map(p => p.name).join(', '); const patch = { participants: base, ...syncTurnOrder(base) }; const log = { type: 'add_participants', message: `Added ${newParticipants.length} participant${newParticipants.length === 1 ? '' : 's'}: ${names}`, delta: { count: newParticipants.length }, undo: { participants: newParticipants, newTurnOrderIds: patch.turnOrderIds }, }; return commit(encounter, patch, log, ctx); } async function updateParticipant(encounter, participantId, updatedData, ctx) { const existing = encounter.participants || []; const target = existing.find(p => p.id === participantId); if (!target) throw new Error(`Participant "${participantId}" not found.`); const merged = { ...target, ...updatedData }; let patch; const oldValues = {}; for (const k of Object.keys(updatedData)) oldValues[k] = target[k]; if (!('initiative' in updatedData) || updatedData.initiative === target.initiative) { const sameSlot = existing.map(p => p.id === participantId ? merged : p); const fields = Object.keys(updatedData).join(', '); patch = { participants: sameSlot, ...syncTurnOrder(sameSlot) }; const log = { type: 'update_participant', participantId, participantName: target.name, message: `${target.name} updated (${fields})`, delta: { changedFields: Object.keys(updatedData) }, undo: { oldValues, newValues: { ...updatedData }, oldTurnOrderIds: encounter.turnOrderIds || [], newTurnOrderIds: patch.turnOrderIds }, }; return commit(encounter, patch, log, ctx); } else { const without = existing.filter(p => p.id !== participantId); const idx = slotIndexForInit(without, merged.initiative); without.splice(idx, 0, merged); patch = { participants: without, ...syncTurnOrder(without) }; const log = { type: 'update_participant', participantId, participantName: target.name, message: `${target.name} updated (initiative: ${merged.initiative})`, delta: { changedFields: Object.keys(updatedData), initiative: merged.initiative }, undo: { oldValues, newValues: { ...updatedData }, oldTurnOrderIds: encounter.turnOrderIds || [], newTurnOrderIds: patch.turnOrderIds }, }; return commit(encounter, patch, log, ctx); } } async function removeParticipant(encounter, participantId, ctx) { const updatedParticipants = (encounter.participants || []).filter(p => p.id !== participantId); const advUpdates = computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants); const turnUpdates = encounter.isStarted ? { ...syncTurnOrder(updatedParticipants), ...advUpdates } : {}; const participant = (encounter.participants || []).find(p => p.id === participantId); const slotIdx = (encounter.participants || []).findIndex(p => p.id === participantId); const patch = { participants: updatedParticipants, ...turnUpdates }; const log = { type: 'remove_participant', participantId, participantName: participant ? participant.name : null, message: `${participant ? participant.name : 'Participant'} removed from encounter`, delta: { dead: true }, undo: { participant, slotIdx, currentTurnParticipantId: encounter.currentTurnParticipantId, turnOrderIds: [...(encounter.turnOrderIds || [])], newCurrentTurnParticipantId: patch.currentTurnParticipantId, newTurnOrderIds: patch.turnOrderIds, }, }; return commit(encounter, patch, log, ctx); } async function toggleParticipantActive(encounter, participantId, ctx) { const participant = (encounter.participants || []).find(p => p.id === participantId); if (!participant) throw new Error('Participant not found.'); const newIsActive = !participant.isActive; const updatedParticipants = (encounter.participants || []).map(p => p.id === participantId ? { ...p, isActive: newIsActive } : p ); // Toggle active changes only active state + mirrored order. It must NOT // advance turn or increment round; DM can deactivate current, then click Next. // Design: "Toggle active: No position change. Skip in rotation only." const turnUpdates = encounter.isStarted ? syncTurnOrder(updatedParticipants) : {}; const patch = { participants: updatedParticipants, ...turnUpdates }; const log = { type: newIsActive ? 'reactivate' : 'deactivate', participantId, participantName: participant.name, message: `${participant.name} ${newIsActive ? 'reactivated' : 'deactivated'}`, delta: { revived: newIsActive }, undo: { wasActive: participant.isActive, currentTurnParticipantId: encounter.currentTurnParticipantId, turnOrderIds: [...(encounter.turnOrderIds || [])] }, }; 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 || {}) : {}; 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; if (amount < 0) { amount = Math.abs(amount); changeType = changeType === 'damage' ? 'heal' : 'damage'; } 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, isActive: participant.isActive, conditions: [...(participant.conditions || [])], }; let updates = {}; 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. if (ruleset === 'generic') { return applyHpChangeGeneric(encounter, participant, changeType, amount, oldValues, ctx); } 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) { const updatedParticipants = (encounter.participants || []).map(p => p.id === participantId ? { ...p, isActive: false } : p ); const patch = { participants: updatedParticipants }; 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 log = { type: 'deactivate_dead_monster', participantId, participantName: participant.name, message: `${participant.name} marked inactive (dead monster)`, delta: { status: 'dead', isActive: false }, undo: { oldValues, newValues }, }; return commit(encounter, patch, log, ctx); } return encounter; } 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, tempHp: tempFinal, ...(participant.type === 'monster' ? { isActive: false } : {}) }; } else { 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, 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, tempHp: tempFinal, ...(zeroStatus === 'dead' ? { isActive: false } : {}) }; } else { updates = { currentHp: newHp, status: 'conscious', tempHp: tempFinal }; } message = `${participant.name} took ${amount} damage (${participant.currentHp} → ${newHp} HP)`; logDelta = { ...logDelta, to: newHp, status: updates.status }; } } else if (changeType === 'heal') { if (status === 'dead') return encounter; const newHp = Math.min(participant.maxHp, Math.max(1, participant.currentHp + amount)); updates = { currentHp: newHp, status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0 }; message = `${participant.name} healed for ${amount} (${participant.currentHp} → ${newHp} HP)`; logDelta = { ...logDelta, to: newHp, status: 'conscious', deathSavesCleared: true }; } else { throw new Error(`Unknown HP change type: ${changeType}`); } const updatedParticipants = (encounter.participants || []).map(p => p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p ); const patch = { participants: updatedParticipants }; const undoAmount = changeType === 'damage' ? amount : -amount; 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 log = { type: changeType, participantId, participantName: participant.name, message, delta: logDelta, undo: { amount: undoAmount, oldValues, newValues }, }; 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 }; 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 => 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, tempHp: tempFinal }; } else if (newHp <= 0) { updates = { currentHp: newHp, status: 'down', tempHp: tempFinal }; } else { updates = { currentHp: newHp, status: 'conscious', tempHp: tempFinal }; } 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'); if (statusBefore !== 'dying') throw new Error('Death saves only apply while dying.'); if (!['success', 'fail', 'nat1', 'nat20'].includes(outcome)) { throw new Error(`deathSave outcome must be success, fail, nat1, or nat20; got "${outcome}".`); } const oldValues = { currentHp: participant.currentHp, status: statusBefore, deathSaveSuccesses: participant.deathSaveSuccesses || 0, deathSaveFailures: participant.deathSaveFailures || 0, isActive: participant.isActive, conditions: [...(participant.conditions || [])], }; let updates; if (outcome === 'nat20') { updates = { currentHp: 1, status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0 }; } else { const successes = (participant.deathSaveSuccesses || 0) + (outcome === 'success' ? 1 : 0); const failures = (participant.deathSaveFailures || 0) + (outcome === 'fail' ? 1 : outcome === 'nat1' ? 2 : 0); if (failures >= 3) updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0 }; else if (successes >= 3) updates = { currentHp: 0, status: 'stable', deathSaveSuccesses: 0, deathSaveFailures: 0 }; else updates = { currentHp: 0, status: 'dying', deathSaveSuccesses: successes, deathSaveFailures: failures }; } const name = participant.name; const message = outcome === 'nat20' ? `Nat 20! ${name} restored to 1 HP, conscious` : outcome === 'nat1' ? `Nat 1! ${name} takes 2 death save failures` : updates.status === 'stable' ? `${name} stabilized (3 death saves)` : updates.status === 'dead' ? `${name} failed 3 death saves — dead` : `${name} death save ${outcome}`; const updatedParticipants = (encounter.participants || []).map(p => p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p ); const patch = { participants: updatedParticipants }; 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 log = { type: 'death_save', participantId, participantName: name, message, delta: { outcome, status: updates.status, deathSaveSuccesses: updates.deathSaveSuccesses, deathSaveFailures: updates.deathSaveFailures }, undo: { oldValues, newValues }, }; const enc = await commit(encounter, patch, log, ctx); return { enc, status: updates.status }; } async function toggleCondition(encounter, participantId, conditionId, ctx) { const participant = (encounter.participants || []).find(p => p.id === participantId); if (!participant) throw new Error('Participant not found.'); const wasActive = (participant.conditions || []).includes(conditionId); const updatedParticipants = (encounter.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 }; }); const patch = { participants: updatedParticipants }; const log = { type: wasActive ? 'remove_condition' : 'add_condition', participantId, participantName: participant.name, message: `${participant.name} ${wasActive ? 'lost' : 'gained'} ${conditionId}`, delta: { condition: conditionId, added: !wasActive }, undo: { condition: conditionId }, }; return commit(encounter, patch, log, 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'); if (status === 'conscious' || participant.currentHp > 0) throw new Error('Cannot stabilize conscious participant.'); if (status === 'dead') throw new Error('Cannot stabilize dead participant.'); if (status === 'stable') return encounter; const updates = { currentHp: 0, status: 'stable', deathSaveSuccesses: 0, deathSaveFailures: 0 }; const updatedParticipants = (encounter.participants || []).map(p => p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p ); const patch = { participants: updatedParticipants }; const oldValues = { currentHp: participant.currentHp, status, deathSaveSuccesses: participant.deathSaveSuccesses || 0, deathSaveFailures: participant.deathSaveFailures || 0, isActive: participant.isActive, conditions: [...(participant.conditions || [])], }; 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 log = { type: 'stabilize', participantId, participantName: participant.name, message: `${participant.name} stabilized at 0 HP`, delta: { status: 'stable' }, undo: { oldValues, newValues }, }; return commit(encounter, patch, log, 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; // 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 ); const patch = { participants: updatedParticipants }; const oldValues = { currentHp: participant.currentHp, status, deathSaveSuccesses: participant.deathSaveSuccesses || 0, deathSaveFailures: participant.deathSaveFailures || 0, isActive: participant.isActive, conditions: [...(participant.conditions || [])], }; 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 log = { type: 'revive', participantId, participantName: participant.name, message: `${participant.name} revived to 0 HP (stable, unconscious)`, delta: { status: 'stable', currentHp: 0 }, undo: { oldValues, newValues }, }; 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); const target = participants.find(p => p.id === targetId); if (!dragged || !target) throw new Error('Dragged or target item not found.'); if (draggedId === targetId) return encounter; // no-op, no write if (dragged.initiative !== target.initiative) return encounter; // cross-init blocked const draggedIndex = participants.findIndex(p => p.id === draggedId); if (encounter.isStarted && !encounter.isPaused && encounter.currentTurnParticipantId) { const pointerIdx = participants.findIndex(p => p.id === encounter.currentTurnParticipantId); const targetIdx0 = participants.findIndex(p => p.id === targetId); if (pointerIdx >= 0) { const crosses = (a, b) => (a <= pointerIdx && b > pointerIdx) || (a > pointerIdx && b <= pointerIdx); if (crosses(draggedIndex, targetIdx0)) return encounter; // cross-pointer blocked } } const targetIndex = participants.findIndex(p => p.id === targetId); const [removedItem] = participants.splice(draggedIndex, 1); const newTargetIndex = participants.findIndex(p => p.id === targetId); const insertIndex = draggedIndex < targetIndex ? newTargetIndex + 1 : newTargetIndex; participants.splice(insertIndex, 0, removedItem); const patch = { participants, ...syncTurnOrder(participants) }; const log = { type: 'reorder', participantId: draggedId, participantName: removedItem.name, message: `${removedItem.name} reordered before ${(participants.find(p => p.id === targetId) || {}).name || targetId}`, delta: { draggedId, targetId }, undo: { participants: encounter.participants || [], turnOrderIds: encounter.turnOrderIds || [], currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, newParticipants: participants, newTurnOrderIds: patch.turnOrderIds, }, }; return commit(encounter, patch, log, ctx); } async function endEncounter(encounter, ctx) { const patch = { isStarted: false, isPaused: false, currentTurnParticipantId: null, round: 0, turnOrderIds: [], endedAt: Date.now() }; const log = { type: 'end_encounter', message: `Combat ended: "${encounter.name}"`, delta: {}, undo: { isStarted: encounter.isStarted ?? false, isPaused: encounter.isPaused ?? false, round: encounter.round ?? 0, currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, turnOrderIds: [...(encounter.turnOrderIds || [])], endedAt: encounter.endedAt ?? null }, }; // Character writeback: sync maxHp/ac/currentHp to campaign players if enabled. const wbCampaignId = encounter.campaignId || ctx.campaignId; if ((wbCampaignId || ctx.campaign) && ctx.storage) { let campaign; try { campaign = ctx.campaign || await ctx.storage.getDoc(`campaigns/${wbCampaignId}`); } catch { campaign = null; } if (campaign && campaign.syncCharacters && Array.isArray(campaign.players)) { const charParticipants = (encounter.participants || []).filter(p => p.originalCharacterId); const oldValues = []; let changed = false; const updatedPlayers = campaign.players.map(player => { const cp = charParticipants.find(p => p.originalCharacterId === player.id); if (!cp) return player; const oldMaxHp = player.defaultMaxHp; const oldAc = player.defaultAc; const oldCurrentHp = player.defaultCurrentHp; oldValues.push({ id: player.id, defaultMaxHp: oldMaxHp, defaultAc: oldAc, defaultCurrentHp: oldCurrentHp }); const newPlayer = { ...player }; if (cp.maxHp != null && cp.maxHp !== oldMaxHp) { newPlayer.defaultMaxHp = cp.maxHp; changed = true; } if (cp.ac != null && cp.ac !== oldAc) { newPlayer.defaultAc = cp.ac; changed = true; } if (cp.currentHp != null && cp.currentHp !== oldCurrentHp) { newPlayer.defaultCurrentHp = cp.currentHp; changed = true; } return newPlayer; }); if (changed) { await ctx.storage.updateDoc(`campaigns/${wbCampaignId}`, { players: updatedPlayers }); log.undo.characterWriteback = oldValues; } } } return commit(encounter, patch, log, ctx); } // ---------------------------------------------------------------------------- // Display lifecycle — write activeDisplay doc, no combat log. // ctx.displayPath required. // ---------------------------------------------------------------------------- async function activateDisplay({ campaignId, encounterId }, ctx) { await ctx.storage.updateDoc(ctx.displayPath, { activeCampaignId: campaignId, activeEncounterId: encounterId }); } async function clearDisplay(ctx) { await ctx.storage.updateDoc(ctx.displayPath, { activeCampaignId: null, activeEncounterId: null }); } async function toggleHidePlayerHp(currentValue, ctx) { await ctx.storage.updateDoc(ctx.displayPath, { hidePlayerHp: !currentValue }); } module.exports = { DEFAULT_MAX_HP, DEFAULT_INIT_MOD, MONSTER_DEFAULT_INIT_MOD, generateId, rollD20, rollHpFormula, formatInitMod, sortParticipantsByInitiative, syncTurnOrder, computeTurnOrderAfterRemoval, snapshotOf, buildEntry, expandUndo, makeParticipant, buildCharacterParticipant, buildMonsterParticipant, startEncounter, nextTurn, togglePause, addParticipant, addParticipants, updateParticipant, removeParticipant, toggleParticipantActive, applyHpChange, setTempHp, deathSave, stabilizeParticipant, reviveParticipant, markDead, toggleCondition, reorderParticipants, endEncounter, activateDisplay, clearDisplay, toggleHidePlayerHp, };