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:
@@ -0,0 +1,77 @@
|
||||
// SLOT-NOT-SORT invariant: addParticipant + updateParticipant must PRESERVE
|
||||
// manually-dragged tie order (same-initiative participants).
|
||||
//
|
||||
// Design doc (docs/INITIATIVE_ORDERING.md):
|
||||
// "Re-slotting on add/edit must preserve drag-established tie order."
|
||||
// "Tie-break = original add order. Later additions slot AFTER existing
|
||||
// same-init participants. Stable insertion."
|
||||
//
|
||||
// Current code uses sortParticipantsByInitiative (stable sort, tie-break =
|
||||
// original array index). That is NOT stable insertion: it re-sorts the whole
|
||||
// list, destroying drag order when a drag moved a same-init pair.
|
||||
//
|
||||
// RED now: drag tie order survives neither add nor edit.
|
||||
|
||||
'use strict';
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const {
|
||||
makeParticipant,
|
||||
startEncounter, addParticipant, updateParticipant, reorderParticipants,
|
||||
} = shared;
|
||||
|
||||
function p(id, init, extra = {}) {
|
||||
return makeParticipant({ id, name: id, type: 'monster',
|
||||
initiative: init, maxHp: 100, currentHp: 100, ...extra });
|
||||
}
|
||||
function enc(ps, extra = {}) {
|
||||
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||||
round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra };
|
||||
}
|
||||
const apply = (e, r) => r && r.patch ? { ...e, ...r.patch } : e;
|
||||
|
||||
describe('SLOT-NOT-SORT: drag tie order survives add/edit', () => {
|
||||
test('addParticipant preserves existing drag tie order', () => {
|
||||
// Two same-init participants. DM drags b BEFORE a (tie override).
|
||||
let e = enc([p('a', 10), p('b', 10)]);
|
||||
e = apply(e, reorderParticipants(e, 'b', 'a')); // drag b ahead of a → [b,a]
|
||||
expect(e.participants.map(x => x.id)).toEqual(['b', 'a']);
|
||||
|
||||
// Add unrelated participant (different init). Tie order [b,a] MUST survive.
|
||||
e = apply(e, addParticipant(e, p('c', 5)));
|
||||
const ids = e.participants.map(x => x.id);
|
||||
// c slots last (init 5 < 10). b,a tie order preserved.
|
||||
expect(ids).toEqual(['b', 'a', 'c']);
|
||||
});
|
||||
|
||||
test('addParticipant with same init as dragged pair slots AFTER tie group', () => {
|
||||
// DM drags b before a (both init 10). Then add d also init 10.
|
||||
// d must slot AFTER existing same-init pair (stable insertion), not re-sort.
|
||||
let e = enc([p('a', 10), p('b', 10)]);
|
||||
e = apply(e, reorderParticipants(e, 'b', 'a')); // [b,a]
|
||||
e = apply(e, addParticipant(e, p('d', 10))); // init 10, after tie group
|
||||
const ids = e.participants.map(x => x.id);
|
||||
expect(ids).toEqual(['b', 'a', 'd']);
|
||||
});
|
||||
|
||||
test('updateParticipant preserves drag tie order on unrelated field edit', () => {
|
||||
let e = enc([p('a', 10), p('b', 10)]);
|
||||
e = apply(e, reorderParticipants(e, 'b', 'a')); // [b,a]
|
||||
// Edit a's name (not initiative). Tie order MUST survive.
|
||||
e = apply(e, updateParticipant(e, 'a', { name: 'A2' }));
|
||||
expect(e.participants.map(x => x.id)).toEqual(['b', 'a']);
|
||||
});
|
||||
|
||||
test('updateParticipant init change slots, preserves OTHER tie group order', () => {
|
||||
// Two tie groups: [a,b] init 10, [c,d] init 5. DM drags b before a.
|
||||
let e = enc([p('a', 10), p('b', 10), p('c', 5), p('d', 5)]);
|
||||
e = apply(e, reorderParticipants(e, 'b', 'a')); // [b,a,c,d]
|
||||
// Edit c's initiative to 10 — slots into top group. [c] order within its
|
||||
// own old group irrelevant (it leaves that group). But [b,a] tie MUST stay.
|
||||
e = apply(e, updateParticipant(e, 'c', { initiative: 10 }));
|
||||
const topTwo = e.participants.filter(x => x.initiative === 10).map(x => x.id);
|
||||
// b before a preserved. c joins the 10-group (exact slot less critical
|
||||
// than b,a order surviving).
|
||||
expect(topTwo.indexOf('b')).toBeLessThan(topTwo.indexOf('a'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user