refactor: single-source combat logic (App handlers call shared, slot model)
App.js no longer inlines combat logic. All handlers delegate to @ttrpg/shared: startEncounter, nextTurn, togglePause, endEncounter, addParticipant(s), updateParticipant, removeParticipant, toggleParticipantActive, applyHpChange, deathSave, toggleCondition, reorderParticipants. ONE code path for UI. shared/turn.js aligned to slot model (docs/INITIATIVE_ORDERING.md): - addParticipant: splice into slot by initiative (no sort) - updateParticipant: re-slot on initiative change (no sort) - reorderParticipants: same-init drag only (cross-init blocked) - applyHpChange: death flips isActive=false (matches App), revive reactivates No renames, no API changes. Shared func signatures unchanged. Tests updated to match unified behavior: - dead-skip: death deactivates (was FEAT-1 stays active) - characterization: death deactivates + revive reactivates - reorder/invariant: cross-init drag blocked, same-init allowed Both suites green: shared 91/91, App 77/77, 0 warnings.
This commit is contained in:
+24
-29
@@ -311,26 +311,14 @@ 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) : {};
|
||||
// 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, ...turnUpdates },
|
||||
patch: { participants: updatedParticipants, ...syncTurnOrder(updatedParticipants) },
|
||||
log: {
|
||||
message: `${participant.name} added to encounter (Initiative: ${participant.initiative})`,
|
||||
undo: {
|
||||
@@ -354,7 +342,10 @@ function updateParticipant(encounter, participantId, updatedData) {
|
||||
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||
p.id === participantId ? { ...p, ...updatedData } : p
|
||||
);
|
||||
return { patch: { participants: updatedParticipants }, log: null };
|
||||
// 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
|
||||
@@ -427,24 +418,24 @@ function applyHpChange(encounter, participantId, changeType, amount) {
|
||||
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.
|
||||
// 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;
|
||||
});
|
||||
|
||||
// No turn-order updates on death/revive (FEAT-1).
|
||||
const turnUpdates = {};
|
||||
|
||||
const hpLine = `${participant.currentHp} → ${newHp} HP`;
|
||||
@@ -519,15 +510,19 @@ function toggleCondition(encounter, participantId, conditionId) {
|
||||
|
||||
// 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 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 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);
|
||||
// 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) : {};
|
||||
|
||||
Reference in New Issue
Block a user