// @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); // first try next-active AFTER removed (same round, no wrap) const after = currentIds.slice(removedPos + 1); const nextSameRound = after.find(id => updatedParticipants.find(p => p.id === id && p.isActive)); if (nextSameRound) { updates.currentTurnParticipantId = nextSameRound; } else { // wrap: no active after removed → advance to first active at top of // order AND bump round. Without the bump, nextTurn sees current already // at order[0] and replays the whole round (BUG-5). const before = currentIds.slice(0, removedPos); const nextId = before.find(id => updatedParticipants.find(p => p.id === id && p.isActive)) ?? null; updates.currentTurnParticipantId = nextId; if (nextId) 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 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, };