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:
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user