Rework backend #1
@@ -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', () => {
|
||||
|
||||
@@ -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) {
|
||||
// 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++) {
|
||||
seen.push(cur.currentTurnParticipantId);
|
||||
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;
|
||||
}
|
||||
// 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 };
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
+64
-33
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user