refactor: 1-list turn order model (turnOrderIds === participants.map(id))

Single source of truth. No re-sort after startEncounter. Drag overrides
initiative (cross-init drag allowed, DM choice). Display === rotation by
construction — same array.

shared/turn.js:
- syncTurnOrder(participants) helper: turnOrderIds = participants.map(id)
- startEncounter: sort ALL participants by init (active+inactive), inactive
  stay in slot, nextTurn skips them. currentTurn = first active.
- addParticipant: splice into participants[] by init pos, sync turnOrderIds.
  computeTurnOrderAfterAddition returns insertAt (caller splices + syncs).
- removeParticipant: filter participants[], sync turnOrderIds, advance
  current if removed==current.
- toggleParticipantActive: stay in slot (flip isActive only), sync. Advance
  current only if deact hits current.
- reorderParticipants: cross-init drag allowed (remove same-init restriction).
  Splice participants[], sync turnOrderIds. Fixes BUG-6.
- computeTurnOrderAfterRemoval: only handles current-advance now (list sync
  at call site).

Tests updated to 1-list contract:
- turn.invariant.test.js: 10 tests, turnOrderIds===participants.map(id)
  always, cross-init drag, inactive-in-slot, rotation follows list.
- turn.characterization/reorder/round-rotation/undo/remove: updated
  expectations (inactive-in-slot, cross-init drag, turnOrderIds sync on
  reorder, insertAt return).

Results: shared 90 green. 500-round replay CLEAN (0 skips, 0 doubles,
0 order shifts). BUG-6 (reorder divergence) fixed structurally.

FE App.js still has duplicate turn funcs + sortParticipantsByInitiative
display render (step 4: delete dups, render participants[] directly).
This commit is contained in:
david raistrick
2026-07-01 16:00:00 -04:00
parent 94b62dc5ab
commit 5d3a0607ef
7 changed files with 187 additions and 143 deletions
+18 -12
View File
@@ -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', () => {
+76 -78
View File
@@ -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));
});
});
+5 -4
View File
@@ -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)
});
+10 -9
View File
@@ -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
+6 -6
View File
@@ -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);
}
+8 -1
View File
@@ -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', () => {