fix(turn): slot-not-sort in add/update; static guard against stray .sort()

addParticipant + updateParticipant used sortParticipantsByInitiative (stable
sort, tie-break = original array index). That destroyed manually-dragged tie
order when a drag moved a same-init pair — violated the slot-not-sort design
(docs/INITIATIVE_ORDERING.md: 'Re-slotting on add/edit must preserve
drag-established tie order').

Fix: new slotIndexForInit(list, init) returns splice index into the CURRENT
list (initiative-descending). addParticipant inserts there; updateParticipant
re-inserts only when initiative changed (unrelated edits keep slot — avoids
mid-round rotation dupes surfaced by the 100-round combat test).

Tests:
- turn.slot-not-sort.test.js: drag tie order survives add/edit (4 cases).
- static.no-sort.test.js: errs if .sort( added outside allowlist
  (sortParticipantsByInitiative only). Verified by injecting a stray sort —
  guard caught it.
This commit is contained in:
david raistrick
2026-07-04 15:40:39 -04:00
parent 6baecd374e
commit cb41d9ec8f
3 changed files with 191 additions and 16 deletions
+44 -16
View File
@@ -31,9 +31,18 @@ const formatInitMod = (mod) => {
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.
// 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) {
@@ -45,6 +54,17 @@ const sortParticipantsByInitiative = (participants, originalOrder) => {
});
};
// 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) => ({
@@ -311,12 +331,12 @@ 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 || []
);
// 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: {
@@ -339,13 +359,21 @@ function addParticipants(encounter, newParticipants) {
// 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 };
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.
if (!('initiative' in updatedData) || updatedData.initiative === target.initiative) {
const sameSlot = existing.map(p => p.id === participantId ? merged : p);
return { patch: { participants: sameSlot, ...syncTurnOrder(sameSlot) }, log: null };
}
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: null };
}
// REMOVE_PARTICIPANT — verbatim from ParticipantManager.confirmDeleteParticipant