0473eacc1d
WORK IN PROGRESS — fix not complete. analyze-turns.js on 500-round replay still finds 46 real skips + 64 double-acts. turn.js changes: - computeTurnOrderAfterAddition: insert by initiative (not append end) - nextTurn wrap: no re-sort, cycle pointer - togglePause resume: no re-sort, order stable - addParticipant: patches turnOrderIds when started - applyHpChange: death no longer flips isActive or touches turnOrderIds (FEAT-1 dead-not-skipped) Tests: - shared/tests/turn.skip.test.js (NEW): deterministic skip invariants pure 100 rounds + 540 rounds w/ mutations, both green - shared/tests/turn.dead-skip.test.js: 4 green (FEAT-1) - turn.characterization.test.js: 3 sites updated to new behavior - turn.combat.test.js: boundary count fixed (wrap-turn attributed to new round), debug dump removed scripts/analyze-turns.js (NEW): deterministic replay-stdout parser. Reconstructs rounds, reports real skips + double-acts. Exit 1 on issue. Catches bugs unit tests miss (46 skips/64 double-acts in 500 rounds). TODO: FEAT-1 marked done, FEAT-2 added (upgrade app logs parseable).
541 lines
20 KiB
JavaScript
541 lines
20 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;
|
|
});
|
|
};
|
|
|
|
// 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 candidates = [...currentIds.slice(removedPos + 1), ...currentIds.slice(0, removedPos)];
|
|
const nextId = candidates.find(id => updatedParticipants.find(p => p.id === id && p.isActive)) ?? null;
|
|
updates.currentTurnParticipantId = nextId;
|
|
}
|
|
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 currentIndex = activePsInOrder.findIndex(p => p.id === encounter.currentTurnParticipantId);
|
|
let nextRound = encounter.round;
|
|
|
|
// Current participant was removed; find next after their old position in turnOrderIds.
|
|
if (currentIndex === -1) {
|
|
const rawPos = (encounter.turnOrderIds || []).indexOf(encounter.currentTurnParticipantId);
|
|
const candidateIds = [
|
|
...(encounter.turnOrderIds || []).slice(rawPos + 1),
|
|
...(encounter.turnOrderIds || []).slice(0, rawPos),
|
|
];
|
|
const nextP = candidateIds.map(id => activePsInOrder.find(p => p.id === id)).find(Boolean);
|
|
currentIndex = nextP ? activePsInOrder.findIndex(p => p.id === nextP.id) - 1 : -1;
|
|
}
|
|
|
|
let nextIndex = (currentIndex + 1) % activePsInOrder.length;
|
|
let newTurnOrderIds = encounter.turnOrderIds;
|
|
|
|
// Round wrap: initiative is cyclic. Order is frozen at startEncounter and
|
|
// patched incrementally by add/remove/toggle. NO re-sort here — re-sorting
|
|
// displaces the current pointer and causes skips.
|
|
if (nextIndex === 0 && currentIndex !== -1) {
|
|
nextRound += 1;
|
|
}
|
|
|
|
const nextParticipant = activePsInOrder[nextIndex];
|
|
|
|
if (!nextParticipant) {
|
|
throw new Error('Could not determine next participant.');
|
|
}
|
|
|
|
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,
|
|
};
|