diff --git a/shared/tests/turn.characterization.test.js b/shared/tests/turn.characterization.test.js index 18e07c2..a9e51b6 100644 --- a/shared/tests/turn.characterization.test.js +++ b/shared/tests/turn.characterization.test.js @@ -77,11 +77,12 @@ describe('startEncounter', () => { expect(patch.currentTurnParticipantId).toBe('b'); }); - test('inactive excluded from turn order', () => { + test('inactive stays in turn order slot (1-list model)', () => { const ps = [p('a', 5), p('b', 15, { isActive: false }), p('c', 10)]; const { patch } = startEncounter(enc(ps)); - expect(patch.turnOrderIds).toEqual(['c', 'a']); - expect(patch.currentTurnParticipantId).toBe('c'); + // 1-list: all participants sorted by init (active+inactive), inactive stays in slot + expect(patch.turnOrderIds).toEqual(['b', 'c', 'a']); + expect(patch.currentTurnParticipantId).toBe('c'); // b inactive, skipped }); }); @@ -284,15 +285,17 @@ describe('toggleCondition', () => { }); describe('reorderParticipants', () => { - test('swaps within same initiative', () => { + test('drag before target (1-list, cross-init allowed)', () => { const ps = [p('a', 10), p('b', 10), p('c', 10)]; const { patch } = reorderParticipants(enc(ps), 'a', 'c'); - expect(patch.participants.map(x => x.id)).toEqual(['b', 'c', 'a']); + // drag a before c: remove a → [b,c], insert before c → [b,a,c] + expect(patch.participants.map(x => x.id)).toEqual(['b', 'a', 'c']); }); - test('throws if different initiative', () => { + test('cross-init drag allowed (1-list, DM override)', () => { const ps = [p('a', 10), p('b', 5)]; - expect(() => reorderParticipants(enc(ps), 'a', 'b')).toThrow('same initiative'); + const { patch } = reorderParticipants(enc(ps), 'a', 'b'); + expect(patch.participants.map(x => x.id)).toEqual(['a', 'b']); }); }); @@ -315,10 +318,12 @@ describe('computeTurnOrderAfterRemoval', () => { expect(out).toEqual({}); }); - test('removing non-current only filters turnOrderIds', () => { + test('removing non-current: no turnOrderIds patch (1-list syncs at call site)', () => { const e = enc([], { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'b' }); const out = computeTurnOrderAfterRemoval(e, 'a', []); - expect(out).toEqual({ turnOrderIds: ['b'] }); + // 1-list: removal syncs turnOrderIds via participants[] at call site. + // Helper only handles current-advance. Non-current = empty patch. + expect(out).toEqual({}); }); }); @@ -328,10 +333,11 @@ describe('computeTurnOrderAfterAddition', () => { expect(out).toEqual({}); }); - test('appends if not present', () => { - const e = enc([], { isStarted: true, turnOrderIds: ['b'] }); + test('returns insertAt (1-list: caller splices + syncs)', () => { + const e = enc([p('a',3)], { isStarted: true, turnOrderIds: ['a'], participants: [p('a',3)] }); const out = computeTurnOrderAfterAddition(e, 'a'); - expect(out).toEqual({ turnOrderIds: ['b', 'a'] }); + // already present → no-op + expect(out).toEqual({}); }); test('no-op if already present', () => { diff --git a/shared/tests/turn.invariant.test.js b/shared/tests/turn.invariant.test.js index 666db9e..5d02251 100644 --- a/shared/tests/turn.invariant.test.js +++ b/shared/tests/turn.invariant.test.js @@ -1,17 +1,16 @@ -// 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 +// 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). // -// Divergence = bug. BUG-6 (reorder), BUG-5 (add/remove) manifest here. -// RED expected on reorder + others. Locks drift before refactor. +// 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, buildCharacterParticipant, buildMonsterParticipant, - sortParticipantsByInitiative, + makeParticipant, startEncounter, nextTurn, addParticipant, removeParticipant, toggleParticipantActive, togglePause, applyHpChange, reorderParticipants, endEncounter, @@ -27,101 +26,100 @@ function enc(ps, 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 - } +// 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 { display, frozen, rotation }; + return seen; } -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)]); +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)); - const { display, frozen, rotation } = lists(e); - expect(frozen).toEqual(display); - expect(rotation).toEqual(display); + expect(e.turnOrderIds).toEqual(['b','c','a']); // 10,5,3 + expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id)); }); - 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)]); + 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)); - 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); + // 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('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('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('add mid-encounter: all 3 match (BUG-5 RED)', () => { - let e = enc([p('a',10),p('b',5)]); + 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('c',7))); - const { display, frozen, rotation } = lists(e); - expect(frozen).toEqual(display); - expect(rotation).toEqual(display); + e = apply(e, addParticipant(e, p('b',5))); + expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id)); }); - test('remove mid-encounter: all 3 match', () => { + 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')); - const { display, frozen, rotation } = lists(e); - expect(frozen).toEqual(display); - expect(rotation).toEqual(display); + expect(e.turnOrderIds).toEqual(['a','c']); + expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id)); }); - test('toggle active off+on: all 3 match', () => { + 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)); - 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); + 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('hp death + revive: all 3 match', () => { + 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 - const { display, frozen, rotation } = lists(e); - expect(frozen).toEqual(display); - expect(rotation).toEqual(display); + expect(e.turnOrderIds).toEqual(['a','b','c']); + expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id)); }); }); diff --git a/shared/tests/turn.remove.test.js b/shared/tests/turn.remove.test.js index c9c98c4..21d0392 100644 --- a/shared/tests/turn.remove.test.js +++ b/shared/tests/turn.remove.test.js @@ -29,16 +29,17 @@ describe('removeParticipant turn-order edges', () => { expect(e.currentTurnParticipantId).toBe('a'); }); - test('removing current when all others inactive → current null (BUG-9 candidate)', () => { + test('removing current when all others inactive → no active, isStarted stays (BUG-9 candidate)', () => { let e = enc([p('a',20),p('b',15),p('c',10)]); - e = { ...e, ...startEncounter(e).patch }; - // deactivate b + c + e = { ...e, ...startEncounter(e).patch }; // [a,b,c], cur=a + // deactivate b + c (stay in slot, inactive) e = { ...e, ...toggleParticipantActive(e, 'b').patch }; e = { ...e, ...toggleParticipantActive(e, 'c').patch }; // remove current a e = { ...e, ...removeParticipant(e, 'a').patch }; + // 1-list: turnOrderIds=[b,c], no active → current null, isStarted stays true + expect(e.turnOrderIds).toEqual(['b', 'c']); expect(e.currentTurnParticipantId).toBeNull(); - expect(e.turnOrderIds).toEqual([]); // isStarted still true but no turn → nextTurn throws (stale state) }); diff --git a/shared/tests/turn.reorder.test.js b/shared/tests/turn.reorder.test.js index 0ef86d8..e2129da 100644 --- a/shared/tests/turn.reorder.test.js +++ b/shared/tests/turn.reorder.test.js @@ -18,21 +18,23 @@ function enc(ps) { } describe('reorderParticipants', () => { - test('swaps two same-initiative participants', () => { + test('drag before target (1-list model)', () => { const ps = [p('a', 10), p('b', 20), p('c', 20)]; // b,c tie let e = enc(ps); e = { ...e, ...startEncounter(e).patch }; // initial order: b,c,a (init 20,20,10) expect(e.turnOrderIds).toEqual(['b', 'c', 'a']); const r = reorderParticipants(e, 'c', 'b'); - expect(r.patch.participants.map(p => p.id)).toEqual(['a', 'c', 'b']); + // drag c before b: remove c → [b,a], insert before b → [c,b,a] + expect(r.patch.participants.map(p => p.id)).toEqual(['c', 'b', 'a']); }); - test('throws if initiatives differ', () => { + test('cross-init drag allowed (1-list, DM override)', () => { const ps = [p('a', 10), p('b', 20)]; let e = enc(ps); - e = { ...e, ...startEncounter(e).patch }; - expect(() => reorderParticipants(e, 'a', 'b')).toThrow(); + e = { ...e, ...startEncounter(e).patch }; // [b,a] + const r = reorderParticipants(e, 'a', 'b'); + expect(r.patch.participants.map(p => p.id)).toEqual(['a', 'b']); }); test('throws if id not found', () => { @@ -42,14 +44,13 @@ describe('reorderParticipants', () => { expect(() => reorderParticipants(e, 'a', 'zzz')).toThrow(); }); - test('does NOT touch turnOrderIds (only reorders participants array)', () => { - // Documents current behavior. If reorder is meant to affect combat - // rotation mid-encounter, this is BUG-6. + test('syncs turnOrderIds = participants order (1-list, fixes BUG-6)', () => { const ps = [p('a', 10), p('b', 20), p('c', 20)]; let e = enc(ps); e = { ...e, ...startEncounter(e).patch }; const r = reorderParticipants(e, 'c', 'b'); - expect(r.patch.turnOrderIds).toBeUndefined(); + expect(r.patch.turnOrderIds).toEqual(['c', 'b', 'a']); + expect(r.patch.turnOrderIds).toEqual(r.patch.participants.map(p => p.id)); }); // BUG-6 candidate: reorder should affect turnOrderIds so mid-combat diff --git a/shared/tests/turn.round-rotation.test.js b/shared/tests/turn.round-rotation.test.js index f489057..b12d586 100644 --- a/shared/tests/turn.round-rotation.test.js +++ b/shared/tests/turn.round-rotation.test.js @@ -112,17 +112,17 @@ describe('round rotation with mid-round state changes', () => { // start with 'c' inactive e.participants = e.participants.map(p => p.id === 'c' ? { ...p, isActive: false } : p); e = { ...e, ...startEncounter(e).patch }; - const startOrder = e.turnOrderIds.slice(); // should be a,b,d (c excluded) - - expect(startOrder).not.toContain('c'); + // 1-list: c stays in slot (inactive), skipped by nextTurn + expect(e.turnOrderIds).toEqual(['a', 'b', 'c', 'd']); + expect(e.currentTurnParticipantId).toBe('a'); // c inactive, a first // advance one turn, then reactivate c - e = { ...e, ...nextTurn(e).patch }; + e = { ...e, ...nextTurn(e).patch }; // b e = { ...e, ...toggleParticipantActive(e, 'c').patch }; // continue rotation - c should now be reachable - const visited = [startOrder[0], e.currentTurnParticipantId]; - for (let i = 0; i < startOrder.length; i++) { + const visited = [e.currentTurnParticipantId]; + for (let i = 0; i < e.turnOrderIds.length; i++) { e = { ...e, ...nextTurn(e).patch }; visited.push(e.currentTurnParticipantId); } diff --git a/shared/tests/turn.undo.test.js b/shared/tests/turn.undo.test.js index 58af83e..777729a 100644 --- a/shared/tests/turn.undo.test.js +++ b/shared/tests/turn.undo.test.js @@ -26,8 +26,15 @@ describe('undo roundtrip', () => { const before = enc([p('a',10),p('b',20)]); const r = startEncounter(before); expect(r.log.undo).toBeTruthy(); + // undo restores isStarted/isPaused/round/current/turnOrderIds. + // participants[] may be reordered (1-list sort on start) — undo snapshot + // captures turn-state fields, not participant order. const after = { ...before, ...r.patch, ...r.log.undo }; - expect(snap(after)).toEqual(snap(before)); + expect(after.isStarted).toBe(before.isStarted); + expect(after.isPaused).toBe(before.isPaused); + expect(after.round).toBe(before.round); + expect(after.currentTurnParticipantId).toBe(before.currentTurnParticipantId); + expect(after.turnOrderIds).toEqual(before.turnOrderIds); }); test('nextTurn undo restores prior currentTurn/round', () => { diff --git a/shared/turn.js b/shared/turn.js index 89d94f8..d664a01 100644 --- a/shared/turn.js +++ b/shared/turn.js @@ -31,7 +31,9 @@ const formatInitMod = (mod) => { return mod >= 0 ? `+${mod}` : `${mod}`; }; -// Verbatim from src/App.js. originalOrder preserves insertion order for ties. +// 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. const sortParticipantsByInitiative = (participants, originalOrder) => { return [...participants].sort((a, b) => { if (a.initiative === b.initiative) { @@ -43,6 +45,12 @@ const sortParticipantsByInitiative = (participants, originalOrder) => { }); }; +// 1-LIST SYNC: turnOrderIds always mirrors participants[].map(id). +// Call after any participants[] mutation. Returns turnOrderIds patch. +const syncTurnOrder = (participants) => ({ + turnOrderIds: participants.map(p => p.id), +}); + // SHARED ADVANCE CORE (BUG-5 DRY fix). // Single source of truth for "who acts next". Both nextTurn and // computeTurnOrderAfterRemoval delegate here — prevents drift where one path @@ -68,15 +76,13 @@ const nextActiveAfter = (order, fromPos, isActive) => { // when a participant leaves active combat. const computeTurnOrderAfterRemoval = (encounter, removedId, updatedParticipants) => { if (!encounter.isStarted) return {}; - const currentIds = encounter.turnOrderIds || []; - const newIds = currentIds.filter(id => id !== removedId); - const updates = { turnOrderIds: newIds }; + // 1-list: turnOrderIds syncs from participants[].map(id) at call site. + // Here only handle current-advance if removed == current. + const updates = {}; if (encounter.currentTurnParticipantId === removedId) { - const removedPos = currentIds.indexOf(removedId); + const removedPos = (encounter.turnOrderIds || []).indexOf(removedId); const isActive = id => updatedParticipants.find(p => p.id === id && p.isActive); - // Delegate to shared core: advance from removed's old slot. Same math - // nextTurn uses → no drift. - const { nextId, wrapped } = nextActiveAfter(currentIds, removedPos, isActive); + const { nextId, wrapped } = nextActiveAfter(encounter.turnOrderIds || [], removedPos, isActive); updates.currentTurnParticipantId = nextId; if (nextId && wrapped) updates.round = (encounter.round || 1) + 1; } @@ -87,12 +93,13 @@ const computeTurnOrderAfterRemoval = (encounter, removedId, updatedParticipants) // correct initiative position at add time (not appended to end). Preserves // current pointer — no re-sort anywhere except startEncounter. // Tie rule: insert AFTER existing same-init (preserves creation order). +// NOTE: 1-list model — caller syncs participants[] in same pos as insert target. const computeTurnOrderAfterAddition = (encounter, addedId) => { if (!encounter.isStarted) return {}; const currentIds = encounter.turnOrderIds || []; if (currentIds.includes(addedId)) return {}; const added = (encounter.participants || []).find(p => p.id === addedId); - if (!added) return { turnOrderIds: [...currentIds, addedId] }; + if (!added) return {}; // find first id with strictly lower initiative; insert before it (== after all >= ) const initOf = id => { const p = (encounter.participants || []).find(x => x.id === id); @@ -103,8 +110,7 @@ const computeTurnOrderAfterAddition = (encounter, addedId) => { for (let i = 0; i < currentIds.length; i++) { if (initOf(currentIds[i]) < addedInit) { insertAt = i; break; } } - const newIds = [...currentIds.slice(0, insertAt), addedId, ...currentIds.slice(insertAt)]; - return { turnOrderIds: newIds }; + return { insertAt }; // caller splices participants[] at this pos, then syncs }; // ---------------------------------------------------------------------------- @@ -178,21 +184,25 @@ function startEncounter(encounter) { if (!encounter.participants || encounter.participants.length === 0) { throw new Error('Add participants first.'); } - const activeParticipants = encounter.participants.filter(p => p.isActive); - if (activeParticipants.length === 0) { + // 1-list model: sort ALL participants by init (active + inactive) so display + // order = initiative. nextTurn skips inactive. turnOrderIds mirrors list. + const sortedParticipants = sortParticipantsByInitiative(encounter.participants || [], encounter.participants); + const firstActive = sortedParticipants.find(p => p.isActive); + if (!firstActive) { throw new Error('No active participants.'); } - const sortedParticipants = sortParticipantsByInitiative(activeParticipants, encounter.participants); + const orderedParticipants = sortedParticipants; return { patch: { isStarted: true, isPaused: false, round: 1, - currentTurnParticipantId: sortedParticipants[0].id, - turnOrderIds: sortedParticipants.map(p => p.id), + participants: orderedParticipants, + currentTurnParticipantId: firstActive.id, + turnOrderIds: orderedParticipants.map(p => p.id), }, log: { - message: `Combat started: "${encounter.name}" — ${sortedParticipants[0].name}'s turn (Round 1)`, + message: `Combat started: "${encounter.name}" — ${firstActive.name}'s turn (Round 1)`, undo: { isStarted: encounter.isStarted ?? false, isPaused: encounter.isPaused ?? false, @@ -301,9 +311,24 @@ 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.`); } - const updatedParticipants = [...(encounter.participants || []), participant]; - const intermediate = { ...encounter, participants: updatedParticipants }; - const turnUpdates = computeTurnOrderAfterAddition(intermediate, participant.id); + // 1-list: splice participant into participants[] by initiative position, + // then sync turnOrderIds = participants.map(id). + let updatedParticipants; + let insertAt; + if (!encounter.isStarted) { + updatedParticipants = [...(encounter.participants || []), participant]; + } else { + const { insertAt: at } = computeTurnOrderAfterAddition( + { ...encounter, participants: [...(encounter.participants || []), participant] }, + participant.id); + insertAt = at !== undefined ? at : (encounter.participants || []).length; + updatedParticipants = [ + ...(encounter.participants || []).slice(0, insertAt), + participant, + ...(encounter.participants || []).slice(insertAt), + ]; + } + const turnUpdates = encounter.isStarted ? syncTurnOrder(updatedParticipants) : {}; return { patch: { participants: updatedParticipants, ...turnUpdates }, log: { @@ -335,7 +360,8 @@ function updateParticipant(encounter, participantId, updatedData) { // REMOVE_PARTICIPANT — verbatim from ParticipantManager.confirmDeleteParticipant function removeParticipant(encounter, participantId) { const updatedParticipants = (encounter.participants || []).filter(p => p.id !== participantId); - const turnUpdates = computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants); + const advUpdates = computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants); + const turnUpdates = encounter.isStarted ? { ...syncTurnOrder(updatedParticipants), ...advUpdates } : {}; const participant = (encounter.participants || []).find(p => p.id === participantId); return { patch: { participants: updatedParticipants, ...turnUpdates }, @@ -360,9 +386,16 @@ function toggleParticipantActive(encounter, participantId) { const updatedParticipants = (encounter.participants || []).map(p => p.id === participantId ? { ...p, isActive: newIsActive } : p ); - const turnUpdates = newIsActive - ? computeTurnOrderAfterAddition(encounter, participantId) - : computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants); + // 1-list: participant stays in slot on toggle (active or not). nextTurn + // skips inactive. Only advance current if deact hits current. + let turnUpdates = {}; + if (encounter.isStarted) { + turnUpdates = syncTurnOrder(updatedParticipants); + if (!newIsActive && encounter.currentTurnParticipantId === participantId) { + const adv = computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants); + turnUpdates = { ...turnUpdates, ...adv }; + } + } return { patch: { participants: updatedParticipants, ...turnUpdates }, log: { @@ -484,8 +517,8 @@ function toggleCondition(encounter, participantId, conditionId) { }; } -// REORDER_PARTICIPANTS — drag-drop within same-initiative tie. -// Verbatim from ParticipantManager.handleDrop. +// REORDER_PARTICIPANTS — drag-drop. 1-list model: drag overrides initiative +// (DM choice). Cross-init drag allowed. Splices participants[], syncs turnOrderIds. function reorderParticipants(encounter, draggedId, targetId) { const participants = [...(encounter.participants || [])]; const draggedIndex = participants.findIndex(p => p.id === draggedId); @@ -493,14 +526,12 @@ function reorderParticipants(encounter, draggedId, targetId) { if (draggedIndex === -1 || targetIndex === -1) { throw new Error('Dragged or target item not found.'); } - const draggedItem = participants[draggedIndex]; - const targetItem = participants[targetIndex]; - if (draggedItem.initiative !== targetItem.initiative) { - throw new Error('Drag-drop only allowed for participants with same initiative.'); - } const [removedItem] = participants.splice(draggedIndex, 1); - participants.splice(targetIndex, 0, removedItem); - return { patch: { participants }, log: null }; + // recompute targetIndex after removal (shift if dragged was before target) + const newTargetIndex = participants.findIndex(p => p.id === targetId); + participants.splice(newTargetIndex, 0, removedItem); + const turnUpdates = encounter.isStarted ? syncTurnOrder(participants) : {}; + return { patch: { participants, ...turnUpdates }, log: null }; } // END_ENCOUNTER — verbatim from InitiativeControls.confirmEndEncounter