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:
+44
-16
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user