feat(combat): add first-class undo and redo controls Add undo and redo controls to the combat UI so the DM can recover from recent actions without leaving the encounter view. Undo and redo operate on the current encounter's combat history. Empty stacks produce clear feedback instead of failing silently. Redo order follows normal stack behavior after multiple undos. This makes combat history actionable during play, not just visible in the log. feat(logs): make combat logs replayable Replace plain combat log messages with structured combat events that can be used by the UI, exported as JSON, replayed, and verified. Each new log entry records the action type, encounter identity, participant identity, a small action delta, undo intent, and a turn snapshot. Download and copy now export the event stream as JSON so a saved combat log is useful for offline analysis and debugging. Legacy logs remain viewable, but new logs use the structured event format. feat(logs): make undo and redo transactional Apply undo and redo as single storage operations so the encounter state and log state cannot drift apart. Server storage applies the encounter update and the log undone flag inside one SQLite transaction. Firebase storage uses a batch write for the same behavior. The storage contract now includes undo/redo semantics. This replaces fragile multi-write undo behavior where a failure could update the encounter without marking the log, or mark the log without updating the encounter. feat(combat): add unified replay and verification tool Add one combat CLI for replaying live combat and verifying combat logs. Replay drives the live backend through the same shared combat logic used by the app, writes a JSON event log to an explicit output path, and automatically verifies the result. Verification checks for DM-visible combat problems such as skipped turns, double actions, bad round changes, and unexpected turn order changes. The tool uses the same JSON event stream produced by log downloads, supports verbose turn output, and handles Ctrl-C by ending the encounter, writing the partial log, and verifying what was captured. fix(perf): keep long combat logging fast Remove the combat-time log query bottleneck that made long replays slow as log volume grew. Combat controls no longer subscribe to the log collection just to keep undo and redo state warm. Undo and redo now query the latest matching encounter log only when clicked. Server collection queries support exact filters, ordering, limits, and offsets, and SQLite indexes keep latest-log and per-encounter log lookups fast. Also fix duplicate WebSocket handler registration so realtime updates do not double-fire under write load. fix(turns): make toggle active a status change Make toggle active a roster/status edit instead of a turn advance. Deactivating the current participant no longer passes the turn or increments the round. The current turn stays where it is until the DM explicitly clicks Next Turn, and Next Turn skips inactive participants during normal rotation. This matches the initiative design: slot order is stable, toggle active does not move participants, and round changes only come from explicit turn advance. chore(ci): make warnings and hangs fail fast Tighten test and build checks so failures are visible instead of noisy or silent. Builds run with CI enabled so warnings fail production builds. The full test command runs app, shared, and server suites with hard timeouts so hangs fail quickly. Static eslint coverage fails on warnings as well as errors. Tests were updated around the new async combat logging flow, structured log events, transactional undo, replay verification, and toggle-active semantics.
694 lines
30 KiB
JavaScript
694 lines
30 KiB
JavaScript
// @ttrpg/shared — turn.js
|
|
// Turn-order + combat actions. WRITES OWN LOGS (single source of truth).
|
|
// Every mutating func is async, takes ctx {storage, encPath, logPath,
|
|
// displayPath}, persists encounter patch + log entry itself. UI, replay,
|
|
// anything calling these funcs = identical writes. Impossible to forget log.
|
|
//
|
|
// Pure helpers (generateId, build*, sort, slot) stay sync — no I/O.
|
|
//
|
|
// return value: NEW encounter state (immutable). Callers use for local state
|
|
// sync; persistence already done inside.
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Constants
|
|
// ----------------------------------------------------------------------------
|
|
|
|
const DEFAULT_MAX_HP = 10;
|
|
const DEFAULT_INIT_MOD = 0;
|
|
const MONSTER_DEFAULT_INIT_MOD = 2;
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Pure utilities (no I/O)
|
|
// ----------------------------------------------------------------------------
|
|
|
|
const generateId = () =>
|
|
(typeof crypto !== 'undefined' && crypto.randomUUID)
|
|
? crypto.randomUUID()
|
|
: `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
|
|
const rollD20 = () => Math.floor(Math.random() * 20) + 1;
|
|
|
|
const formatInitMod = (mod) => {
|
|
if (mod === undefined || mod === null) return 'N/A';
|
|
return mod >= 0 ? `+${mod}` : `${mod}`;
|
|
};
|
|
|
|
// SLOT, NEVER SORT. 1-list model (docs/INITIATIVE_ORDERING.md).
|
|
const sortParticipantsByInitiative = (participants, originalOrder) => {
|
|
return [...participants].sort((a, b) => {
|
|
if (a.initiative === b.initiative) {
|
|
const indexA = originalOrder.findIndex(p => p.id === a.id);
|
|
const indexB = originalOrder.findIndex(p => p.id === b.id);
|
|
return indexA - indexB;
|
|
}
|
|
return b.initiative - a.initiative;
|
|
});
|
|
};
|
|
|
|
function slotIndexForInit(list, init) {
|
|
for (let i = 0; i < list.length; i++) {
|
|
if (init > list[i].initiative) return i;
|
|
}
|
|
return list.length;
|
|
}
|
|
|
|
const syncTurnOrder = (participants) => ({
|
|
turnOrderIds: participants.map(p => p.id),
|
|
});
|
|
|
|
const nextActiveAfter = (order, fromPos, isActive) => {
|
|
const n = order.length;
|
|
if (n === 0) return { nextId: null, wrapped: false };
|
|
for (let step = 1; step < n; step++) {
|
|
const idx = (fromPos + step) % n;
|
|
const id = order[idx];
|
|
if (isActive(id)) return { nextId: id, wrapped: idx <= fromPos };
|
|
}
|
|
return { nextId: null, wrapped: false };
|
|
};
|
|
|
|
const computeTurnOrderAfterRemoval = (encounter, removedId, updatedParticipants) => {
|
|
if (!encounter.isStarted) return {};
|
|
const updates = {};
|
|
if (encounter.currentTurnParticipantId === removedId) {
|
|
const removedPos = (encounter.turnOrderIds || []).indexOf(removedId);
|
|
const isActive = id => updatedParticipants.find(p => p.id === id && p.isActive);
|
|
const { nextId, wrapped } = nextActiveAfter(encounter.turnOrderIds || [], removedPos, isActive);
|
|
updates.currentTurnParticipantId = nextId;
|
|
if (nextId && wrapped) updates.round = (encounter.round || 1) + 1;
|
|
}
|
|
return updates;
|
|
};
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Snapshot helper (lean — analyzer + viewer use this only)
|
|
// ----------------------------------------------------------------------------
|
|
|
|
function snapshotOf(enc) {
|
|
return {
|
|
round: enc.round ?? 0,
|
|
currentTurnParticipantId: enc.currentTurnParticipantId ?? null,
|
|
turnOrderIds: [...(enc.turnOrderIds || [])],
|
|
activeIds: (enc.participants || []).filter(p => p.isActive).map(p => p.id),
|
|
};
|
|
}
|
|
|
|
// Build LEAN log entry. Stores mutation delta only — no roster arrays,
|
|
// no payload duplication, no redo wrapper. Undo = id-based inverse.
|
|
// log object now carries: { type, message, participantId, delta, undo }.
|
|
// delta: type-specific scalars (amount, condition, init, changedFields...)
|
|
// undo: type-specific inverse (full participant obj for remove; id scalars
|
|
// for others). Client expandUndo() turns this into a patch at click.
|
|
function buildEntry(enc, encPath, log) {
|
|
if (!log) return null;
|
|
const e = {
|
|
ts: Date.now(),
|
|
type: log.type,
|
|
message: log.message || '',
|
|
encounterId: enc.id || null,
|
|
encounterName: enc.name || null,
|
|
encounterPath: encPath,
|
|
participantId: log.participantId || null,
|
|
participantName: log.participantName || null,
|
|
undone: false,
|
|
snapshot: snapshotOf(enc),
|
|
};
|
|
if (log.delta) e.delta = log.delta;
|
|
if (log.undo) e.undo = log.undo;
|
|
return e;
|
|
}
|
|
|
|
// Execute: apply patch to encounter doc + write lean log. Returns newEnc.
|
|
async function commit(enc, patch, log, ctx) {
|
|
const newEnc = { ...enc, ...(patch || {}) };
|
|
const logPromise = (log && ctx.logPath)
|
|
? ctx.storage.addDoc(ctx.logPath, buildEntry(newEnc, ctx.encPath, log))
|
|
: Promise.resolve();
|
|
await Promise.all([
|
|
ctx.storage.updateDoc(ctx.encPath, patch),
|
|
logPromise,
|
|
]);
|
|
return newEnc;
|
|
}
|
|
|
|
// expandUndo: turn lean `undo` (id-based) into full patch for server merge.
|
|
// Caller passes CURRENT encounter doc (from subscription) so roster ops can
|
|
// rebuild arrays. Returns { encounterPath, updates, redo } shaped for legacy
|
|
// transactionalUndo contract. redo = forward delta applied fresh.
|
|
function expandUndo(entry, currentEnc) {
|
|
const u = entry.undo;
|
|
if (!u || !currentEnc) return null;
|
|
const encPath = entry.encounterPath;
|
|
const parts = currentEnc.participants || [];
|
|
const snap = entry.snapshot || {};
|
|
// snapshot captured POST-action state; redo = re-apply forward from pre.
|
|
// pre snapshot derivable from undo when scalar; roster ops need stored obj.
|
|
switch (entry.type) {
|
|
case 'add_participant':
|
|
case 'add_participants': {
|
|
const added = Array.isArray(u.participants) ? u.participants : [u.participant];
|
|
const ids = new Set(added.filter(Boolean).map(p => p.id));
|
|
const turnState = {};
|
|
if (u.currentTurnParticipantId !== undefined) turnState.currentTurnParticipantId = u.currentTurnParticipantId;
|
|
if (u.turnOrderIds) turnState.turnOrderIds = u.turnOrderIds;
|
|
return {
|
|
encounterPath: encPath,
|
|
updates: { participants: parts.filter(p => !ids.has(p.id)), ...turnState },
|
|
redo: { 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)];
|
|
}
|
|
return {
|
|
encounterPath: encPath,
|
|
updates: { participants: restoredParts, ...turnState },
|
|
redo: { participants: parts.filter(x => x.id !== (p && p.id)) },
|
|
};
|
|
}
|
|
case 'damage':
|
|
case 'heal': {
|
|
const amt = u.amount || 0;
|
|
const pid = entry.participantId;
|
|
const cur = parts.find(p => p.id === pid);
|
|
if (!cur) return null;
|
|
const newHp = Math.max(0, Math.min(cur.maxHp, cur.currentHp + amt));
|
|
return {
|
|
encounterPath: encPath,
|
|
updates: { participants: parts.map(p => p.id === pid ? { ...p, currentHp: newHp } : p) },
|
|
redo: { participants: parts.map(p => p.id === pid ? { ...p, currentHp: cur.currentHp - amt } : 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: swap dragged/target back. Stored old order.
|
|
return {
|
|
encounterPath: encPath,
|
|
updates: { participants: u.participants || parts, ...(u.turnOrderIds ? { turnOrderIds: u.turnOrderIds } : {}) },
|
|
redo: { participants: parts, turnOrderIds: snap.turnOrderIds || currentEnc.turnOrderIds || [] },
|
|
};
|
|
}
|
|
case 'update_participant': {
|
|
const pid = entry.participantId;
|
|
const oldVals = u.oldValues || {};
|
|
const newVals = u.newValues || {};
|
|
return {
|
|
encounterPath: encPath,
|
|
updates: { participants: parts.map(p => p.id === pid ? { ...p, ...oldVals } : p) },
|
|
redo: { participants: parts.map(p => p.id === pid ? { ...p, ...newVals } : p) },
|
|
};
|
|
}
|
|
case 'death_save': {
|
|
const pid = entry.participantId;
|
|
const oldVals = u.oldValues || {};
|
|
return {
|
|
encounterPath: encPath,
|
|
updates: { participants: parts.map(p => p.id === pid ? { ...p, ...oldVals } : p) },
|
|
redo: { participants: parts },
|
|
};
|
|
}
|
|
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 'pause':
|
|
case 'resume': {
|
|
return {
|
|
encounterPath: encPath,
|
|
updates: { isPaused: u.isPaused },
|
|
redo: { isPaused: !u.isPaused },
|
|
};
|
|
}
|
|
case 'start_encounter':
|
|
case 'end_encounter': {
|
|
return {
|
|
encounterPath: encPath,
|
|
updates: u,
|
|
redo: { isStarted: entry.type === 'start_encounter', isPaused: false, currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [] },
|
|
};
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Participant factories (pure — no write)
|
|
// ----------------------------------------------------------------------------
|
|
|
|
function makeParticipant(opts) {
|
|
return {
|
|
id: opts.id || generateId(),
|
|
name: opts.name,
|
|
type: opts.type,
|
|
originalCharacterId: opts.originalCharacterId || null,
|
|
initiative: opts.initiative,
|
|
maxHp: opts.maxHp,
|
|
currentHp: opts.currentHp,
|
|
isNpc: opts.isNpc || false,
|
|
conditions: opts.conditions || [],
|
|
isActive: opts.isActive !== undefined ? opts.isActive : true,
|
|
deathSaves: opts.deathSaves || 0,
|
|
deathFails: opts.deathFails || 0,
|
|
isStable: opts.isStable || false,
|
|
isDying: opts.isDying || false,
|
|
};
|
|
}
|
|
|
|
function buildCharacterParticipant(character) {
|
|
const initiativeRoll = rollD20();
|
|
const modifier = character.defaultInitMod || 0;
|
|
const finalInitiative = initiativeRoll + modifier;
|
|
const maxHp = character.defaultMaxHp || DEFAULT_MAX_HP;
|
|
return {
|
|
participant: makeParticipant({
|
|
name: character.name,
|
|
type: 'character',
|
|
originalCharacterId: character.id,
|
|
initiative: finalInitiative,
|
|
maxHp,
|
|
currentHp: maxHp,
|
|
isNpc: false,
|
|
}),
|
|
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
|
|
};
|
|
}
|
|
|
|
function buildMonsterParticipant({ name, maxHp, initMod, isNpc }) {
|
|
const initiativeRoll = rollD20();
|
|
const modifier = initMod !== undefined ? initMod : MONSTER_DEFAULT_INIT_MOD;
|
|
const finalInitiative = initiativeRoll + modifier;
|
|
const hp = maxHp || DEFAULT_MAX_HP;
|
|
return {
|
|
participant: makeParticipant({
|
|
name,
|
|
type: 'monster',
|
|
initiative: finalInitiative,
|
|
maxHp: hp,
|
|
currentHp: hp,
|
|
isNpc: isNpc || false,
|
|
}),
|
|
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
|
|
};
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Combat actions — async. Write encounter + log inside. Return newEnc.
|
|
// ctx = { storage, encPath, logPath, displayPath }
|
|
// ----------------------------------------------------------------------------
|
|
|
|
async function startEncounter(encounter, ctx) {
|
|
if (!encounter.participants || encounter.participants.length === 0) {
|
|
throw new Error('Add participants first.');
|
|
}
|
|
const sortedParticipants = sortParticipantsByInitiative(encounter.participants || [], encounter.participants);
|
|
const firstActive = sortedParticipants.find(p => p.isActive);
|
|
if (!firstActive) throw new Error('No active participants.');
|
|
const patch = {
|
|
isStarted: true, isPaused: false, round: 1,
|
|
participants: sortedParticipants,
|
|
currentTurnParticipantId: firstActive.id,
|
|
turnOrderIds: sortedParticipants.map(p => p.id),
|
|
};
|
|
const log = {
|
|
type: 'start_encounter',
|
|
participantId: firstActive.id, participantName: firstActive.name,
|
|
message: `Combat started: "${encounter.name}" — ${firstActive.name}'s turn (Round 1)`,
|
|
delta: { round: 1, firstTurnId: firstActive.id },
|
|
undo: {
|
|
isStarted: encounter.isStarted ?? false,
|
|
isPaused: encounter.isPaused ?? false,
|
|
round: encounter.round ?? 0,
|
|
currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
|
|
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
|
},
|
|
};
|
|
return commit(encounter, patch, log, ctx);
|
|
}
|
|
|
|
async function nextTurn(encounter, ctx) {
|
|
if (!encounter.isStarted || encounter.isPaused) {
|
|
throw new Error('Encounter not running.');
|
|
}
|
|
if (!encounter.currentTurnParticipantId || !encounter.turnOrderIds || encounter.turnOrderIds.length === 0) {
|
|
throw new Error('No active turn.');
|
|
}
|
|
const activePsInOrder = encounter.turnOrderIds
|
|
.map(id => encounter.participants.find(p => p.id === id && p.isActive))
|
|
.filter(Boolean);
|
|
if (activePsInOrder.length === 0) {
|
|
const patch = { isStarted: false, isPaused: false, currentTurnParticipantId: null, round: encounter.round };
|
|
const log = { type: 'auto_end', message: `Combat auto-ended: no active participants`, undo: { currentTurnParticipantId: encounter.currentTurnParticipantId, round: encounter.round, isStarted: true, isPaused: false } };
|
|
return commit(encounter, patch, log, ctx);
|
|
}
|
|
let nextRound = encounter.round;
|
|
const order = encounter.turnOrderIds || [];
|
|
const fromPos = order.indexOf(encounter.currentTurnParticipantId);
|
|
const isActive = id => {
|
|
const p = encounter.participants.find(x => x.id === id);
|
|
return !!p && p.isActive;
|
|
};
|
|
const { nextId, wrapped } = nextActiveAfter(order, fromPos, isActive);
|
|
if (!nextId) throw new Error('Could not determine next participant.');
|
|
if (wrapped) nextRound += 1;
|
|
const nextParticipant = encounter.participants.find(p => p.id === nextId);
|
|
const patch = { currentTurnParticipantId: nextParticipant.id, round: nextRound, turnOrderIds: encounter.turnOrderIds };
|
|
const log = {
|
|
type: 'next_turn',
|
|
participantId: nextParticipant.id, participantName: nextParticipant.name,
|
|
message: `${nextParticipant.name}'s turn (Round ${nextRound})`,
|
|
delta: { wrapped, prevRound: encounter.round },
|
|
undo: { currentTurnParticipantId: encounter.currentTurnParticipantId, round: encounter.round, turnOrderIds: [...encounter.turnOrderIds] },
|
|
};
|
|
return commit(encounter, patch, log, ctx);
|
|
}
|
|
|
|
async function togglePause(encounter, ctx) {
|
|
if (!encounter || !encounter.isStarted) throw new Error('Encounter not started.');
|
|
const newPausedState = !encounter.isPaused;
|
|
const patch = { isPaused: newPausedState, turnOrderIds: encounter.turnOrderIds };
|
|
const log = {
|
|
type: newPausedState ? 'pause' : 'resume',
|
|
message: `Combat ${newPausedState ? 'paused' : 'resumed'}: "${encounter.name}"`,
|
|
delta: { paused: newPausedState },
|
|
undo: { isPaused: encounter.isPaused ?? false },
|
|
};
|
|
return commit(encounter, patch, log, 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 || [])] },
|
|
};
|
|
return commit(encounter, patch, log, ctx);
|
|
}
|
|
|
|
async function addParticipants(encounter, newParticipants, ctx) {
|
|
const updatedParticipants = [...(encounter.participants || []), ...newParticipants];
|
|
const names = newParticipants.map(p => p.name).join(', ');
|
|
const patch = { participants: updatedParticipants };
|
|
const log = {
|
|
type: 'add_participants',
|
|
message: `Added ${newParticipants.length} participant${newParticipants.length === 1 ? '' : 's'}: ${names}`,
|
|
delta: { count: newParticipants.length },
|
|
undo: { participants: newParticipants },
|
|
};
|
|
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 } },
|
|
};
|
|
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 } },
|
|
};
|
|
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 || [])] },
|
|
};
|
|
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, ctx) {
|
|
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
|
if (!participant) throw new Error('Participant not found.');
|
|
if (isNaN(amount) || amount === 0) return encounter; // no-op, no write
|
|
let newHp = participant.currentHp;
|
|
if (changeType === 'damage') newHp = Math.max(0, participant.currentHp - amount);
|
|
else if (changeType === 'heal') newHp = Math.min(participant.maxHp, participant.currentHp + amount);
|
|
const wasDead = participant.currentHp === 0;
|
|
const isDead = newHp === 0;
|
|
const wasResurrected = wasDead && newHp > 0;
|
|
const updatedParticipants = (encounter.participants || []).map(p => {
|
|
if (p.id !== participantId) return p;
|
|
const updates = { ...p, currentHp: newHp };
|
|
if (isDead && !wasDead) {
|
|
updates.isActive = false;
|
|
updates.deathSaves = p.deathSaves || 0;
|
|
updates.isDying = false;
|
|
}
|
|
if (wasResurrected) {
|
|
updates.isActive = true;
|
|
updates.deathSaves = 0;
|
|
updates.isDying = false;
|
|
}
|
|
return updates;
|
|
});
|
|
const hpLine = `${participant.currentHp} → ${newHp} HP`;
|
|
const deathSuffix = (isDead && !wasDead) ? (participant.type === 'character' ? ' — Unconscious' : ' — Defeated') : '';
|
|
const resurSuffix = wasResurrected ? ' — Revived' : '';
|
|
const message = changeType === 'damage'
|
|
? `${participant.name} took ${amount} damage (${hpLine})${deathSuffix}`
|
|
: `${participant.name} healed for ${amount} (${hpLine})${resurSuffix}`;
|
|
const patch = { participants: updatedParticipants };
|
|
// undo: inverse amount (damage→heal, heal→damage). expandUndo uses sign.
|
|
const undoAmount = changeType === 'damage' ? amount : -amount;
|
|
const log = {
|
|
type: changeType,
|
|
participantId, participantName: participant.name,
|
|
message,
|
|
delta: { amount, from: participant.currentHp, to: newHp },
|
|
undo: { amount: undoAmount },
|
|
};
|
|
return commit(encounter, patch, log, ctx);
|
|
}
|
|
|
|
// DEATH_SAVE — returns { enc, status, isDying } so caller knows terminal state.
|
|
async function deathSave(encounter, participantId, type, n, ctx) {
|
|
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
|
if (!participant) throw new Error('Participant not found.');
|
|
if (type !== 'success' && type !== 'fail') {
|
|
throw new Error(`deathSave type must be 'success' or 'fail', got "${type}".`);
|
|
}
|
|
const name = participant.name;
|
|
const current = type === 'success' ? (participant.deathSaves || 0) : (participant.deathFails || 0);
|
|
const next = current === n ? n - 1 : n;
|
|
const field = type === 'success' ? 'deathSaves' : 'deathFails';
|
|
const updates = { [field]: next };
|
|
let status = 'pending';
|
|
const newSuccesses = type === 'success' ? next : (participant.deathSaves || 0);
|
|
const newFails = type === 'fail' ? next : (participant.deathFails || 0);
|
|
if (newSuccesses >= 3) { updates.isStable = true; updates.isDying = false; status = 'stable'; }
|
|
else if (newFails >= 3) { updates.isStable = false; updates.isDying = true; status = 'dead'; }
|
|
else { updates.isStable = false; updates.isDying = false; }
|
|
const updatedParticipants = (encounter.participants || []).map(p =>
|
|
p.id === participantId ? { ...p, ...updates } : p
|
|
);
|
|
const message = status === 'stable' ? `${name} stabilized (3 death saves)`
|
|
: status === 'dead' ? `${name} failed 3 death saves — dead`
|
|
: `${name} death ${type}: ${next}/3`;
|
|
const patch = { participants: updatedParticipants };
|
|
const oldValues = { [field]: participant[field] || 0, isStable: participant.isStable || false, isDying: participant.isDying || false };
|
|
const log = {
|
|
type: 'death_save',
|
|
participantId, participantName: name,
|
|
message,
|
|
delta: { type, count: next, status },
|
|
undo: { oldValues },
|
|
};
|
|
const enc = await commit(encounter, patch, log, ctx);
|
|
return { enc, status, isDying: status === 'dead' };
|
|
}
|
|
|
|
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 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.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 [removedItem] = participants.splice(draggedIndex, 1);
|
|
const newTargetIndex = participants.findIndex(p => p.id === targetId);
|
|
participants.splice(newTargetIndex, 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 },
|
|
};
|
|
return commit(encounter, patch, log, ctx);
|
|
}
|
|
|
|
async function endEncounter(encounter, ctx) {
|
|
const patch = { isStarted: false, isPaused: false, currentTurnParticipantId: null, round: 0, turnOrderIds: [] };
|
|
const log = {
|
|
type: 'end_encounter',
|
|
message: `Combat ended: "${encounter.name}"`,
|
|
delta: {},
|
|
undo: { isStarted: encounter.isStarted ?? false, isPaused: encounter.isPaused ?? false, round: encounter.round ?? 0, currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, turnOrderIds: [...(encounter.turnOrderIds || [])] },
|
|
};
|
|
return commit(encounter, patch, log, ctx);
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Display lifecycle — write activeDisplay doc, no combat log.
|
|
// ctx.displayPath required.
|
|
// ----------------------------------------------------------------------------
|
|
|
|
async function activateDisplay({ campaignId, encounterId }, ctx) {
|
|
await ctx.storage.updateDoc(ctx.displayPath, { activeCampaignId: campaignId, activeEncounterId: encounterId });
|
|
}
|
|
|
|
async function clearDisplay(ctx) {
|
|
await ctx.storage.updateDoc(ctx.displayPath, { activeCampaignId: null, activeEncounterId: null });
|
|
}
|
|
|
|
async function toggleHidePlayerHp(currentValue, ctx) {
|
|
await ctx.storage.updateDoc(ctx.displayPath, { hidePlayerHp: !currentValue });
|
|
}
|
|
|
|
module.exports = {
|
|
DEFAULT_MAX_HP, DEFAULT_INIT_MOD, MONSTER_DEFAULT_INIT_MOD,
|
|
generateId, rollD20, formatInitMod,
|
|
sortParticipantsByInitiative, syncTurnOrder, computeTurnOrderAfterRemoval,
|
|
snapshotOf, buildEntry, expandUndo,
|
|
makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
|
|
startEncounter, nextTurn, togglePause,
|
|
addParticipant, addParticipants, updateParticipant, removeParticipant,
|
|
toggleParticipantActive, applyHpChange, deathSave, toggleCondition,
|
|
reorderParticipants, endEncounter,
|
|
activateDisplay, clearDisplay, toggleHidePlayerHp,
|
|
};
|