Campaign-level toggle (CharacterManager, live flip). When ON, ending an encounter writes each character participant's current HP, Max HP, and AC back to the campaign roster so they carry into the next encounter. - shared: buildCharacterParticipant uses defaultCurrentHp (fallback maxHp) - endEncounter: syncs maxHp/ac/currentHp to campaign players if syncCharacters, snapshots old values into undo payload for restore. Works retroactive (ctx.campaignId fallback for encounters lacking campaignId field). - New encounters store campaignId field. - Undo end-encounter restores character values client-side. - CharacterManager: styled sync toggle (checkbox badge), stat badges in list (HP/Current HP/AC/Init), edit box supports Current HP field. Tests: 5 writeback cases (off/on/skip monsters/undo snapshot/missing char).
1122 lines
50 KiB
JavaScript
1122 lines
50 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,
|
|
};
|
|
}
|
|
|
|
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',
|
|
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 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: [], 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, deathSave, stabilizeParticipant, reviveParticipant, markDead, toggleCondition,
|
|
reorderParticipants, endEncounter,
|
|
activateDisplay, clearDisplay, toggleHidePlayerHp,
|
|
};
|