Files
ttrpg-initiative-tracker/shared/turn.js
T
david raistrick e650cd2710 refactor: single-source display lifecycle + scenario test no React
Move activeDisplay lifecycle to shared (one path, not duplicated in App):
- activateDisplay({campaignId, encounterId})
- clearDisplay()
- toggleHidePlayerHp(current)
All 6 App sites wired (start/end/2×delete-encounter/delete-campaign/hp-toggle).
Existing {patch,log} return shape preserved. No interface changes.

Combat.scenario.test.js rewritten to call shared directly, no React:
- Same actions as old UI version (roster chars, monsters, addAll, hp-toggle×2,
  start, 100 rounds of damage/heal/conditions/toggle/edit/deathsave/pause/
  resume/add/remove, end). Same funcs, same order, same data.
- Models two firestore docs (encounter + activeDisplay) via shared patches.
- Run time: 72s -> 4ms.

Both suites green: shared 91/91, App 77/77.
2026-07-03 18:26:02 -04:00

607 lines
23 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}`;
};
// 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.`);
}
// FEAT-3: reslot by initiative on add (stable sort, tie-break original index).
// Pre-combat: list reflects init order immediately. Post-combat: slots into running order.
const updatedParticipants = sortParticipantsByInitiative(
[...(encounter.participants || []), participant],
encounter.participants || []
);
return {
patch: { participants: updatedParticipants, ...syncTurnOrder(updatedParticipants) },
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
);
// FEAT-3: reslot by initiative so any init change reflects in list order
// immediately (stable sort, tie-break = original array index).
const reslotted = sortParticipantsByInitiative(updatedParticipants, encounter.participants || []);
return { patch: { participants: reslotted, ...syncTurnOrder(reslotted) }, 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;
// Unified (App main): death flips isActive=false (removed from active
// rotation, skipped by nextTurn). Revive flips true. No turnOrderIds change.
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 = {};
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.
// REORDER_PARTICIPANTS — drag. Same-initiative ONLY (tie-break override).
// Cross-init drag = no-op (use edit-initiative field instead). Splice move.
function reorderParticipants(encounter, draggedId, targetId) {
const participants = [...(encounter.participants || [])];
const dragged = participants.find(p => p.id === draggedId);
const target = participants.find(p => p.id === targetId);
if (!dragged || !target) throw new Error('Dragged or target item not found.');
if (draggedId === targetId) return { patch: null, log: null };
if (dragged.initiative !== target.initiative) {
return { patch: null, log: null }; // cross-init blocked
}
const draggedIndex = participants.findIndex(p => p.id === draggedId);
const [removedItem] = participants.splice(draggedIndex, 1);
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 || [])],
},
},
};
}
// ----------------------------------------------------------------------------
// Display lifecycle (activeDisplay/status doc). Pure patches, same shape as
// App's inline storage.updateDoc(getPath.activeDisplay(), ...). Single source.
// Callers persist patch to the activeDisplay/status doc.
// ----------------------------------------------------------------------------
// ACTIVATE_DISPLAY — start combat: point player display at this encounter.
function activateDisplay({ campaignId, encounterId }) {
return { patch: { activeCampaignId: campaignId, activeEncounterId: encounterId } };
}
// CLEAR_DISPLAY — end combat: player display shows nothing.
function clearDisplay() {
return { patch: { activeCampaignId: null, activeEncounterId: null } };
}
// TOGGLE_HIDE_PLAYER_HP — flip player-HP visibility on display.
function toggleHidePlayerHp(currentValue) {
return { patch: { hidePlayerHp: !currentValue } };
}
module.exports = {
DEFAULT_MAX_HP,
DEFAULT_INIT_MOD,
MONSTER_DEFAULT_INIT_MOD,
generateId,
rollD20,
formatInitMod,
sortParticipantsByInitiative,
syncTurnOrder,
computeTurnOrderAfterRemoval,
computeTurnOrderAfterAddition,
makeParticipant,
buildCharacterParticipant,
buildMonsterParticipant,
startEncounter,
nextTurn,
togglePause,
addParticipant,
addParticipants,
updateParticipant,
removeParticipant,
toggleParticipantActive,
applyHpChange,
deathSave,
toggleCondition,
reorderParticipants,
endEncounter,
activateDisplay,
clearDisplay,
toggleHidePlayerHp,
};