// 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')); }); });