Files
ttrpg-initiative-tracker/shared/turn.js
T

587 lines
22 KiB
JavaScript
Raw Normal View History

// @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}`;
};
// Sort used ONLY at insert points (startEncounter, addParticipant) to position
// participants by initiative. Once positioned, turnOrderIds = participants.map(id)
// (1-list model). No re-sort after start — drag/edit are manual overrides.
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;
});
};
// 1-LIST SYNC: turnOrderIds always mirrors participants[].map(id).
// Call after any participants[] mutation. Returns turnOrderIds patch.
const syncTurnOrder = (participants) => ({
turnOrderIds: participants.map(p => p.id),
});
// 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 {};
// 1-list: turnOrderIds syncs from participants[].map(id) at call site.
// Here only handle current-advance if removed == current.
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;
};
// 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).
// NOTE: 1-list model — caller syncs participants[] in same pos as insert target.
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 {};
// 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; }
}
return { insertAt }; // caller splices participants[] at this pos, then syncs
};
// ----------------------------------------------------------------------------
// 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.');
}
// 1-list model: sort ALL participants by init (active + inactive) so display
// order = initiative. nextTurn skips inactive. turnOrderIds mirrors list.
const sortedParticipants = sortParticipantsByInitiative(encounter.participants || [], encounter.participants);
const firstActive = sortedParticipants.find(p => p.isActive);
if (!firstActive) {
throw new Error('No active participants.');
}
const orderedParticipants = sortedParticipants;
return {
patch: {
isStarted: true,
isPaused: false,
round: 1,
participants: orderedParticipants,
currentTurnParticipantId: firstActive.id,
turnOrderIds: orderedParticipants.map(p => p.id),
},
log: {
message: `Combat started: "${encounter.name}" — ${firstActive.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.`);
}
// 1-list: splice participant into participants[] by initiative position,
// then sync turnOrderIds = participants.map(id).
let updatedParticipants;
let insertAt;
if (!encounter.isStarted) {
updatedParticipants = [...(encounter.participants || []), participant];
} else {
const { insertAt: at } = computeTurnOrderAfterAddition(
{ ...encounter, participants: [...(encounter.participants || []), participant] },
participant.id);
insertAt = at !== undefined ? at : (encounter.participants || []).length;
updatedParticipants = [
...(encounter.participants || []).slice(0, insertAt),
participant,
...(encounter.participants || []).slice(insertAt),
];
}
const turnUpdates = encounter.isStarted ? syncTurnOrder(updatedParticipants) : {};
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 advUpdates = computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants);
const turnUpdates = encounter.isStarted ? { ...syncTurnOrder(updatedParticipants), ...advUpdates } : {};
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
);
// 1-list: participant stays in slot on toggle (active or not). nextTurn
// skips inactive. Only advance current if deact hits current.
let turnUpdates = {};
if (encounter.isStarted) {
turnUpdates = syncTurnOrder(updatedParticipants);
if (!newIsActive && encounter.currentTurnParticipantId === participantId) {
const adv = computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants);
turnUpdates = { ...turnUpdates, ...adv };
}
}
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. 1-list model: drag overrides initiative
// (DM choice). Cross-init drag allowed. Splices participants[], syncs turnOrderIds.
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 [removedItem] = participants.splice(draggedIndex, 1);
// recompute targetIndex after removal (shift if dragged was before target)
const newTargetIndex = participants.findIndex(p => p.id === targetId);
participants.splice(newTargetIndex, 0, removedItem);
const turnUpdates = encounter.isStarted ? syncTurnOrder(participants) : {};
return { patch: { participants, ...turnUpdates }, 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,
};