refactor: 1-list turn order model (turnOrderIds === participants.map(id))
Single source of truth. No re-sort after startEncounter. Drag overrides initiative (cross-init drag allowed, DM choice). Display === rotation by construction — same array. shared/turn.js: - syncTurnOrder(participants) helper: turnOrderIds = participants.map(id) - startEncounter: sort ALL participants by init (active+inactive), inactive stay in slot, nextTurn skips them. currentTurn = first active. - addParticipant: splice into participants[] by init pos, sync turnOrderIds. computeTurnOrderAfterAddition returns insertAt (caller splices + syncs). - removeParticipant: filter participants[], sync turnOrderIds, advance current if removed==current. - toggleParticipantActive: stay in slot (flip isActive only), sync. Advance current only if deact hits current. - reorderParticipants: cross-init drag allowed (remove same-init restriction). Splice participants[], sync turnOrderIds. Fixes BUG-6. - computeTurnOrderAfterRemoval: only handles current-advance now (list sync at call site). Tests updated to 1-list contract: - turn.invariant.test.js: 10 tests, turnOrderIds===participants.map(id) always, cross-init drag, inactive-in-slot, rotation follows list. - turn.characterization/reorder/round-rotation/undo/remove: updated expectations (inactive-in-slot, cross-init drag, turnOrderIds sync on reorder, insertAt return). Results: shared 90 green. 500-round replay CLEAN (0 skips, 0 doubles, 0 order shifts). BUG-6 (reorder divergence) fixed structurally. FE App.js still has duplicate turn funcs + sortParticipantsByInitiative display render (step 4: delete dups, render participants[] directly).
This commit is contained in:
+64
-33
@@ -31,7 +31,9 @@ const formatInitMod = (mod) => {
|
||||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||
};
|
||||
|
||||
// Verbatim from src/App.js. originalOrder preserves insertion order for ties.
|
||||
// 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) {
|
||||
@@ -43,6 +45,12 @@ const sortParticipantsByInitiative = (participants, originalOrder) => {
|
||||
});
|
||||
};
|
||||
|
||||
// 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
|
||||
@@ -68,15 +76,13 @@ const nextActiveAfter = (order, fromPos, isActive) => {
|
||||
// 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 };
|
||||
// 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 = currentIds.indexOf(removedId);
|
||||
const removedPos = (encounter.turnOrderIds || []).indexOf(removedId);
|
||||
const isActive = id => updatedParticipants.find(p => p.id === id && p.isActive);
|
||||
// Delegate to shared core: advance from removed's old slot. Same math
|
||||
// nextTurn uses → no drift.
|
||||
const { nextId, wrapped } = nextActiveAfter(currentIds, removedPos, isActive);
|
||||
const { nextId, wrapped } = nextActiveAfter(encounter.turnOrderIds || [], removedPos, isActive);
|
||||
updates.currentTurnParticipantId = nextId;
|
||||
if (nextId && wrapped) updates.round = (encounter.round || 1) + 1;
|
||||
}
|
||||
@@ -87,12 +93,13 @@ const computeTurnOrderAfterRemoval = (encounter, removedId, updatedParticipants)
|
||||
// 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 { turnOrderIds: [...currentIds, 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);
|
||||
@@ -103,8 +110,7 @@ const computeTurnOrderAfterAddition = (encounter, addedId) => {
|
||||
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 };
|
||||
return { insertAt }; // caller splices participants[] at this pos, then syncs
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -178,21 +184,25 @@ 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) {
|
||||
// 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 sortedParticipants = sortParticipantsByInitiative(activeParticipants, encounter.participants);
|
||||
const orderedParticipants = sortedParticipants;
|
||||
return {
|
||||
patch: {
|
||||
isStarted: true,
|
||||
isPaused: false,
|
||||
round: 1,
|
||||
currentTurnParticipantId: sortedParticipants[0].id,
|
||||
turnOrderIds: sortedParticipants.map(p => p.id),
|
||||
participants: orderedParticipants,
|
||||
currentTurnParticipantId: firstActive.id,
|
||||
turnOrderIds: orderedParticipants.map(p => p.id),
|
||||
},
|
||||
log: {
|
||||
message: `Combat started: "${encounter.name}" — ${sortedParticipants[0].name}'s turn (Round 1)`,
|
||||
message: `Combat started: "${encounter.name}" — ${firstActive.name}'s turn (Round 1)`,
|
||||
undo: {
|
||||
isStarted: encounter.isStarted ?? false,
|
||||
isPaused: encounter.isPaused ?? false,
|
||||
@@ -301,9 +311,24 @@ 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);
|
||||
// 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: {
|
||||
@@ -335,7 +360,8 @@ function updateParticipant(encounter, participantId, updatedData) {
|
||||
// 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 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 },
|
||||
@@ -360,9 +386,16 @@ function toggleParticipantActive(encounter, participantId) {
|
||||
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||
p.id === participantId ? { ...p, isActive: newIsActive } : p
|
||||
);
|
||||
const turnUpdates = newIsActive
|
||||
? computeTurnOrderAfterAddition(encounter, participantId)
|
||||
: computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants);
|
||||
// 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: {
|
||||
@@ -484,8 +517,8 @@ function toggleCondition(encounter, participantId, conditionId) {
|
||||
};
|
||||
}
|
||||
|
||||
// REORDER_PARTICIPANTS — drag-drop within same-initiative tie.
|
||||
// Verbatim from ParticipantManager.handleDrop.
|
||||
// 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);
|
||||
@@ -493,14 +526,12 @@ function reorderParticipants(encounter, draggedId, 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 };
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user