Old model broken: single deathSaves counter, 3=revive. No fails tracked,
successes conflated with fails. DM couldn't model real death save outcomes.
New model (D&D 5e):
- deathSave(enc, id, type, n) — type='success'|'fail', n=1|2|3
- Participant fields: deathSaves (successes), deathFails, isStable, isDying
- 3 successes = stable (0hp unconscious, safe until healed)
- 3 failures = dead (isDying, caller animates removal)
- Toggling same pip = undo that pip
- Returns { patch, log, status } — status: 'stable'|'dead'|'pending'
isDying back-compat flag kept for App.js removal animation
App.js:
- UI split into green ✓ saves row + red ✕ fails row
- Status badges: Stabilized (emerald) / Dying (red pulse)
- handleDeathSaveChange(participantId, type, saveNumber) — new sig
- makeParticipant init: deathFails + isStable defaults
Data loss: old single-counter participants lose saves (feature didn't work,
no real state to preserve). User confirmed acceptable.
Tests:
- turn.deathsave.test.js: RED-then-GREEN, 3-success stable, 3-fail dead,
independent tracking, new signature
- Updated 6 callers across characterization/combat/dead-skip/logging/scenario
- Logs UI tests: new title selectors (Success N / Fail N)
243 tests green.
701 lines
27 KiB
JavaScript
701 lines
27 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}`;
|
|
};
|
|
|
|
// SLOT, NEVER SORT. 1-list model (docs/INITIATIVE_ORDERING.md).
|
|
//
|
|
// `sortParticipantsByInitiative` exists for ONE call site: startEncounter
|
|
// (freeze list once by init). After that, mutations = insert/move ops that
|
|
// PRESERVE existing array order. No wholesale re-sort ever — destroys drag
|
|
// tie-break order.
|
|
//
|
|
// slotIndexForInit: pure insert position. Scans existing list, returns index
|
|
// of first participant with init STRICTLY LESS than target. New participant
|
|
// splices there. Same-init participants stay ahead (stable). Existing tie
|
|
// order (incl. drag-established) untouched because scan reads current array,
|
|
// not original add-order.
|
|
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;
|
|
});
|
|
};
|
|
|
|
// Find splice index for a single participant by initiative. List assumed
|
|
// already initiative-descending (startEncounter freezes it; add/edit maintain).
|
|
// Returns position to insert so list stays descending, ties go AFTER existing
|
|
// same-init (stable insertion). Current array order = source of truth.
|
|
function slotIndexForInit(list, init) {
|
|
for (let i = 0; i < list.length; i++) {
|
|
if (init > list[i].initiative) return i;
|
|
}
|
|
return list.length;
|
|
}
|
|
|
|
// 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.
|
|
// 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, // successes (0-3)
|
|
deathFails: opts.deathFails || 0, // failures (0-3)
|
|
isStable: opts.isStable || false,
|
|
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 (slotIndexForInit). 1-list model.
|
|
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.`);
|
|
}
|
|
// SLOT (not sort): insert by initiative into current list. Preserves
|
|
// existing array order incl. drag-established tie order.
|
|
const base = [...(encounter.participants || [])];
|
|
const idx = slotIndexForInit(base, participant.initiative);
|
|
base.splice(idx, 0, participant);
|
|
const updatedParticipants = base;
|
|
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 undo = { participants: encounter.participants || [] };
|
|
const updatedParticipants = [...(encounter.participants || []), ...newParticipants];
|
|
const names = newParticipants.map(p => p.name).join(', ');
|
|
return {
|
|
patch: { participants: updatedParticipants },
|
|
log: {
|
|
message: `Added ${newParticipants.length} participant${newParticipants.length === 1 ? '' : 's'}: ${names}`,
|
|
undo,
|
|
},
|
|
};
|
|
}
|
|
|
|
// UPDATE_PARTICIPANT — edit modal save (name/initiative/hp/isNpc).
|
|
function updateParticipant(encounter, participantId, updatedData) {
|
|
const existing = encounter.participants || [];
|
|
const target = existing.find(p => p.id === participantId);
|
|
if (!target) throw new Error(`Participant "${participantId}" not found.`);
|
|
const merged = { ...target, ...updatedData };
|
|
// SLOT only if initiative changed. Other edits (name/hp/notes/isNpc) = no
|
|
// position change (design doc). Re-slots on unrelated edits shuffle the
|
|
// list mid-round → rotation dupes.
|
|
const undo = { participants: encounter.participants || [] };
|
|
if (!('initiative' in updatedData) || updatedData.initiative === target.initiative) {
|
|
const sameSlot = existing.map(p => p.id === participantId ? merged : p);
|
|
const fields = Object.keys(updatedData).join(', ');
|
|
return {
|
|
patch: { participants: sameSlot, ...syncTurnOrder(sameSlot) },
|
|
log: {
|
|
message: `${target.name} updated (${fields})`,
|
|
undo,
|
|
},
|
|
};
|
|
}
|
|
const without = existing.filter(p => p.id !== participantId);
|
|
const idx = slotIndexForInit(without, merged.initiative);
|
|
without.splice(idx, 0, merged);
|
|
return {
|
|
patch: { participants: without, ...syncTurnOrder(without) },
|
|
log: {
|
|
message: `${target.name} updated (initiative: ${merged.initiative})`,
|
|
undo,
|
|
},
|
|
};
|
|
}
|
|
|
|
// 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 — D&D 5e model. Separate success/fail tracking.
|
|
// type: 'success' | 'fail'
|
|
// n: 1 | 2 | 3 (which pip clicked)
|
|
// 3 successes = stable (0hp unconscious, safe). 3 fails = dead (isDying,
|
|
// caller animates removal). Toggling same pip = undo that pip.
|
|
// Returns { patch, log, status } where status: 'stable'|'dead'|'pending'.
|
|
function deathSave(encounter, participantId, type, n) {
|
|
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
|
if (!participant) throw new Error('Participant not found.');
|
|
if (type !== 'success' && type !== 'fail') {
|
|
throw new Error(`deathSave type must be 'success' or 'fail', got "${type}".`);
|
|
}
|
|
const name = participant.name;
|
|
const undo = { participants: encounter.participants || [] };
|
|
|
|
// Toggle: clicking current-value pip decrements (undo that pip).
|
|
const current = type === 'success'
|
|
? (participant.deathSaves || 0)
|
|
: (participant.deathFails || 0);
|
|
const next = current === n ? n - 1 : n;
|
|
|
|
const field = type === 'success' ? 'deathSaves' : 'deathFails';
|
|
const updates = { [field]: next };
|
|
|
|
// Clear terminal flags on any change (re-eval below).
|
|
let status = 'pending';
|
|
const newSuccesses = type === 'success' ? next : (participant.deathSaves || 0);
|
|
const newFails = type === 'fail' ? next : (participant.deathFails || 0);
|
|
|
|
if (newSuccesses >= 3) {
|
|
updates.isStable = true;
|
|
updates.isDying = false;
|
|
status = 'stable';
|
|
} else if (newFails >= 3) {
|
|
updates.isStable = false;
|
|
updates.isDying = true;
|
|
status = 'dead';
|
|
} else {
|
|
updates.isStable = false;
|
|
updates.isDying = false;
|
|
}
|
|
|
|
const updatedParticipants = (encounter.participants || []).map(p =>
|
|
p.id === participantId ? { ...p, ...updates } : p
|
|
);
|
|
|
|
const message = status === 'stable'
|
|
? `${name} stabilized (3 death saves)`
|
|
: status === 'dead'
|
|
? `${name} failed 3 death saves — dead`
|
|
: `${name} death ${type}: ${next}/3`;
|
|
|
|
return {
|
|
patch: { participants: updatedParticipants },
|
|
log: { message, undo },
|
|
status,
|
|
isDying: status === 'dead', // back-compat for App.js handler
|
|
};
|
|
}
|
|
|
|
// 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. Same-initiative ONLY (tie-break override).
|
|
// Cross-init drag = no-op (use edit-initiative field instead). Splice move.
|
|
// Cross-pointer drag = no-op once encounter started (BUG-13). nextTurn walks
|
|
// turnOrderIds forward from current pointer. Dragging a not-yet-acted
|
|
// participant to a position behind the pointer would skip them (wrap past).
|
|
// Dragging an already-acted participant ahead of pointer would re-act them.
|
|
// Blocking cross-pointer keeps rotation deterministic. Pre-combat: free.
|
|
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);
|
|
// BUG-13: block cross-pointer reorder while encounter running. nextTurn
|
|
// walks turnOrderIds forward from current pointer. Dragging across the
|
|
// pointer makes who-acts-next ambiguous — either skip (upcoming dragged
|
|
// ahead of pointer, walk wraps past) or double-act (acted dragged behind).
|
|
// Full fix needs actedThisRound tracking. Pragmatic now: block both dirs.
|
|
// Pre-combat: free reorder (no pointer yet).
|
|
if (encounter.isStarted && encounter.currentTurnParticipantId) {
|
|
const pointerIdx = participants.findIndex(p => p.id === encounter.currentTurnParticipantId);
|
|
const targetIdx0 = participants.findIndex(p => p.id === targetId);
|
|
if (pointerIdx >= 0) {
|
|
const crosses = (a, b) => (a <= pointerIdx && b > pointerIdx) || (a > pointerIdx && b <= pointerIdx);
|
|
if (crosses(draggedIndex, targetIdx0)) {
|
|
return { patch: null, log: null }; // cross-pointer blocked
|
|
}
|
|
}
|
|
}
|
|
const [removedItem] = participants.splice(draggedIndex, 1);
|
|
const newTargetIndex = participants.findIndex(p => p.id === targetId);
|
|
participants.splice(newTargetIndex, 0, removedItem);
|
|
const turnUpdates = syncTurnOrder(participants); // 1-list: always mirror
|
|
return {
|
|
patch: { participants, ...turnUpdates },
|
|
log: {
|
|
message: `${removedItem.name} reordered before ${(participants.find(p => p.id === targetId) || {}).name || targetId}`,
|
|
undo: {
|
|
participants: encounter.participants || [],
|
|
turnOrderIds: encounter.turnOrderIds || [],
|
|
currentTurnParticipantId: encounter.currentTurnParticipantId ?? 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,
|
|
makeParticipant,
|
|
buildCharacterParticipant,
|
|
buildMonsterParticipant,
|
|
startEncounter,
|
|
nextTurn,
|
|
togglePause,
|
|
addParticipant,
|
|
addParticipants,
|
|
updateParticipant,
|
|
removeParticipant,
|
|
toggleParticipantActive,
|
|
applyHpChange,
|
|
deathSave,
|
|
toggleCondition,
|
|
reorderParticipants,
|
|
endEncounter,
|
|
activateDisplay,
|
|
clearDisplay,
|
|
toggleHidePlayerHp,
|
|
};
|