295 lines
9.6 KiB
TypeScript
295 lines
9.6 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
OrderedOpportunityError,
|
|
protectOrderedOpportunityLast,
|
|
reorderOrderedOpportunity,
|
|
validateOrderedOpportunity,
|
|
type OrderedOpportunity,
|
|
} from '../game/ordered-opportunity';
|
|
|
|
function fixture(
|
|
remaining = ['a', 'b', 'c', 'd'],
|
|
completed: string[] = [],
|
|
): OrderedOpportunity {
|
|
return {
|
|
event: 'movement-turn-4',
|
|
eligible: [...completed, ...remaining],
|
|
remaining,
|
|
completed,
|
|
current: remaining[0] ?? null,
|
|
currentStarted: false,
|
|
};
|
|
}
|
|
function freeze(state: OrderedOpportunity) {
|
|
Object.freeze(state.eligible);
|
|
Object.freeze(state.remaining);
|
|
Object.freeze(state.completed);
|
|
return Object.freeze(state);
|
|
}
|
|
function move(
|
|
state: OrderedOpportunity,
|
|
player: string,
|
|
position: 'first' | 'last',
|
|
) {
|
|
return reorderOrderedOpportunity(state, {
|
|
event: state.event,
|
|
player,
|
|
position,
|
|
});
|
|
}
|
|
function permutations(values: string[]): string[][] {
|
|
return values.length
|
|
? values.flatMap((value, index) =>
|
|
permutations(values.filter((_, at) => at !== index)).map((tail) => [
|
|
value,
|
|
...tail,
|
|
]),
|
|
)
|
|
: [[]];
|
|
}
|
|
|
|
void test('first and last reorder every unstarted position immutably and leave completed work intact', () => {
|
|
const state = freeze(fixture(['a', 'b', 'c', 'd'], ['done']));
|
|
for (const player of state.remaining) {
|
|
for (const position of ['first', 'last'] as const) {
|
|
const result = move(state, player, position);
|
|
const rest = state.remaining.filter((id) => id !== player);
|
|
assert.deepEqual(
|
|
result.remaining,
|
|
position === 'first' ? [player, ...rest] : [...rest, player],
|
|
);
|
|
assert.deepEqual(result.eligible, state.eligible);
|
|
assert.deepEqual(result.completed, ['done']);
|
|
assert.equal(result.event, state.event);
|
|
assert.equal(result.currentStarted, false);
|
|
assert.equal(result.current, result.remaining[0]);
|
|
assert.notEqual(result, state);
|
|
for (const key of ['remaining', 'completed', 'eligible'] as const)
|
|
assert.notEqual(result[key], state[key]);
|
|
}
|
|
}
|
|
assert.deepEqual(state, fixture(['a', 'b', 'c', 'd'], ['done']));
|
|
});
|
|
|
|
void test('every finite four-player ordering and completion boundary conserves participants and a started current unit', () => {
|
|
let cases = 0;
|
|
for (const order of permutations(['a', 'b', 'c', 'd'])) {
|
|
for (
|
|
let completedCount = 0;
|
|
completedCount <= order.length;
|
|
completedCount++
|
|
) {
|
|
for (const started of [false, true]) {
|
|
const remaining = order.slice(completedCount);
|
|
if (started && !remaining.length) continue;
|
|
const state = freeze({
|
|
...fixture(remaining, order.slice(0, completedCount)),
|
|
currentStarted: started,
|
|
});
|
|
validateOrderedOpportunity(state);
|
|
for (const player of remaining) {
|
|
for (const position of ['first', 'last'] as const) {
|
|
cases++;
|
|
if (
|
|
started &&
|
|
player === state.current &&
|
|
position === 'last' &&
|
|
remaining.length > 1
|
|
) {
|
|
assert.throws(
|
|
() => move(state, player, position),
|
|
/split a started opportunity/,
|
|
);
|
|
continue;
|
|
}
|
|
const result = move(state, player, position);
|
|
validateOrderedOpportunity(result);
|
|
assert.deepEqual(
|
|
[...result.remaining].sort(),
|
|
[...remaining].sort(),
|
|
);
|
|
assert.deepEqual(result.completed, state.completed);
|
|
assert.deepEqual(result.eligible, state.eligible);
|
|
assert.equal(result.currentStarted, started);
|
|
if (started) assert.equal(result.current, state.current);
|
|
const unstarted = result.remaining.slice(started ? 1 : 0);
|
|
if (!(started && player === state.current))
|
|
assert.equal(
|
|
position === 'first' ? unstarted[0] : unstarted.at(-1),
|
|
player,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
assert.equal(cases, 960);
|
|
});
|
|
|
|
void test('first targets the unstarted remainder without interrupting or splitting the locked current actor', () => {
|
|
const state = freeze({ ...fixture(), currentStarted: true });
|
|
assert.deepEqual(move(state, 'd', 'first').remaining, ['a', 'd', 'b', 'c']);
|
|
assert.deepEqual(move(state, 'b', 'last').remaining, ['a', 'c', 'd', 'b']);
|
|
assert.deepEqual(move(state, 'a', 'first'), state);
|
|
assert.throws(() => move(state, 'a', 'last'), /unsupported/);
|
|
assert.throws(
|
|
() =>
|
|
protectOrderedOpportunityLast(state, { event: state.event, player: 'a' }),
|
|
/unsupported/,
|
|
);
|
|
const sole = freeze({
|
|
...fixture(['a'], ['b', 'c', 'd']),
|
|
currentStarted: true,
|
|
});
|
|
assert.deepEqual(move(sole, 'a', 'last'), sole);
|
|
});
|
|
|
|
void test('protected last survives repeated Guild deferral and earlier Guild turns without overriding competing priority', () => {
|
|
const initial = freeze(fixture(['sapho', 'a', 'guild', 'b']));
|
|
const protectedState = protectOrderedOpportunityLast(initial, {
|
|
event: initial.event,
|
|
player: 'sapho',
|
|
});
|
|
assert.deepEqual(protectedState.remaining, ['a', 'guild', 'b', 'sapho']);
|
|
assert.equal(protectedState.protectedLast, 'sapho');
|
|
let state = move(protectedState, 'guild', 'last');
|
|
assert.deepEqual(state.remaining, ['a', 'b', 'guild', 'sapho']);
|
|
for (let i = 0; i < 4; i++) state = move(state, 'guild', 'last');
|
|
assert.deepEqual(state.remaining, ['a', 'b', 'guild', 'sapho']);
|
|
state = move(state, 'guild', 'first');
|
|
assert.deepEqual(state.remaining, ['guild', 'a', 'b', 'sapho']);
|
|
assert.deepEqual(move(state, 'guild', 'last').remaining, [
|
|
'a',
|
|
'b',
|
|
'guild',
|
|
'sapho',
|
|
]);
|
|
const locked = { ...protectedState, currentStarted: true };
|
|
assert.deepEqual(move(locked, 'guild', 'last').remaining, [
|
|
'a',
|
|
'b',
|
|
'guild',
|
|
'sapho',
|
|
]);
|
|
assert.deepEqual(move(locked, 'guild', 'first').remaining, [
|
|
'a',
|
|
'guild',
|
|
'b',
|
|
'sapho',
|
|
]);
|
|
assert.throws(() => move(protectedState, 'sapho', 'first'), /protected last/);
|
|
assert.throws(
|
|
() =>
|
|
protectOrderedOpportunityLast(protectedState, {
|
|
event: state.event,
|
|
player: 'guild',
|
|
}),
|
|
/Competing/,
|
|
);
|
|
assert.deepEqual(
|
|
protectOrderedOpportunityLast(protectedState, {
|
|
event: state.event,
|
|
player: 'sapho',
|
|
}),
|
|
protectedState,
|
|
);
|
|
assert.deepEqual(initial, fixture(['sapho', 'a', 'guild', 'b']));
|
|
});
|
|
|
|
void test('stale events, completed actors and ineligible IDs reject without resurrecting or granting an opportunity', () => {
|
|
const state = freeze(fixture(['b', 'c'], ['a']));
|
|
for (const position of ['first', 'last'] as const) {
|
|
assert.throws(
|
|
() =>
|
|
reorderOrderedOpportunity(state, {
|
|
event: 'old-event',
|
|
player: 'b',
|
|
position,
|
|
}),
|
|
/expired/,
|
|
);
|
|
assert.throws(() => move(state, 'a', position), /already completed/);
|
|
assert.throws(() => move(state, 'observer', position), /not eligible/);
|
|
}
|
|
assert.throws(
|
|
() =>
|
|
protectOrderedOpportunityLast(state, { event: 'old-event', player: 'b' }),
|
|
/expired/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
protectOrderedOpportunityLast(state, { event: state.event, player: 'a' }),
|
|
/already completed/,
|
|
);
|
|
const closed = freeze(fixture([], ['a', 'b', 'c']));
|
|
validateOrderedOpportunity(closed);
|
|
assert.throws(() => move(closed, 'a', 'first'), /already completed/);
|
|
assert.deepEqual(state, fixture(['b', 'c'], ['a']));
|
|
});
|
|
|
|
void test('malformed serialized partitions, current markers and protected positions are rejected rather than repaired', () => {
|
|
const state = fixture(['a', 'b'], ['done']);
|
|
const corrupt: unknown[] = [
|
|
null,
|
|
{},
|
|
{ ...state, event: '' },
|
|
{ ...state, eligible: ['done', 'a', 'a'] },
|
|
{ ...state, remaining: ['a', 'a'] },
|
|
{ ...state, completed: ['done', 'done'] },
|
|
{ ...state, remaining: ['a'] },
|
|
{ ...state, remaining: ['a', 'other'] },
|
|
{ ...state, completed: ['a'] },
|
|
{ ...state, remaining: ['a', ''] },
|
|
{ ...state, current: 'b' },
|
|
{ ...state, current: null },
|
|
{ ...state, currentStarted: 'yes' },
|
|
{ ...fixture([], ['done']), currentStarted: true },
|
|
{ ...state, protectedLast: 'a' },
|
|
{ ...state, protectedLast: 'done' },
|
|
{ ...state, protectedLast: 'other' },
|
|
];
|
|
for (const value of corrupt) {
|
|
assert.throws(
|
|
() => validateOrderedOpportunity(value as OrderedOpportunity),
|
|
OrderedOpportunityError,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
reorderOrderedOpportunity(value as OrderedOpportunity, {
|
|
event: state.event,
|
|
player: 'a',
|
|
position: 'first',
|
|
}),
|
|
OrderedOpportunityError,
|
|
);
|
|
}
|
|
assert.throws(
|
|
() =>
|
|
reorderOrderedOpportunity(state, {
|
|
event: state.event,
|
|
player: 'a',
|
|
position: 'middle' as 'first',
|
|
}),
|
|
/Choose first or last/,
|
|
);
|
|
});
|
|
|
|
void test('JSON snapshots preserve scope and protection, and no-op results share no participant arrays', () => {
|
|
const first = protectOrderedOpportunityLast(
|
|
fixture(['a', 'guild', 'sapho']),
|
|
{ event: 'movement-turn-4', player: 'sapho' },
|
|
);
|
|
const saved: OrderedOpportunity = JSON.parse(
|
|
JSON.stringify({ ...first, currentStarted: true }),
|
|
);
|
|
const restored = move(freeze(saved), 'guild', 'last');
|
|
assert.deepEqual(restored, saved);
|
|
assert.notEqual(restored.remaining, saved.remaining);
|
|
assert.notEqual(restored.eligible, saved.eligible);
|
|
assert.notEqual(restored.completed, saved.completed);
|
|
assert.equal(restored.event, 'movement-turn-4');
|
|
assert.equal(restored.protectedLast, 'sapho');
|
|
assert.equal(restored.currentStarted, true);
|
|
});
|