128 lines
4.9 KiB
JavaScript
128 lines
4.9 KiB
JavaScript
|
|
// 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
|
||
|
|
//
|
||
|
|
// Divergence = bug. BUG-6 (reorder), BUG-5 (add/remove) manifest here.
|
||
|
|
// RED expected on reorder + others. Locks drift before refactor.
|
||
|
|
|
||
|
|
'use strict';
|
||
|
|
|
||
|
|
const shared = require('@ttrpg/shared');
|
||
|
|
const {
|
||
|
|
makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
|
||
|
|
sortParticipantsByInitiative,
|
||
|
|
startEncounter, nextTurn, addParticipant, removeParticipant,
|
||
|
|
toggleParticipantActive, togglePause, applyHpChange,
|
||
|
|
reorderParticipants, endEncounter,
|
||
|
|
} = shared;
|
||
|
|
|
||
|
|
function p(id, init, extra = {}) {
|
||
|
|
return makeParticipant({ id, name: id, type: 'monster',
|
||
|
|
initiative: init, maxHp: 100, currentHp: 100, ...extra });
|
||
|
|
}
|
||
|
|
function enc(ps, extra = {}) {
|
||
|
|
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
||
|
|
round:0, currentTurnParticipantId:null, turnOrderIds:[], ...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
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return { display, frozen, rotation };
|
||
|
|
}
|
||
|
|
|
||
|
|
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)]);
|
||
|
|
e = apply(e, startEncounter(e));
|
||
|
|
const { display, frozen, rotation } = lists(e);
|
||
|
|
expect(frozen).toEqual(display);
|
||
|
|
expect(rotation).toEqual(display);
|
||
|
|
});
|
||
|
|
|
||
|
|
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)]);
|
||
|
|
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);
|
||
|
|
});
|
||
|
|
|
||
|
|
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('add mid-encounter: all 3 match (BUG-5 RED)', () => {
|
||
|
|
let e = enc([p('a',10),p('b',5)]);
|
||
|
|
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);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('remove mid-encounter: all 3 match', () => {
|
||
|
|
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);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('toggle active off+on: all 3 match', () => {
|
||
|
|
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);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('hp death + revive: all 3 match', () => {
|
||
|
|
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
|
||
|
|
e = apply(e, applyHpChange(e, 'b', 'heal', 50)); // b revive
|
||
|
|
const { display, frozen, rotation } = lists(e);
|
||
|
|
expect(frozen).toEqual(display);
|
||
|
|
expect(rotation).toEqual(display);
|
||
|
|
});
|
||
|
|
});
|