Temp HP: - setTempHp() in shared/turn.js (replaces, no stacking) - Damage absorbs temp HP first (both 5e + generic rulesets) - Inline temp HP input on participant card (cyan when active) - Temp HP field in edit modal - makeParticipant tempHp field (default 0) - 8 shared tests Character isNpc: - isNpc field on character roster model - buildCharacterParticipant: type 'npc' when isNpc - Add form checkbox + edit form checkbox - NPC badge on character list row - Writeback preserves isNpc Initiative box: - Amber-bordered box around initiative input - Gold text (amber-300), disabled stays gold - Stone-950 bg matches page - Bumped maxHp (stone-200) + tempHp (stone-300/cyan) visibility
1194 lines
54 KiB
JavaScript
1194 lines
54 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;
|
|
|
|
// 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,
|
|
};
|