494327ff17
Extract shared nextActiveAfter() advance core. Both nextTurn and computeTurnOrderAfterRemoval delegate to it — single source of truth, eliminates drift risk where one path changes and the other doesn't. Previously two separate advance implementations computed the same target, but any future edit to one would silently desync deact-current advance from normal nextTurn advance. Replay (scripts/replay-combat.js): - Move turn-line print before mutations (event order = reality) - Emit [pointer X→Y] lines when a mutation advances currentTurnParticipantId - Emit [pointer X→Y wrap] when round bumps (removal-wrap case) - Skip pointer emission for nextTurn (label=null) — already logged via turn line Parser (scripts/analyze-turns.js): - Parse [pointer X→Y wrap] events - Credit pointer-target as acted (deact-current advance = turn pointer) - Wrap pointer credits NEXT round (not current) — fixes cross-round false skip - Drop currentRemoved special-case — pointer lines make skip check precise Tests: - shared/tests/turn.dry.test.js: 3 tests lock deact-current advance == nextTurn advance (mid-round, inactive-skipper, wrap+round-bump). RED catches future drift. Results: 500-round replay now 0 real skips, 0 double-acts (was 5+3). Shared suite: 79 green + 1 RED (BUG-6 reorder, intentional).
556 lines
21 KiB
JavaScript
556 lines
21 KiB
JavaScript
// @ttrpg/shared — turn.js
|
|
// Pure turn-order logic. No I/O, no React, no Firebase.
|
|
// Ported VERBATIM from src/App.js (M1). Bugs preserved intentionally.
|
|
// Characterization tests lock current behavior. Fixes come in M4.
|
|
//
|
|
// Functions return NEW state (immutable). They never mutate input encounter.
|
|
|
|
'use strict';
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Constants (mirror src/App.js)
|
|
// ----------------------------------------------------------------------------
|
|
|
|
const DEFAULT_MAX_HP = 10;
|
|
const DEFAULT_INIT_MOD = 0;
|
|
const MONSTER_DEFAULT_INIT_MOD = 2;
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Utility functions (verbatim from src/App.js)
|
|
// ----------------------------------------------------------------------------
|
|
|
|
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}`;
|
|
};
|
|
|
|
// Verbatim from src/App.js. originalOrder preserves insertion order for ties.
|
|
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;
|
|
});
|
|
};
|
|
|
|
// SHARED ADVANCE CORE (BUG-5 DRY fix).
|
|
// Single source of truth for "who acts next". Both nextTurn and
|
|
// computeTurnOrderAfterRemoval delegate here — prevents drift where one path
|
|
// changes and the other doesn't.
|
|
//
|
|
// order: turnOrderIds (raw, may contain inactive/removed ids).
|
|
// fromPos: index of the last-acted slot (current participant, or the removed
|
|
// participant's old slot). Step +1 forward, skip fromPos itself.
|
|
// isActive: predicate id -> bool.
|
|
// Returns { nextId, wrapped }. wrapped = cycled past order end = new round.
|
|
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 }; // no other active participant
|
|
};
|
|
|
|
// Verbatim from src/App.js. Returns turnOrderIds/currentTurnParticipantId updates
|
|
// when a participant leaves active combat.
|
|
const computeTurnOrderAfterRemoval = (encounter, removedId, updatedParticipants) => {
|
|
if (!encounter.isStarted) return {};
|
|
const currentIds = encounter.turnOrderIds || [];
|
|
const newIds = currentIds.filter(id => id !== removedId);
|
|
const updates = { turnOrderIds: newIds };
|
|
if (encounter.currentTurnParticipantId === removedId) {
|
|
const removedPos = currentIds.indexOf(removedId);
|
|
const isActive = id => updatedParticipants.find(p => p.id === id && p.isActive);
|
|
// Delegate to shared core: advance from removed's old slot. Same math
|
|
// nextTurn uses → no drift.
|
|
const { nextId, wrapped } = nextActiveAfter(currentIds, removedPos, isActive);
|
|
updates.currentTurnParticipantId = nextId;
|
|
if (nextId && wrapped) updates.round = (encounter.round || 1) + 1;
|
|
}
|
|
return updates;
|
|
};
|
|
|
|
// Insert addedId into turnOrderIds by initiative. New participant slots into
|
|
// correct initiative position at add time (not appended to end). Preserves
|
|
// current pointer — no re-sort anywhere except startEncounter.
|
|
// Tie rule: insert AFTER existing same-init (preserves creation order).
|
|
const computeTurnOrderAfterAddition = (encounter, addedId) => {
|
|
if (!encounter.isStarted) return {};
|
|
const currentIds = encounter.turnOrderIds || [];
|
|
if (currentIds.includes(addedId)) return {};
|
|
const added = (encounter.participants || []).find(p => p.id === addedId);
|
|
if (!added) return { turnOrderIds: [...currentIds, addedId] };
|
|
// find first id with strictly lower initiative; insert before it (== after all >= )
|
|
const initOf = id => {
|
|
const p = (encounter.participants || []).find(x => x.id === id);
|
|
return p ? (p.initiative || 0) : 0;
|
|
};
|
|
const addedInit = added.initiative || 0;
|
|
let insertAt = currentIds.length;
|
|
for (let i = 0; i < currentIds.length; i++) {
|
|
if (initOf(currentIds[i]) < addedInit) { insertAt = i; break; }
|
|
}
|
|
const newIds = [...currentIds.slice(0, insertAt), addedId, ...currentIds.slice(insertAt)];
|
|
return { turnOrderIds: newIds };
|
|
};
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Participant factory (mirrors ParticipantManager.handleAddParticipant shape)
|
|
// ----------------------------------------------------------------------------
|
|
|
|
function makeParticipant(opts) {
|
|
return {
|
|
id: opts.id || generateId(),
|
|
name: opts.name,
|
|
type: opts.type, // 'character' | 'monster'
|
|
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,
|
|
isDying: opts.isDying || false,
|
|
};
|
|
}
|
|
|
|
// Build a character participant from a campaign character (rolls initiative).
|
|
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 },
|
|
};
|
|
}
|
|
|
|
// Build a monster participant (rolls initiative).
|
|
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 },
|
|
};
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Action handlers — pure: (encounter, action) => { encounter, patch, log }
|
|
// Return patch = partial fields to merge into stored encounter.
|
|
// Caller persists patch + broadcasts.
|
|
// ----------------------------------------------------------------------------
|
|
|
|
// START_ENCOUNTER — verbatim from InitiativeControls.handleStartEncounter
|
|
function startEncounter(encounter) {
|
|
if (!encounter.participants || encounter.participants.length === 0) {
|
|
throw new Error('Add participants first.');
|
|
}
|
|
const activeParticipants = encounter.participants.filter(p => p.isActive);
|
|
if (activeParticipants.length === 0) {
|
|
throw new Error('No active participants.');
|
|
}
|
|
const sortedParticipants = sortParticipantsByInitiative(activeParticipants, encounter.participants);
|
|
return {
|
|
patch: {
|
|
isStarted: true,
|
|
isPaused: false,
|
|
round: 1,
|
|
currentTurnParticipantId: sortedParticipants[0].id,
|
|
turnOrderIds: sortedParticipants.map(p => p.id),
|
|
},
|
|
log: {
|
|
message: `Combat started: "${encounter.name}" — ${sortedParticipants[0].name}'s turn (Round 1)`,
|
|
undo: {
|
|
isStarted: encounter.isStarted ?? false,
|
|
isPaused: encounter.isPaused ?? false,
|
|
round: encounter.round ?? 0,
|
|
currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
|
|
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
// NEXT_TURN — verbatim from InitiativeControls.handleNextTurn
|
|
// NOTE: this is the suspected skip-bug source. Preserved for M3 characterization.
|
|
function nextTurn(encounter) {
|
|
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) {
|
|
// End encounter — no active participants left.
|
|
return {
|
|
patch: {
|
|
isStarted: false,
|
|
isPaused: false,
|
|
currentTurnParticipantId: null,
|
|
round: encounter.round,
|
|
},
|
|
log: { message: `Combat auto-ended: no active participants`, undo: null },
|
|
};
|
|
}
|
|
|
|
let nextRound = encounter.round;
|
|
let newTurnOrderIds = encounter.turnOrderIds;
|
|
|
|
// Delegate to shared advance core (BUG-5 DRY fix). Same math
|
|
// computeTurnOrderAfterRemoval uses → no drift. fromPos = current's slot
|
|
// in raw turnOrderIds; -1 path handles removed/stale current.
|
|
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);
|
|
|
|
return {
|
|
patch: {
|
|
currentTurnParticipantId: nextParticipant.id,
|
|
round: nextRound,
|
|
turnOrderIds: newTurnOrderIds,
|
|
},
|
|
log: {
|
|
message: `${nextParticipant.name}'s turn (Round ${nextRound})`,
|
|
undo: {
|
|
currentTurnParticipantId: encounter.currentTurnParticipantId,
|
|
round: encounter.round,
|
|
turnOrderIds: [...encounter.turnOrderIds],
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
// PAUSE / RESUME — verbatim from InitiativeControls.handleTogglePause
|
|
function togglePause(encounter) {
|
|
if (!encounter || !encounter.isStarted) {
|
|
throw new Error('Encounter not started.');
|
|
}
|
|
const newPausedState = !encounter.isPaused;
|
|
let newTurnOrderIds = encounter.turnOrderIds;
|
|
if (!newPausedState && encounter.isPaused) {
|
|
// Resume: do NOT re-sort. Re-sorting displaces the current pointer —
|
|
// participants who already acted move earlier in order and nextTurn
|
|
// revisits them (whole round replays). Order is frozen at startEncounter
|
|
// and patched incrementally; resume keeps it stable.
|
|
}
|
|
return {
|
|
patch: { isPaused: newPausedState, turnOrderIds: newTurnOrderIds },
|
|
log: {
|
|
message: `Combat ${newPausedState ? 'paused' : 'resumed'}: "${encounter.name}"`,
|
|
undo: {
|
|
isPaused: encounter.isPaused ?? false,
|
|
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
// ADD_PARTICIPANT — appends participant. (Initiative rolled by caller via build*.)
|
|
// If encounter already started, also slot participant into turnOrderIds by
|
|
// initiative (via computeTurnOrderAfterAddition).
|
|
function addParticipant(encounter, participant) {
|
|
if ((encounter.participants || []).some(p => p.id === participant.id)) {
|
|
throw new Error(`Participant with id "${participant.id}" already exists in encounter.`);
|
|
}
|
|
const updatedParticipants = [...(encounter.participants || []), participant];
|
|
const intermediate = { ...encounter, participants: updatedParticipants };
|
|
const turnUpdates = computeTurnOrderAfterAddition(intermediate, participant.id);
|
|
return {
|
|
patch: { participants: updatedParticipants, ...turnUpdates },
|
|
log: {
|
|
message: `${participant.name} added to encounter (Initiative: ${participant.initiative})`,
|
|
undo: {
|
|
participants: [...(encounter.participants || [])],
|
|
...(encounter.isStarted ? {
|
|
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
|
} : {}),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
// ADD_PARTICIPANTS — bulk add (e.g. "add all campaign characters").
|
|
function addParticipants(encounter, newParticipants) {
|
|
const updatedParticipants = [...(encounter.participants || []), ...newParticipants];
|
|
return { patch: { participants: updatedParticipants }, log: null };
|
|
}
|
|
|
|
// UPDATE_PARTICIPANT — edit modal save (name/initiative/hp/isNpc).
|
|
function updateParticipant(encounter, participantId, updatedData) {
|
|
const updatedParticipants = (encounter.participants || []).map(p =>
|
|
p.id === participantId ? { ...p, ...updatedData } : p
|
|
);
|
|
return { patch: { participants: updatedParticipants }, log: null };
|
|
}
|
|
|
|
// REMOVE_PARTICIPANT — verbatim from ParticipantManager.confirmDeleteParticipant
|
|
function removeParticipant(encounter, participantId) {
|
|
const updatedParticipants = (encounter.participants || []).filter(p => p.id !== participantId);
|
|
const turnUpdates = computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants);
|
|
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
|
return {
|
|
patch: { participants: updatedParticipants, ...turnUpdates },
|
|
log: {
|
|
message: `${participant ? participant.name : 'Participant'} removed from encounter`,
|
|
undo: {
|
|
participants: [...(encounter.participants || [])],
|
|
...(encounter.isStarted ? {
|
|
currentTurnParticipantId: encounter.currentTurnParticipantId,
|
|
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
|
} : {}),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
// TOGGLE_ACTIVE — verbatim from ParticipantManager.toggleParticipantActive
|
|
function toggleParticipantActive(encounter, participantId) {
|
|
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
|
|
);
|
|
const turnUpdates = newIsActive
|
|
? computeTurnOrderAfterAddition(encounter, participantId)
|
|
: computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants);
|
|
return {
|
|
patch: { participants: updatedParticipants, ...turnUpdates },
|
|
log: {
|
|
message: `${participant.name} ${newIsActive ? 'reactivated' : 'deactivated'}`,
|
|
undo: {
|
|
participants: [...(encounter.participants || [])],
|
|
...(encounter.isStarted ? {
|
|
currentTurnParticipantId: encounter.currentTurnParticipantId,
|
|
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
|
} : {}),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
// APPLY_HP_CHANGE — verbatim from ParticipantManager.applyHpChange
|
|
// changeType: 'damage' | 'heal'
|
|
function applyHpChange(encounter, participantId, changeType, amount) {
|
|
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
|
if (!participant) throw new Error('Participant not found.');
|
|
if (isNaN(amount) || amount === 0) {
|
|
return { patch: null, log: null }; // no-op
|
|
}
|
|
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;
|
|
|
|
// FEAT-1: death no longer flips isActive or touches turnOrderIds.
|
|
// Dead participants stay in turn order, nextTurn still visits them, PCs
|
|
// get their death-save turn. isActive = DM-controlled combatant toggle only.
|
|
const updatedParticipants = (encounter.participants || []).map(p => {
|
|
if (p.id !== participantId) return p;
|
|
const updates = { ...p, currentHp: newHp };
|
|
if (isDead && !wasDead) {
|
|
updates.deathSaves = p.deathSaves || 0;
|
|
updates.isDying = false;
|
|
}
|
|
if (wasResurrected) {
|
|
updates.deathSaves = 0;
|
|
updates.isDying = false;
|
|
}
|
|
return updates;
|
|
});
|
|
|
|
// No turn-order updates on death/revive (FEAT-1).
|
|
const turnUpdates = {};
|
|
|
|
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}`;
|
|
|
|
return {
|
|
patch: { participants: updatedParticipants, ...turnUpdates },
|
|
log: {
|
|
message,
|
|
undo: {
|
|
participants: [...(encounter.participants || [])],
|
|
...((isDead && !wasDead) || wasResurrected ? {
|
|
currentTurnParticipantId: encounter.currentTurnParticipantId,
|
|
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
|
} : {}),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
// DEATH_SAVE — verbatim from ParticipantManager.handleDeathSaveChange
|
|
// saveNumber: 1 | 2 | 3. Returns isDying flag if 3rd save hit (client triggers removal animation).
|
|
function deathSave(encounter, participantId, saveNumber) {
|
|
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
|
if (!participant) throw new Error('Participant not found.');
|
|
const currentSaves = participant.deathSaves || 0;
|
|
const newSaves = currentSaves === saveNumber ? saveNumber - 1 : saveNumber;
|
|
|
|
if (newSaves === 3) {
|
|
// Mark dying — caller waits for animation, then calls removeParticipant.
|
|
const updatedParticipants = (encounter.participants || []).map(p =>
|
|
p.id === participantId ? { ...p, deathSaves: newSaves, isDying: true } : p
|
|
);
|
|
return {
|
|
patch: { participants: updatedParticipants },
|
|
log: null,
|
|
isDying: true,
|
|
};
|
|
}
|
|
|
|
const updatedParticipants = (encounter.participants || []).map(p =>
|
|
p.id === participantId ? { ...p, deathSaves: newSaves } : p
|
|
);
|
|
return { patch: { participants: updatedParticipants }, log: null, isDying: false };
|
|
}
|
|
|
|
// TOGGLE_CONDITION — verbatim from ParticipantManager.toggleCondition
|
|
function toggleCondition(encounter, participantId, conditionId) {
|
|
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 };
|
|
});
|
|
return {
|
|
patch: { participants: updatedParticipants },
|
|
log: {
|
|
message: `${participant.name} ${wasActive ? 'lost' : 'gained'} ${conditionId}`,
|
|
undo: { participants: [...(encounter.participants || [])] },
|
|
},
|
|
};
|
|
}
|
|
|
|
// REORDER_PARTICIPANTS — drag-drop within same-initiative tie.
|
|
// Verbatim from ParticipantManager.handleDrop.
|
|
function reorderParticipants(encounter, draggedId, targetId) {
|
|
const participants = [...(encounter.participants || [])];
|
|
const draggedIndex = participants.findIndex(p => p.id === draggedId);
|
|
const targetIndex = participants.findIndex(p => p.id === targetId);
|
|
if (draggedIndex === -1 || targetIndex === -1) {
|
|
throw new Error('Dragged or target item not found.');
|
|
}
|
|
const draggedItem = participants[draggedIndex];
|
|
const targetItem = participants[targetIndex];
|
|
if (draggedItem.initiative !== targetItem.initiative) {
|
|
throw new Error('Drag-drop only allowed for participants with same initiative.');
|
|
}
|
|
const [removedItem] = participants.splice(draggedIndex, 1);
|
|
participants.splice(targetIndex, 0, removedItem);
|
|
return { patch: { participants }, log: null };
|
|
}
|
|
|
|
// END_ENCOUNTER — verbatim from InitiativeControls.confirmEndEncounter
|
|
function endEncounter(encounter) {
|
|
return {
|
|
patch: {
|
|
isStarted: false,
|
|
isPaused: false,
|
|
currentTurnParticipantId: null,
|
|
round: 0,
|
|
turnOrderIds: [],
|
|
},
|
|
log: {
|
|
message: `Combat ended: "${encounter.name}"`,
|
|
undo: {
|
|
isStarted: encounter.isStarted ?? false,
|
|
isPaused: encounter.isPaused ?? false,
|
|
round: encounter.round ?? 0,
|
|
currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
|
|
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
DEFAULT_MAX_HP,
|
|
DEFAULT_INIT_MOD,
|
|
MONSTER_DEFAULT_INIT_MOD,
|
|
generateId,
|
|
rollD20,
|
|
formatInitMod,
|
|
sortParticipantsByInitiative,
|
|
computeTurnOrderAfterRemoval,
|
|
computeTurnOrderAfterAddition,
|
|
makeParticipant,
|
|
buildCharacterParticipant,
|
|
buildMonsterParticipant,
|
|
startEncounter,
|
|
nextTurn,
|
|
togglePause,
|
|
addParticipant,
|
|
addParticipants,
|
|
updateParticipant,
|
|
removeParticipant,
|
|
toggleParticipantActive,
|
|
applyHpChange,
|
|
deathSave,
|
|
toggleCondition,
|
|
reorderParticipants,
|
|
endEncounter,
|
|
};
|