fix(turn): block cross-pointer reorder (BUG-13)

reorderParticipants allowed dragging across current pointer mid-round.
nextTurn walks turnOrderIds forward from pointer, so cross-pointer drag
= ambiguous who-acts-next: upcoming dragged ahead of pointer = skipped
(walk wraps past), acted dragged behind pointer = double-act.

Full fix needs actedThisRound tracking. Pragmatic now: block both
directions during active encounter. Pre-combat: free reorder (no pointer).

reorderParticipants: added pointer check (currentTurn index). If drag
crosses pointer, return null patch (no-op). Same-side reorder allowed.
syncTurnOrder now always mirrors (was started-only — pre-combat reorder
left turnOrderIds stale).

Tests:
- turn.bug13.test.js: cross-pointer blocked (both dirs), same-side allowed,
  pre-combat free.
- turn.reorder.test.js: 3 tests updated to pre-combat (cross-pointer now
  blocked post-start).
- turn.invariant.test.js: reorder test uses same-side upcoming pair.

214 tests green.
This commit is contained in:
david raistrick
2026-07-04 17:12:13 -04:00
parent 0ff50561c7
commit 942545c085
5 changed files with 148 additions and 52 deletions
+22 -3
View File
@@ -536,10 +536,13 @@ 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.
// 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);
@@ -550,10 +553,26 @@ function reorderParticipants(encounter, draggedId, targetId) {
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 = encounter.isStarted ? syncTurnOrder(participants) : {};
const turnUpdates = syncTurnOrder(participants); // 1-list: always mirror
return { patch: { participants, ...turnUpdates }, log: null };
}