// INVARIANT test: three order lists must always match. // 1. display order = sortParticipantsByInitiative(participants).map(id) // 2. turnOrderIds = frozen rotation array // 3. nextTurn order = walking active rotation via repeated nextTurn // // Divergence = bug. BUG-6 (reorder), BUG-5 (add/remove) manifest here. // RED expected on reorder + others. Locks drift before refactor. 'use strict'; const shared = require('@ttrpg/shared'); const { makeParticipant, buildCharacterParticipant, buildMonsterParticipant, sortParticipantsByInitiative, 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; // snapshot the 3 lists function lists(e) { const display = sortParticipantsByInitiative(e.participants, e.participants).map(x => x.id); const frozen = [...(e.turnOrderIds || [])]; // walk full rotation: from current, nextTurn until back to start, collect ids in order const rotation = []; if (e.isStarted && !e.isPaused && e.currentTurnParticipantId) { let cur = e; const start = cur.currentTurnParticipantId; const seen = []; for (let i = 0; i < (cur.turnOrderIds || []).length + 1; i++) { seen.push(cur.currentTurnParticipantId); const nxt = nextTurn(cur); cur = apply(cur, nxt); if (cur.currentTurnParticipantId === start) break; } // rotation = [start, next1, ...] cyclic. Normalize to start at frozen[0] // so raw-array compare matches (same cycle, canonical start). const head = frozen[0]; const offset = seen.indexOf(head); if (offset >= 0) { rotation.push(...seen.slice(offset), ...seen.slice(0, offset)); } else { rotation.push(...seen); // head not in rotation (inactive?) — leave as-is } } return { display, frozen, rotation }; } describe('3-list invariant: display === turnOrderIds === nextTurn rotation', () => { test('startEncounter: all three match', () => { let e = enc([p('a',10),p('b',7),p('c',3)]); e = apply(e, startEncounter(e)); const { display, frozen, rotation } = lists(e); expect(frozen).toEqual(display); expect(rotation).toEqual(display); }); test('tie: drag order in participants[] preserved across all 3', () => { // a,b both init=10. participants[] order [a,b] = display tiebreak. let e = enc([p('a',10),p('b',10),p('c',3)]); e = apply(e, startEncounter(e)); const { display, frozen, rotation } = lists(e); expect(display.indexOf('a')).toBeLessThan(display.indexOf('b')); // a before b expect(frozen).toEqual(display); expect(rotation).toEqual(display); }); test('reorder via drag: all 3 reflect new order (BUG-6 RED)', () => { // a,b,c init 10,10,3. drag b before a. let e = enc([p('a',10),p('b',10),p('c',3)]); e = apply(e, startEncounter(e)); e = apply(e, reorderParticipants(e, 'b', 'a')); // b dragged before a const { display, frozen, rotation } = lists(e); expect(display.indexOf('b')).toBeLessThan(display.indexOf('a')); expect(frozen).toEqual(display); expect(rotation).toEqual(display); }); test('add mid-encounter: all 3 match (BUG-5 RED)', () => { let e = enc([p('a',10),p('b',5)]); e = apply(e, startEncounter(e)); e = apply(e, addParticipant(e, p('c',7))); const { display, frozen, rotation } = lists(e); expect(frozen).toEqual(display); expect(rotation).toEqual(display); }); test('remove mid-encounter: all 3 match', () => { let e = enc([p('a',10),p('b',7),p('c',3)]); e = apply(e, startEncounter(e)); e = apply(e, removeParticipant(e, 'b')); const { display, frozen, rotation } = lists(e); expect(frozen).toEqual(display); expect(rotation).toEqual(display); }); test('toggle active off+on: all 3 match', () => { let e = enc([p('a',10),p('b',7),p('c',3)]); e = apply(e, startEncounter(e)); e = apply(e, nextTurn(e)); // b current e = apply(e, toggleParticipantActive(e, 'a')); // a off e = apply(e, toggleParticipantActive(e, 'a')); // a on const { display, frozen, rotation } = lists(e); expect(frozen).toEqual(display); expect(rotation).toEqual(display); }); test('hp death + revive: all 3 match', () => { 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 e = apply(e, applyHpChange(e, 'b', 'heal', 50)); // b revive const { display, frozen, rotation } = lists(e); expect(frozen).toEqual(display); expect(rotation).toEqual(display); }); });