Files
ttrpg-initiative-tracker/shared/turn.js
T
david raistrick 9870d4df0d Fix addParticipants: slot by initiative instead of append
addParticipants (bulk add all characters) appended to list end, ignoring
initiative. Pre-combat list showed random order until start encounter.
Now slots each participant by initiative desc, preserves existing order +
drag ties. Matches addParticipant single-add semantics.

Tests: 5 bulk-add slot cases (turn.bulkadd.test.js).
2026-07-07 16:07:20 -04:00

1045 lines
47 KiB
JavaScript

// @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;
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),
};
}
// 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':
case 'end_encounter': {
return {
encounterPath: encPath,
updates: u,
redo: { isStarted: entry.type === 'start_encounter', isPaused: false, currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [] },
};
}
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,
};
}
function buildCharacterParticipant(character) {
const initiativeRoll = rollD20();
const modifier = character.defaultInitMod || 0;
const finalInitiative = initiativeRoll + modifier;
const maxHp = character.defaultMaxHp || DEFAULT_MAX_HP;
return {
participant: makeParticipant({
name: character.name,
type: 'character',
originalCharacterId: character.id,
initiative: finalInitiative,
maxHp,
currentHp: maxHp,
}),
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
};
}
function buildMonsterParticipant({ name, maxHp, initMod, asNpc }) {
const initiativeRoll = rollD20();
const modifier = initMod !== undefined ? initMod : MONSTER_DEFAULT_INIT_MOD;
const finalInitiative = initiativeRoll + modifier;
const hp = maxHp || DEFAULT_MAX_HP;
return {
participant: makeParticipant({
name,
type: asNpc ? 'npc' : 'monster',
initiative: finalInitiative,
maxHp: hp,
currentHp: hp,
}),
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
};
}
// ----------------------------------------------------------------------------
// 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),
};
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 || [])],
},
};
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 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,
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;
// GENERIC ruleset: no death saves, negative HP allowed, down status at <=0.
// Monster death = dead + inactive. No unconscious auto-condition.
if (ruleset === 'generic') {
return applyHpChangeGeneric(encounter, participant, changeType, amount, oldValues, ctx);
}
if (changeType === 'damage') {
if (participant.currentHp === 0) {
if (status === 'dead') {
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, ...(participant.type === 'monster' ? { isActive: false } : {}) };
} else {
updates = { currentHp: 0, status: 'dying', deathSaveSuccesses: 0, deathSaveFailures: failures };
}
message = `${participant.name} took ${amount} damage while ${status === 'stable' ? 'stable' : 'dying'}`;
logDelta = { ...logDelta, to: 0, status: updates.status, deathSaveFailures: updates.deathSaveFailures };
} else if (amount >= participant.currentHp + participant.maxHp) {
updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, ...(participant.type === 'monster' ? { isActive: false } : {}) };
message = `Massive damage! ${participant.name} instantly killed`;
logDelta = { ...logDelta, to: 0, status: 'dead', massive: true };
} else {
const newHp = Math.max(0, participant.currentHp - amount);
if (newHp === 0) {
const zeroStatus = participant.type === 'monster' ? 'dead' : 'dying';
updates = { currentHp: 0, status: zeroStatus, deathSaveSuccesses: 0, deathSaveFailures: 0, ...(zeroStatus === 'dead' ? { isActive: false } : {}) };
} else {
updates = { currentHp: newHp, status: 'conscious' };
}
message = `${participant.name} took ${amount} damage (${participant.currentHp}${newHp} HP)`;
logDelta = { ...logDelta, to: newHp, status: updates.status };
}
} else if (changeType === 'heal') {
if (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 };
if (changeType === 'damage') {
if (oldValues.status === 'dead') {
if (participant.type === 'monster' && participant.isActive !== false) {
const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, isActive: false } : p
);
const newP = updatedParticipants.find(p => p.id === participantId);
const newValues = {
currentHp: newP.currentHp, status: newP.status,
deathSaveSuccesses: 0, deathSaveFailures: 0,
isActive: newP.isActive, conditions: [...(newP.conditions || [])],
};
return commit(encounter, { participants: updatedParticipants }, {
type: 'deactivate_dead_monster', participantId, participantName: participant.name,
message: `${participant.name} marked inactive (dead monster)`,
delta: { status: 'dead', isActive: false },
undo: { oldValues, newValues },
}, ctx);
}
return encounter;
}
const newHp = participant.currentHp - amount;
const isMonster = participant.type === 'monster';
if (newHp <= 0 && isMonster) {
updates = { currentHp: newHp, status: 'dead', isActive: false };
} else if (newHp <= 0) {
updates = { currentHp: newHp, status: 'down' };
} else {
updates = { currentHp: newHp, status: 'conscious' };
}
message = `${participant.name} took ${amount} damage (${participant.currentHp}${newHp} HP)`;
logDelta = { ...logDelta, to: newHp, status: updates.status };
} else if (changeType === 'heal') {
if (oldValues.status === 'dead') return encounter;
const newHp = Math.min(participant.maxHp, participant.currentHp + amount);
updates = { currentHp: newHp, status: 'conscious' };
message = `${participant.name} healed for ${amount} (${participant.currentHp}${newHp} HP)`;
logDelta = { ...logDelta, to: newHp, status: 'conscious' };
} else {
throw new Error(`Unknown HP change type: ${changeType}`);
}
const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, ...updates } : p
);
const newP = updatedParticipants.find(p => p.id === participantId);
const newValues = {
currentHp: newP.currentHp, status: newP.status,
deathSaveSuccesses: newP.deathSaveSuccesses || 0,
deathSaveFailures: newP.deathSaveFailures || 0,
isActive: newP.isActive, conditions: [...(newP.conditions || [])],
};
const undoAmount = changeType === 'damage' ? amount : -amount;
return commit(encounter, { participants: updatedParticipants }, {
type: changeType, participantId, participantName: participant.name,
message, delta: logDelta,
undo: { amount: undoAmount, oldValues, newValues },
}, ctx);
}
async function deathSave(encounter, participantId, outcome, ctx) {
if ((encounter.ruleset || '5e') === 'generic') {
throw new Error('Death saves not available in generic ruleset.');
}
const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.');
const statusBefore = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
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: [] };
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 || [])] },
};
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, formatInitMod,
sortParticipantsByInitiative, syncTurnOrder, computeTurnOrderAfterRemoval,
snapshotOf, buildEntry, expandUndo,
makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
startEncounter, nextTurn, togglePause,
addParticipant, addParticipants, updateParticipant, removeParticipant,
toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, markDead, toggleCondition,
reorderParticipants, endEncounter,
activateDisplay, clearDisplay, toggleHidePlayerHp,
};