2026-07-01 16:00:00 -04:00
|
|
|
// 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).
|
2026-07-01 15:32:30 -04:00
|
|
|
//
|
2026-07-01 16:00:00 -04:00
|
|
|
// RED now: current code has two lists (sort on display, frozen turnOrderIds),
|
|
|
|
|
// reorder throws on cross-init. Refactor (1-list model) greens these.
|
2026-07-06 10:31:46 -04:00
|
|
|
//
|
|
|
|
|
// New API: mutating funcs are async, take ctx last, write encounter + log
|
|
|
|
|
// internally, return newEnc. Chained calls become `e = await fn(e, ctx)`.
|
|
|
|
|
// No-ops (reorder blocked) return the same enc ref.
|
2026-07-01 15:32:30 -04:00
|
|
|
|
|
|
|
|
'use strict';
|
|
|
|
|
|
|
|
|
|
const shared = require('@ttrpg/shared');
|
|
|
|
|
const {
|
2026-07-01 16:00:00 -04:00
|
|
|
makeParticipant,
|
2026-07-01 15:32:30 -04:00
|
|
|
startEncounter, nextTurn, addParticipant, removeParticipant,
|
|
|
|
|
toggleParticipantActive, togglePause, applyHpChange,
|
|
|
|
|
reorderParticipants, endEncounter,
|
|
|
|
|
} = shared;
|
2026-07-06 10:31:46 -04:00
|
|
|
const { mockCtx } = require('./_helpers');
|
2026-07-01 15:32:30 -04:00
|
|
|
|
|
|
|
|
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 };
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-01 16:00:00 -04:00
|
|
|
describe('1-list model: turnOrderIds === participants.map(id), no re-sort', () => {
|
2026-07-06 10:31:46 -04:00
|
|
|
test('startEncounter: list = sorted-active participants order', async () => {
|
|
|
|
|
const { ctx } = mockCtx();
|
2026-07-01 16:00:00 -04:00
|
|
|
let e = enc([p('a',3),p('b',10),p('c',5)]);
|
2026-07-06 10:31:46 -04:00
|
|
|
e = await startEncounter(e, ctx);
|
2026-07-01 16:00:00 -04:00
|
|
|
expect(e.turnOrderIds).toEqual(['b','c','a']); // 10,5,3
|
|
|
|
|
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
2026-07-01 15:32:30 -04:00
|
|
|
});
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
test('startEncounter: inactive stays in list slot (skipped by nextTurn)', async () => {
|
|
|
|
|
const { ctx } = mockCtx();
|
2026-07-01 16:00:00 -04:00
|
|
|
let e = enc([p('a',10),p('b',5,{isActive:false}),p('c',3)]);
|
2026-07-06 10:31:46 -04:00
|
|
|
e = await startEncounter(e, ctx);
|
2026-07-01 16:00:00 -04:00
|
|
|
// 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
|
2026-07-01 15:32:30 -04:00
|
|
|
});
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
test('addParticipant mid-encounter: inserted by init, list synced', async () => {
|
|
|
|
|
const { ctx } = mockCtx();
|
2026-07-01 16:00:00 -04:00
|
|
|
let e = enc([p('a',10),p('c',3)]);
|
2026-07-06 10:31:46 -04:00
|
|
|
e = await startEncounter(e, ctx); // [a,c]
|
|
|
|
|
e = await addParticipant(e, p('b',7), ctx); // insert between a,c
|
2026-07-01 16:00:00 -04:00
|
|
|
expect(e.turnOrderIds).toEqual(['a','b','c']);
|
|
|
|
|
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
2026-07-01 15:32:30 -04:00
|
|
|
});
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
test('addParticipant: list === participants.map(id) after add', async () => {
|
|
|
|
|
const { ctx } = mockCtx();
|
2026-07-01 16:00:00 -04:00
|
|
|
let e = enc([p('a',10)]);
|
2026-07-06 10:31:46 -04:00
|
|
|
e = await startEncounter(e, ctx);
|
|
|
|
|
e = await addParticipant(e, p('b',5), ctx);
|
2026-07-01 16:00:00 -04:00
|
|
|
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
2026-07-01 15:32:30 -04:00
|
|
|
});
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
test('removeParticipant: list synced, order preserved', async () => {
|
|
|
|
|
const { ctx } = mockCtx();
|
2026-07-01 15:32:30 -04:00
|
|
|
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
2026-07-06 10:31:46 -04:00
|
|
|
e = await startEncounter(e, ctx);
|
|
|
|
|
e = await removeParticipant(e, 'b', ctx);
|
2026-07-01 16:00:00 -04:00
|
|
|
expect(e.turnOrderIds).toEqual(['a','c']);
|
|
|
|
|
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
2026-07-01 15:32:30 -04:00
|
|
|
});
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
test('reorder cross-init: blocked (no-op)', async () => {
|
|
|
|
|
const { ctx } = mockCtx();
|
2026-07-01 15:32:30 -04:00
|
|
|
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
2026-07-06 10:31:46 -04:00
|
|
|
e = await startEncounter(e, ctx); // [a,b,c]
|
|
|
|
|
const newEnc = await reorderParticipants(e, 'c', 'a', ctx); // cross-init
|
|
|
|
|
expect(newEnc).toBe(e); // same ref = no write
|
2026-07-03 17:59:41 -04:00
|
|
|
expect(e.turnOrderIds).toEqual(['a','b','c']); // unchanged
|
2026-07-01 16:00:00 -04:00
|
|
|
});
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
test('reorder same-init: within upcoming side of pointer follows new order', async () => {
|
|
|
|
|
const { ctx } = mockCtx();
|
2026-07-04 17:12:13 -04:00
|
|
|
let e = enc([p('a',10),p('b',10),p('c',10),p('d',3)]); // a,b,c tie
|
2026-07-06 10:31:46 -04:00
|
|
|
e = await startEncounter(e, ctx); // [a,b,c,d], cur=a (idx0)
|
2026-07-04 17:12:13 -04:00
|
|
|
// Reorder c before b (both upcoming idx1,2). Within same side = allowed.
|
2026-07-06 10:31:46 -04:00
|
|
|
e = await reorderParticipants(e, 'c', 'b', ctx); // [a,c,b,d], cur=a
|
2026-07-04 17:12:13 -04:00
|
|
|
expect(e.turnOrderIds).toEqual(['a','c','b','d']);
|
|
|
|
|
// walk: a current, next c, next b, next d, wrap a
|
2026-07-06 10:31:46 -04:00
|
|
|
e = await nextTurn(e, ctx);
|
2026-07-04 17:12:13 -04:00
|
|
|
expect(e.currentTurnParticipantId).toBe('c');
|
2026-07-01 16:00:00 -04:00
|
|
|
});
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
test('toggle inactive: list unchanged (stays in rotation slot)', async () => {
|
|
|
|
|
const { ctx } = mockCtx();
|
2026-07-01 16:00:00 -04:00
|
|
|
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
2026-07-06 10:31:46 -04:00
|
|
|
e = await startEncounter(e, ctx); // [a,b,c]
|
|
|
|
|
e = await toggleParticipantActive(e, 'b', ctx); // b off
|
2026-07-01 16:00:00 -04:00
|
|
|
expect(e.turnOrderIds).toEqual(['a','b','c']); // b stays in slot
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
test('toggle inactive: nextTurn skips b, visits a→c', async () => {
|
|
|
|
|
const { ctx } = mockCtx();
|
2026-07-01 16:00:00 -04:00
|
|
|
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
2026-07-06 10:31:46 -04:00
|
|
|
e = await startEncounter(e, ctx); // cur=a
|
|
|
|
|
e = await toggleParticipantActive(e, 'b', ctx); // b inactive
|
|
|
|
|
e = await nextTurn(e, ctx); // skip b → c
|
2026-07-01 16:00:00 -04:00
|
|
|
expect(e.currentTurnParticipantId).toBe('c');
|
2026-07-01 15:32:30 -04:00
|
|
|
});
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
test('hp death + revive: list unchanged', async () => {
|
|
|
|
|
const { ctx } = mockCtx();
|
2026-07-01 15:32:30 -04:00
|
|
|
let e = enc([p('a',10),p('b',7),p('c',3)]);
|
2026-07-06 10:31:46 -04:00
|
|
|
e = await startEncounter(e, ctx);
|
|
|
|
|
e = await applyHpChange(e, 'b', 'damage', 100, ctx); // b dies
|
2026-07-01 16:00:00 -04:00
|
|
|
expect(e.turnOrderIds).toEqual(['a','b','c']);
|
2026-07-06 10:31:46 -04:00
|
|
|
e = await applyHpChange(e, 'b', 'heal', 50, ctx); // b revive
|
2026-07-01 16:00:00 -04:00
|
|
|
expect(e.turnOrderIds).toEqual(['a','b','c']);
|
|
|
|
|
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
|
2026-07-01 15:32:30 -04:00
|
|
|
});
|
|
|
|
|
});
|