Files
ttrpg-initiative-tracker/shared/tests/turn.round-rotation.test.js
T

176 lines
6.5 KiB
JavaScript
Raw Normal View History

// 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 };
const startOrder = e.turnOrderIds.slice(); // should be a,b,d (c excluded)
expect(startOrder).not.toContain('c');
// advance one turn, then reactivate c
e = { ...e, ...nextTurn(e).patch };
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++) {
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);
});
});