M1: shared turn logic + characterization tests (39 green)
- npm workspaces: shared/, server/ - shared/turn.js: port turn logic verbatim from App.js (bugs preserved) - 39 characterization tests lock current behavior - gitignore: sqlite data, logs
This commit is contained in:
+519
@@ -0,0 +1,519 @@
|
||||
// @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;
|
||||
};
|
||||
|
||||
// Verbatim from src/App.js. Returns turnOrderIds update when a participant
|
||||
// re-enters active combat mid-encounter.
|
||||
const computeTurnOrderAfterAddition = (encounter, addedId) => {
|
||||
if (!encounter.isStarted) return {};
|
||||
const currentIds = encounter.turnOrderIds || [];
|
||||
if (currentIds.includes(addedId)) return {};
|
||||
return { turnOrderIds: [...currentIds, addedId] };
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 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;
|
||||
|
||||
if (nextIndex === 0 && currentIndex !== -1) {
|
||||
nextRound += 1;
|
||||
// Rebuild turn order by initiative at start of new round so participants
|
||||
// activated mid-round (appended to end) slot into proper initiative position next round.
|
||||
const activePs = encounter.participants.filter(p => p.isActive);
|
||||
const sorted = sortParticipantsByInitiative(activePs, encounter.participants);
|
||||
newTurnOrderIds = sorted.map(p => p.id);
|
||||
}
|
||||
|
||||
const nextParticipant = (nextIndex === 0 && currentIndex !== -1)
|
||||
? encounter.participants.find(p => p.id === newTurnOrderIds[0])
|
||||
: 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) {
|
||||
// Resuming — recompute turn order from active participants.
|
||||
const activeParticipants = encounter.participants.filter(p => p.isActive);
|
||||
const sortedParticipants = sortParticipantsByInitiative(activeParticipants, encounter.participants);
|
||||
newTurnOrderIds = sortedParticipants.map(p => p.id);
|
||||
}
|
||||
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*.)
|
||||
function addParticipant(encounter, participant) {
|
||||
const updatedParticipants = [...(encounter.participants || []), participant];
|
||||
return {
|
||||
patch: { participants: updatedParticipants },
|
||||
log: {
|
||||
message: `${participant.name} added to encounter (Initiative: ${participant.initiative})`,
|
||||
undo: { participants: [...(encounter.participants || [])] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
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 turnUpdates = (isDead && !wasDead)
|
||||
? computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants)
|
||||
: wasResurrected
|
||||
? computeTurnOrderAfterAddition(encounter, participantId)
|
||||
: {};
|
||||
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user