Files
ttrpg-initiative-tracker/shared/tests/turn.round-rotation.test.js
T
david raistrick 5d3a0607ef 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).
2026-07-01 16:00:00 -04:00

176 lines
6.6 KiB
JavaScript

// Regression test: full round must rotate through ALL active participants exactly once.
// Audit of 100-round replay found 124 skips + 78 dupes (round 1 already missing Fighter
// before any coverage action). nextTurn has core bug, not just coverage-path issue.
//
// This test is RED until nextTurn fixed.
const shared = require('@ttrpg/shared');
const { startEncounter, nextTurn, makeParticipant } = shared;
function p(id, initiative, extra = {}) {
return makeParticipant({
id, name: id, type: 'monster',
initiative, maxHp: 20, currentHp: 20,
...extra,
});
}
function enc(ps) {
return {
name: 'T', participants: ps,
isStarted: false, isPaused: false,
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
};
}
describe('round rotation integrity', () => {
test('3 participants: one full round visits each exactly once', () => {
const ps = [p('a', 20), p('b', 15), p('c', 10)];
let e = enc(ps);
e = { ...e, ...startEncounter(e).patch };
const startOrder = e.turnOrderIds.slice();
const visited = [e.currentTurnParticipantId];
// advance (len-1) turns: visits remaining participants, round NOT yet wrapped.
for (let i = 0; i < startOrder.length - 1; i++) {
e = { ...e, ...nextTurn(e).patch };
visited.push(e.currentTurnParticipantId);
}
expect(e.round).toBe(1); // still round 1
const uniq = new Set(visited);
expect(uniq.size).toBe(startOrder.length); // each exactly once
expect(visited.length).toBe(startOrder.length);
});
test('8 participants (replay shape): one full round visits each exactly once', () => {
const ps = [
p('Goblin1', 12), p('Wolf', 13), p('Merchant', 8), p('OrcBoss', 11),
p('Goblin2', 12), p('Fighter', 14), p('Rogue', 15), p('Cleric', 10),
];
let e = enc(ps);
e = { ...e, ...startEncounter(e).patch };
const startOrder = e.turnOrderIds.slice();
const visited = [e.currentTurnParticipantId];
for (let i = 0; i < startOrder.length - 1; i++) {
e = { ...e, ...nextTurn(e).patch };
visited.push(e.currentTurnParticipantId);
}
expect(e.round).toBe(1);
const uniq = new Set(visited);
expect(uniq.size).toBe(startOrder.length);
expect(visited.length).toBe(startOrder.length);
});
test('multiple rounds: each round visits each participant exactly once', () => {
const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)];
let e = enc(ps);
e = { ...e, ...startEncounter(e).patch };
const startOrder = e.turnOrderIds.slice();
const expectedRound = e.round;
// capture exactly one full round (current + len-1 advances), no wrap yet.
const visited = [e.currentTurnParticipantId];
for (let i = 0; i < startOrder.length - 1; i++) {
e = { ...e, ...nextTurn(e).patch };
visited.push(e.currentTurnParticipantId);
}
const uniq = new Set(visited);
expect(uniq.size).toBe(startOrder.length);
expect(e.round).toBe(expectedRound);
});
});
describe('round rotation with mid-round state changes', () => {
const { toggleParticipantActive, addParticipant, removeParticipant, reorderParticipants, applyHpChange } = shared;
test('toggle a participant inactive mid-round, others still each visited once', () => {
const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)];
let e = enc(ps);
e = { ...e, ...startEncounter(e).patch };
const startOrder = e.turnOrderIds.slice();
const visited = [e.currentTurnParticipantId];
e = { ...e, ...nextTurn(e).patch }; visited.push(e.currentTurnParticipantId);
// now mark 'a' inactive (already took its turn)
e = { ...e, ...toggleParticipantActive(e, 'a').patch };
e = { ...e, ...nextTurn(e).patch }; visited.push(e.currentTurnParticipantId);
e = { ...e, ...nextTurn(e).patch }; visited.push(e.currentTurnParticipantId);
// round should wrap, but 'a' inactive so only b,c,d visited
const visitedActive = visited.filter(id => id !== 'a');
const uniq = new Set(visitedActive);
expect(uniq.size).toBe(startOrder.length - 1); // b,c,d each once
});
test('reactivate inactive participant mid-round, it gets a turn this round', () => {
const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)];
let e = enc(ps);
// start with 'c' inactive
e.participants = e.participants.map(p => p.id === 'c' ? { ...p, isActive: false } : p);
e = { ...e, ...startEncounter(e).patch };
// 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 }; // b
e = { ...e, ...toggleParticipantActive(e, 'c').patch };
// continue rotation - c should now be reachable
const visited = [e.currentTurnParticipantId];
for (let i = 0; i < e.turnOrderIds.length; i++) {
e = { ...e, ...nextTurn(e).patch };
visited.push(e.currentTurnParticipantId);
}
expect(visited).toContain('c');
});
test('addParticipant mid-round: new participant gets turn this round or next', () => {
const ps = [p('a', 20), p('b', 15), p('c', 10)];
let e = enc(ps);
e = { ...e, ...startEncounter(e).patch };
const startOrder = e.turnOrderIds.slice();
e = { ...e, ...nextTurn(e).patch }; // advance one
// add new participant
const newP = p('x', 25);
e = { ...e, ...addParticipant(e, newP).patch };
// finish round - original 3 should still each get exactly one turn
const visited = [startOrder[0], e.currentTurnParticipantId];
while (e.round === 1) {
const r = nextTurn(e);
e = { ...e, ...r.patch };
visited.push(e.currentTurnParticipantId);
if (visited.length > 20) break; // safety
}
const originals = visited.filter(id => ['a','b','c'].includes(id));
const uniq = new Set(originals);
expect(uniq.size).toBe(3);
});
test('reorderParticipants mid-round keeps rotation valid', () => {
const ps = [p('a', 20), p('b', 15), p('c', 15), p('d', 5)]; // b,c same init (15)
let e = enc(ps);
e = { ...e, ...startEncounter(e).patch };
const startOrder = e.turnOrderIds.slice();
e = { ...e, ...nextTurn(e).patch };
// reorder: swap b,c (same initiative)
e = { ...e, ...reorderParticipants(e, 'b', 'c').patch };
const visited = [startOrder[0], e.currentTurnParticipantId];
for (let i = 0; i < startOrder.length; i++) {
e = { ...e, ...nextTurn(e).patch };
visited.push(e.currentTurnParticipantId);
}
const uniq = new Set(visited);
expect(uniq.size).toBeGreaterThanOrEqual(startOrder.length);
});
});