tests: consolidate into tests/ dirs, fix import paths
Move all test files out of source dirs into per-workspace tests/: - shared/tests/ (3 unit test files) - server/tests/ (1 integration test) - src/tests/ (8 characterization + scenario tests + testHelpers) Fix all relative import paths (App, storage, __mocks__, testHelpers). Fix jest.config testMatch globs in shared/ and server/ (rootDir + <rootDir>/tests pattern). Delete scripts/repro-pause-bug.js (debug scratch, superseded by turn.pause-add.test.js). Keep scripts/replay-combat.js + scripts/audit-rotation.js as manual demo/exploratory tools (NOT unit tests, not deterministic). No logic changes. All green: shared 49 + 1 validated RED, server 23, FE 62. Scenario test unchanged (240s timeout, pre-existing slow).
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
// Characterization tests for shared/turn.js.
|
||||
// Lock CURRENT behavior (bugs included). M3 will extend, M4 will fix.
|
||||
// These tests assert what the code does NOW, not what it SHOULD do.
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const {
|
||||
sortParticipantsByInitiative,
|
||||
computeTurnOrderAfterRemoval,
|
||||
computeTurnOrderAfterAddition,
|
||||
startEncounter,
|
||||
nextTurn,
|
||||
togglePause,
|
||||
addParticipant,
|
||||
removeParticipant,
|
||||
toggleParticipantActive,
|
||||
applyHpChange,
|
||||
deathSave,
|
||||
toggleCondition,
|
||||
reorderParticipants,
|
||||
endEncounter,
|
||||
makeParticipant,
|
||||
} = shared;
|
||||
|
||||
// Helper: minimal encounter with given participants.
|
||||
function enc(participants = [], extra = {}) {
|
||||
return {
|
||||
name: 'Test Encounter',
|
||||
participants,
|
||||
isStarted: false,
|
||||
isPaused: false,
|
||||
round: 0,
|
||||
currentTurnParticipantId: null,
|
||||
turnOrderIds: [],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function p(id, initiative, extra = {}) {
|
||||
return makeParticipant({
|
||||
id, name: id, type: 'monster',
|
||||
initiative, maxHp: 20, currentHp: 20,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
describe('sortParticipantsByInitiative', () => {
|
||||
test('higher initiative first', () => {
|
||||
const ps = [p('a', 5), p('b', 15), p('c', 10)];
|
||||
const sorted = sortParticipantsByInitiative(ps, ps);
|
||||
expect(sorted.map(x => x.id)).toEqual(['b', 'c', 'a']);
|
||||
});
|
||||
|
||||
test('ties broken by original order', () => {
|
||||
const ps = [p('a', 10), p('b', 10), p('c', 10)];
|
||||
const sorted = sortParticipantsByInitiative(ps, ps);
|
||||
expect(sorted.map(x => x.id)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startEncounter', () => {
|
||||
test('throws if no participants', () => {
|
||||
expect(() => startEncounter(enc([]))).toThrow('participants');
|
||||
});
|
||||
|
||||
test('throws if no active participants', () => {
|
||||
const e = enc([p('a', 10, { isActive: false })]);
|
||||
expect(() => startEncounter(e)).toThrow('active');
|
||||
});
|
||||
|
||||
test('sets round 1, turn order sorted, current = highest init', () => {
|
||||
const ps = [p('a', 5), p('b', 15), p('c', 10)];
|
||||
const e = enc(ps);
|
||||
const { patch } = startEncounter(e);
|
||||
expect(patch.isStarted).toBe(true);
|
||||
expect(patch.round).toBe(1);
|
||||
expect(patch.turnOrderIds).toEqual(['b', 'c', 'a']);
|
||||
expect(patch.currentTurnParticipantId).toBe('b');
|
||||
});
|
||||
|
||||
test('inactive excluded from turn order', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextTurn', () => {
|
||||
test('throws if not started', () => {
|
||||
expect(() => nextTurn(enc([p('a', 10)], { isStarted: false }))).toThrow();
|
||||
});
|
||||
|
||||
test('throws if paused', () => {
|
||||
expect(() => nextTurn(enc([p('a', 10)], { isStarted: true, isPaused: true, currentTurnParticipantId: 'a', turnOrderIds: ['a'] }))).toThrow();
|
||||
});
|
||||
|
||||
test('advances to next in order, no round bump', () => {
|
||||
const ps = [p('a', 5), p('b', 15), p('c', 10)];
|
||||
const e = enc(ps, {
|
||||
isStarted: true,
|
||||
round: 1,
|
||||
currentTurnParticipantId: 'b',
|
||||
turnOrderIds: ['b', 'c', 'a'],
|
||||
});
|
||||
const { patch } = nextTurn(e);
|
||||
expect(patch.currentTurnParticipantId).toBe('c');
|
||||
expect(patch.round).toBe(1);
|
||||
});
|
||||
|
||||
test('wraps round when last in order', () => {
|
||||
const ps = [p('a', 5), p('b', 15), p('c', 10)];
|
||||
const e = enc(ps, {
|
||||
isStarted: true,
|
||||
round: 1,
|
||||
currentTurnParticipantId: 'a',
|
||||
turnOrderIds: ['b', 'c', 'a'],
|
||||
});
|
||||
const { patch } = nextTurn(e);
|
||||
expect(patch.currentTurnParticipantId).toBe('b');
|
||||
expect(patch.round).toBe(2);
|
||||
});
|
||||
|
||||
test('ends encounter if no active participants', () => {
|
||||
const ps = [p('a', 10, { isActive: false })];
|
||||
const e = enc(ps, {
|
||||
isStarted: true,
|
||||
round: 1,
|
||||
currentTurnParticipantId: 'a',
|
||||
turnOrderIds: ['a'],
|
||||
});
|
||||
const { patch } = nextTurn(e);
|
||||
expect(patch.isStarted).toBe(false);
|
||||
expect(patch.currentTurnParticipantId).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('togglePause', () => {
|
||||
test('pauses started encounter', () => {
|
||||
const e = enc([p('a', 10)], { isStarted: true, isPaused: false });
|
||||
const { patch } = togglePause(e);
|
||||
expect(patch.isPaused).toBe(true);
|
||||
});
|
||||
|
||||
test('resume recomputes turn order from active', () => {
|
||||
const ps = [p('a', 5), p('b', 15)];
|
||||
const e = enc(ps, { isStarted: true, isPaused: true, turnOrderIds: ['a', 'b'] });
|
||||
const { patch } = togglePause(e);
|
||||
expect(patch.isPaused).toBe(false);
|
||||
expect(patch.turnOrderIds).toEqual(['b', 'a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeParticipant', () => {
|
||||
test('removes from participants array', () => {
|
||||
const ps = [p('a', 10), p('b', 5)];
|
||||
const { patch } = removeParticipant(enc(ps), 'a');
|
||||
expect(patch.participants.map(x => x.id)).toEqual(['b']);
|
||||
});
|
||||
|
||||
test('not started: no turn order mutation', () => {
|
||||
const ps = [p('a', 10), p('b', 5)];
|
||||
const { patch } = removeParticipant(enc(ps), 'a');
|
||||
expect(patch.turnOrderIds).toBeUndefined();
|
||||
});
|
||||
|
||||
test('started: removes from turnOrderIds', () => {
|
||||
const ps = [p('a', 10), p('b', 5)];
|
||||
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'b' });
|
||||
const { patch } = removeParticipant(e, 'a');
|
||||
expect(patch.turnOrderIds).toEqual(['b']);
|
||||
});
|
||||
|
||||
test('started: removing current picks next active', () => {
|
||||
const ps = [p('a', 10), p('b', 5), p('c', 3)];
|
||||
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b', 'c'], currentTurnParticipantId: 'a' });
|
||||
const { patch } = removeParticipant(e, 'a');
|
||||
expect(patch.currentTurnParticipantId).toBe('b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleParticipantActive', () => {
|
||||
test('deactivates participant', () => {
|
||||
const ps = [p('a', 10, { isActive: true })];
|
||||
const { patch } = toggleParticipantActive(enc(ps), 'a');
|
||||
expect(patch.participants[0].isActive).toBe(false);
|
||||
});
|
||||
|
||||
test('started: deactivating current advances turn', () => {
|
||||
const ps = [p('a', 10), p('b', 5)];
|
||||
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' });
|
||||
const { patch } = toggleParticipantActive(e, 'a');
|
||||
expect(patch.currentTurnParticipantId).toBe('b');
|
||||
});
|
||||
|
||||
test('started: reactivating appends to turn order', () => {
|
||||
const ps = [p('a', 10, { isActive: false }), p('b', 5)];
|
||||
const e = enc(ps, { isStarted: true, turnOrderIds: ['b'], currentTurnParticipantId: 'b' });
|
||||
const { patch } = toggleParticipantActive(e, 'a');
|
||||
expect(patch.turnOrderIds).toEqual(['b', 'a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyHpChange', () => {
|
||||
test('damage reduces hp, clamps 0', () => {
|
||||
const ps = [p('a', 10, { currentHp: 15, maxHp: 20 })];
|
||||
const { patch } = applyHpChange(enc(ps), 'a', 'damage', 5);
|
||||
expect(patch.participants[0].currentHp).toBe(10);
|
||||
});
|
||||
|
||||
test('damage to 0 deactivates + removes from turn order', () => {
|
||||
const ps = [p('a', 10, { currentHp: 3 }), p('b', 5)];
|
||||
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' });
|
||||
const { patch } = applyHpChange(e, 'a', 'damage', 5);
|
||||
expect(patch.participants[0].currentHp).toBe(0);
|
||||
expect(patch.participants[0].isActive).toBe(false);
|
||||
expect(patch.currentTurnParticipantId).toBe('b');
|
||||
});
|
||||
|
||||
test('heal above 0 revives + reactivates + resets death saves', () => {
|
||||
const ps = [p('a', 10, { currentHp: 0, isActive: false, deathSaves: 2 })];
|
||||
const { patch } = applyHpChange(enc(ps), 'a', 'heal', 5);
|
||||
expect(patch.participants[0].currentHp).toBe(5);
|
||||
expect(patch.participants[0].isActive).toBe(true);
|
||||
expect(patch.participants[0].deathSaves).toBe(0);
|
||||
});
|
||||
|
||||
test('heal clamps to maxHp', () => {
|
||||
const ps = [p('a', 10, { currentHp: 18, maxHp: 20 })];
|
||||
const { patch } = applyHpChange(enc(ps), 'a', 'heal', 10);
|
||||
expect(patch.participants[0].currentHp).toBe(20);
|
||||
});
|
||||
|
||||
test('zero amount = no-op', () => {
|
||||
const ps = [p('a', 10, { currentHp: 10 })];
|
||||
const { patch } = applyHpChange(enc(ps), 'a', 'damage', 0);
|
||||
expect(patch).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deathSave', () => {
|
||||
test('increments saves', () => {
|
||||
const ps = [p('a', 10, { currentHp: 0, deathSaves: 0 })];
|
||||
const { patch } = deathSave(enc(ps), 'a', 1);
|
||||
expect(patch.participants[0].deathSaves).toBe(1);
|
||||
});
|
||||
|
||||
test('clicking same save decrements (toggle)', () => {
|
||||
const ps = [p('a', 10, { currentHp: 0, deathSaves: 2 })];
|
||||
const { patch } = deathSave(enc(ps), 'a', 2);
|
||||
expect(patch.participants[0].deathSaves).toBe(1);
|
||||
});
|
||||
|
||||
test('third save sets isDying', () => {
|
||||
const ps = [p('a', 10, { currentHp: 0, deathSaves: 2 })];
|
||||
const result = deathSave(enc(ps), 'a', 3);
|
||||
expect(result.patch.participants[0].deathSaves).toBe(3);
|
||||
expect(result.patch.participants[0].isDying).toBe(true);
|
||||
expect(result.isDying).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleCondition', () => {
|
||||
test('adds condition', () => {
|
||||
const ps = [p('a', 10, { conditions: [] })];
|
||||
const { patch } = toggleCondition(enc(ps), 'a', 'poisoned');
|
||||
expect(patch.participants[0].conditions).toEqual(['poisoned']);
|
||||
});
|
||||
|
||||
test('removes condition', () => {
|
||||
const ps = [p('a', 10, { conditions: ['poisoned', 'blinded'] })];
|
||||
const { patch } = toggleCondition(enc(ps), 'a', 'poisoned');
|
||||
expect(patch.participants[0].conditions).toEqual(['blinded']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorderParticipants', () => {
|
||||
test('swaps within same initiative', () => {
|
||||
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']);
|
||||
});
|
||||
|
||||
test('throws if different initiative', () => {
|
||||
const ps = [p('a', 10), p('b', 5)];
|
||||
expect(() => reorderParticipants(enc(ps), 'a', 'b')).toThrow('same initiative');
|
||||
});
|
||||
});
|
||||
|
||||
describe('endEncounter', () => {
|
||||
test('resets all combat state', () => {
|
||||
const e = enc([p('a', 10)], {
|
||||
isStarted: true, round: 5, currentTurnParticipantId: 'a', turnOrderIds: ['a'],
|
||||
});
|
||||
const { patch } = endEncounter(e);
|
||||
expect(patch.isStarted).toBe(false);
|
||||
expect(patch.round).toBe(0);
|
||||
expect(patch.currentTurnParticipantId).toBe(null);
|
||||
expect(patch.turnOrderIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeTurnOrderAfterRemoval', () => {
|
||||
test('not started = empty', () => {
|
||||
const out = computeTurnOrderAfterRemoval(enc([]), 'a', []);
|
||||
expect(out).toEqual({});
|
||||
});
|
||||
|
||||
test('removing non-current only filters turnOrderIds', () => {
|
||||
const e = enc([], { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'b' });
|
||||
const out = computeTurnOrderAfterRemoval(e, 'a', []);
|
||||
expect(out).toEqual({ turnOrderIds: ['b'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeTurnOrderAfterAddition', () => {
|
||||
test('not started = empty', () => {
|
||||
const out = computeTurnOrderAfterAddition(enc([]), 'a');
|
||||
expect(out).toEqual({});
|
||||
});
|
||||
|
||||
test('appends if not present', () => {
|
||||
const e = enc([], { isStarted: true, turnOrderIds: ['b'] });
|
||||
const out = computeTurnOrderAfterAddition(e, 'a');
|
||||
expect(out).toEqual({ turnOrderIds: ['b', 'a'] });
|
||||
});
|
||||
|
||||
test('no-op if already present', () => {
|
||||
const e = enc([], { isStarted: true, turnOrderIds: ['a', 'b'] });
|
||||
const out = computeTurnOrderAfterAddition(e, 'a');
|
||||
expect(out).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('addParticipant', () => {
|
||||
test('appends participant', () => {
|
||||
const np = p('z', 7);
|
||||
const { patch } = addParticipant(enc([p('a', 10)]), np);
|
||||
expect(patch.participants.map(x => x.id)).toEqual(['a', 'z']);
|
||||
});
|
||||
|
||||
test('rejects duplicate id (skip-bug root cause)', () => {
|
||||
// Two participants with same id → togglePause resume rebuilds order with
|
||||
// dup id twice → nextTurn gets stuck repeating that id forever.
|
||||
// Audit found this in 100-round replay (addParticipant fired while paused
|
||||
// because nextTurn threw, loop spun, same totalTurns %10 → re-added).
|
||||
const existing = p('x', 5);
|
||||
const dup = makeParticipant({ id: 'x', name: 'x2', type: 'monster', initiative: 10, maxHp: 100, currentHp: 100 });
|
||||
expect(() => addParticipant(enc([p('a', 10), existing]), dup)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
// Characterization test: addParticipant + pause/resume corrupts turn rotation.
|
||||
// Audit found 56-77 violations/100 rounds starting round 20 in pure turn.js
|
||||
// simulation. Visible in live replay (round 10: 17 turns, 6 duped actors,
|
||||
// R-series stuck repeating forever).
|
||||
//
|
||||
// This test uses FRESH ids (crypto.randomUUID equivalent) — NOT the audit's
|
||||
// self-inflicted dup (loop spun while paused, re-added same `r${totalTurns}`).
|
||||
// Validates real bug reachable via normal UI flow (DM adds monster while paused,
|
||||
// resumes).
|
||||
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { startEncounter, nextTurn, togglePause, addParticipant, makeParticipant } = shared;
|
||||
|
||||
function p(id, initiative, extra = {}) {
|
||||
return makeParticipant({
|
||||
id, name: id, type: 'monster',
|
||||
initiative, maxHp: 100, currentHp: 100,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
function enc(ps) {
|
||||
return {
|
||||
name: 'T', participants: ps,
|
||||
isStarted: false, isPaused: false,
|
||||
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('addParticipant + pause/resume rotation corruption', () => {
|
||||
test('add fresh participant while paused, resume, rotation completes full cycle', () => {
|
||||
const ps = [p('a', 20), p('b', 15), p('c', 10)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
const baseOrder = e.turnOrderIds.slice(); // [a,b,c]
|
||||
|
||||
e = { ...e, ...nextTurn(e).patch }; // current=b
|
||||
e = { ...e, ...togglePause(e).patch }; // pause
|
||||
|
||||
// add fresh participant x (initiative 25, would sort first)
|
||||
const x = p('x', 25);
|
||||
e = { ...e, ...addParticipant(e, x).patch };
|
||||
e = { ...e, ...togglePause(e).patch }; // resume (rebuilds order)
|
||||
|
||||
// after resume, complete one full round: visit each active participant once
|
||||
const visited = [e.currentTurnParticipantId];
|
||||
for (let i = 0; i < e.turnOrderIds.length - 1; i++) {
|
||||
e = { ...e, ...nextTurn(e).patch };
|
||||
visited.push(e.currentTurnParticipantId);
|
||||
}
|
||||
const uniq = new Set(visited);
|
||||
// EXPECT: 4 unique (a,b,c,x). BUG: rotation may not visit all.
|
||||
expect(uniq.size).toBe(e.turnOrderIds.length);
|
||||
});
|
||||
|
||||
test('multiple adds while paused, resume, rotation visits all', () => {
|
||||
const ps = [p('a', 20), p('b', 15), p('c', 10)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
|
||||
e = { ...e, ...nextTurn(e).patch }; // current=b
|
||||
e = { ...e, ...togglePause(e).patch }; // pause
|
||||
|
||||
// add 3 fresh participants
|
||||
for (const id of ['x', 'y', 'z']) {
|
||||
const np = p(id, 5 + Math.floor(Math.random() * 30));
|
||||
e = { ...e, ...addParticipant(e, np).patch };
|
||||
}
|
||||
e = { ...e, ...togglePause(e).patch }; // resume
|
||||
|
||||
const visited = [e.currentTurnParticipantId];
|
||||
for (let i = 0; i < e.turnOrderIds.length + 2; i++) {
|
||||
e = { ...e, ...nextTurn(e).patch };
|
||||
visited.push(e.currentTurnParticipantId);
|
||||
}
|
||||
const uniq = new Set(visited);
|
||||
// EXPECT: all 6 participants reachable. BUG: some stuck/repeated.
|
||||
expect(uniq.size).toBe(e.turnOrderIds.length);
|
||||
});
|
||||
|
||||
test('add while running, then pause+resume, rotation stays valid', () => {
|
||||
const ps = [p('a', 20), p('b', 15), p('c', 10)];
|
||||
let e = enc(ps);
|
||||
e = { ...e, ...startEncounter(e).patch };
|
||||
|
||||
e = { ...e, ...nextTurn(e).patch }; // current=b
|
||||
const x = p('x', 25);
|
||||
e = { ...e, ...addParticipant(e, x).patch }; // add while running
|
||||
e = { ...e, ...togglePause(e).patch }; // pause
|
||||
e = { ...e, ...togglePause(e).patch }; // resume
|
||||
|
||||
const visited = [e.currentTurnParticipantId];
|
||||
for (let i = 0; i < e.turnOrderIds.length + 2; i++) {
|
||||
e = { ...e, ...nextTurn(e).patch };
|
||||
visited.push(e.currentTurnParticipantId);
|
||||
}
|
||||
const uniq = new Set(visited);
|
||||
expect(uniq.size).toBe(e.turnOrderIds.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user