// INVARIANT test: ONE list. turnOrderIds === participants.map(id) always. // No re-sort after startEncounter. nextTurn follows list order, skipping inactive. // Drag (reorder) overrides initiative — cross-init drag allowed + reflected. // Display === rotation by construction (same array). // // RED now: current code has two lists (sort on display, frozen turnOrderIds), // reorder throws on cross-init. Refactor (1-list model) greens these. 'use strict'; const shared = require('@ttrpg/shared'); const { makeParticipant, startEncounter, nextTurn, addParticipant, removeParticipant, toggleParticipantActive, togglePause, applyHpChange, reorderParticipants, endEncounter, } = 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; // walk one full rotation from current, collect active ids in list order function walkRotation(e) { if (!e.isStarted || e.isPaused || !e.currentTurnParticipantId) return []; let cur = e; const start = cur.currentTurnParticipantId; const seen = []; for (let i = 0; i < (cur.turnOrderIds || []).length + 1; i++) { const curP = (cur.participants || []).find(p => p.id === cur.currentTurnParticipantId); if (curP && curP.isActive) seen.push(cur.currentTurnParticipantId); const nxt = nextTurn(cur); cur = apply(cur, nxt); if (cur.currentTurnParticipantId === start) break; } return seen; } describe('1-list model: turnOrderIds === participants.map(id), no re-sort', () => { test('startEncounter: list = sorted-active participants order', () => { let e = enc([p('a',3),p('b',10),p('c',5)]); e = apply(e, startEncounter(e)); expect(e.turnOrderIds).toEqual(['b','c','a']); // 10,5,3 expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id)); }); test('startEncounter: inactive stays in list slot (skipped by nextTurn)', () => { let e = enc([p('a',10),p('b',5,{isActive:false}),p('c',3)]); e = apply(e, startEncounter(e)); // 1-list: inactive b stays in slot, nextTurn skips it expect(e.turnOrderIds).toEqual(['a','b','c']); expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id)); expect(e.currentTurnParticipantId).toBe('a'); // b inactive, skipped on start }); test('addParticipant mid-encounter: inserted by init, list synced', () => { let e = enc([p('a',10),p('c',3)]); e = apply(e, startEncounter(e)); // [a,c] e = apply(e, addParticipant(e, p('b',7))); // insert between a,c expect(e.turnOrderIds).toEqual(['a','b','c']); expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id)); }); test('addParticipant: list === participants.map(id) after add', () => { let e = enc([p('a',10)]); e = apply(e, startEncounter(e)); e = apply(e, addParticipant(e, p('b',5))); expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id)); }); test('removeParticipant: list synced, order preserved', () => { let e = enc([p('a',10),p('b',7),p('c',3)]); e = apply(e, startEncounter(e)); e = apply(e, removeParticipant(e, 'b')); expect(e.turnOrderIds).toEqual(['a','c']); expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id)); }); test('reorder cross-init: allowed, list + rotation reflect new order', () => { let e = enc([p('a',10),p('b',7),p('c',3)]); e = apply(e, startEncounter(e)); // [a,b,c] e = apply(e, reorderParticipants(e, 'c', 'a')); // drag c before a expect(e.turnOrderIds).toEqual(['c','a','b']); expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id)); }); test('reorder: rotation follows new list order', () => { let e = enc([p('a',10),p('b',7),p('c',3)]); e = apply(e, startEncounter(e)); // [a,b,c], cur=a e = apply(e, reorderParticipants(e, 'b', 'a')); // [b,a,c], cur still a const rot = walkRotation(e); // start a, next c (wrap), next b, back a expect(rot).toEqual(['a','c','b']); }); test('toggle inactive: list unchanged (stays in rotation slot)', () => { let e = enc([p('a',10),p('b',7),p('c',3)]); e = apply(e, startEncounter(e)); // [a,b,c] e = apply(e, toggleParticipantActive(e, 'b')); // b off expect(e.turnOrderIds).toEqual(['a','b','c']); // b stays in slot }); test('toggle inactive: nextTurn skips b, visits a→c', () => { let e = enc([p('a',10),p('b',7),p('c',3)]); e = apply(e, startEncounter(e)); // cur=a e = apply(e, toggleParticipantActive(e, 'b')); // b inactive e = apply(e, nextTurn(e)); // skip b → c expect(e.currentTurnParticipantId).toBe('c'); }); test('hp death + revive: list unchanged', () => { let e = enc([p('a',10),p('b',7),p('c',3)]); e = apply(e, startEncounter(e)); e = apply(e, applyHpChange(e, 'b', 'damage', 100)); // b dies expect(e.turnOrderIds).toEqual(['a','b','c']); e = apply(e, applyHpChange(e, 'b', 'heal', 50)); // b revive expect(e.turnOrderIds).toEqual(['a','b','c']); expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id)); }); });