Initial commit
This commit is contained in:
commit
8c709fb52f
771 files changed
+222983
No files matched your search
@@ -0,0 +1,294 @@
|
||||
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);
|
||||
});
|
||||
Reference in new issue
Block a user