305 lines
8.4 KiB
TypeScript
305 lines
8.4 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
createRemainingCohort,
|
|
validateCohortSelection,
|
|
type MovementCohort,
|
|
type MovementMarker,
|
|
} from '../game/ornithopter';
|
|
|
|
void test('a multi-sector first group leaves independent ordinary and elite counts, including same-origin residual groups', () => {
|
|
const all = { 'shield_wall:7': 4, 'shield_wall:8': 3, 'arrakeen:10': 2 };
|
|
const elites = { 'shield_wall:7': 2, 'shield_wall:8': 1 };
|
|
const selected = { 'shield_wall:7': 2, 'shield_wall:8': 1 };
|
|
const typed = { 'shield_wall:7': 1 };
|
|
const before = structuredClone({ all, elites, selected, typed });
|
|
const remaining = createRemainingCohort(all, elites, selected, typed);
|
|
assert.deepEqual(remaining, {
|
|
forces: { 'shield_wall:7': 2, 'shield_wall:8': 2, 'arrakeen:10': 2 },
|
|
elites: { 'shield_wall:7': 1, 'shield_wall:8': 1 },
|
|
});
|
|
assert.doesNotThrow(() =>
|
|
validateCohortSelection(
|
|
remaining,
|
|
remaining.forces,
|
|
remaining.elites,
|
|
{ 'shield_wall:7': 2, 'shield_wall:8': 1 },
|
|
{ 'shield_wall:7': 1, 'shield_wall:8': 1 },
|
|
),
|
|
);
|
|
assert.deepEqual({ all, elites, selected, typed }, before);
|
|
});
|
|
|
|
void test('first-group arrivals cannot enlarge a destination quota or become a second group at an empty original destination', () => {
|
|
const remaining = createRemainingCohort(
|
|
{ origin: 4, destination: 2 },
|
|
{ origin: 1 },
|
|
{ origin: 3 },
|
|
{ origin: 1 },
|
|
);
|
|
const current = { origin: 1, destination: 5 },
|
|
elites = { destination: 1 };
|
|
assert.doesNotThrow(() =>
|
|
validateCohortSelection(remaining, current, elites, { destination: 2 }, {}),
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
validateCohortSelection(
|
|
remaining,
|
|
current,
|
|
elites,
|
|
{ destination: 3 },
|
|
{},
|
|
),
|
|
/unmoved cohort/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
validateCohortSelection(
|
|
remaining,
|
|
current,
|
|
elites,
|
|
{ destination: 1 },
|
|
{ destination: 1 },
|
|
),
|
|
/unmoved cohort/,
|
|
);
|
|
const empty = createRemainingCohort({ origin: 4 }, {}, { origin: 4 }, {});
|
|
assert.throws(
|
|
() =>
|
|
validateCohortSelection(
|
|
empty,
|
|
{ destination: 4 },
|
|
{},
|
|
{ destination: 1 },
|
|
{},
|
|
),
|
|
/unmoved cohort/,
|
|
);
|
|
});
|
|
|
|
void test('typed quotas reject ordinary-for-elite substitutions even when total force counts fit', () => {
|
|
assert.throws(
|
|
() => createRemainingCohort({ a: 3 }, { a: 2 }, { a: 2 }, {}),
|
|
/ordinary or elite/,
|
|
);
|
|
assert.throws(
|
|
() => createRemainingCohort({ a: 3 }, { a: 1 }, { a: 2 }, { a: 2 }),
|
|
/ordinary or elite/,
|
|
);
|
|
const cohort = createRemainingCohort({ a: 4 }, { a: 2 }, { a: 1 }, {});
|
|
assert.deepEqual(cohort, { forces: { a: 3 }, elites: { a: 2 } });
|
|
assert.throws(
|
|
() => validateCohortSelection(cohort, { a: 3 }, { a: 2 }, { a: 2 }, {}),
|
|
/unmoved cohort/,
|
|
);
|
|
assert.throws(
|
|
() => validateCohortSelection(cohort, { a: 3 }, {}, { a: 2 }, { a: 1 }),
|
|
/current physical/,
|
|
);
|
|
assert.throws(
|
|
() => validateCohortSelection(cohort, { a: 2 }, { a: 2 }, { a: 1 }, {}),
|
|
/current physical/,
|
|
);
|
|
});
|
|
|
|
void test('current losses limit remaining selections without increasing their original allowance', () => {
|
|
const cohort = createRemainingCohort({ a: 5, b: 4 }, { b: 2 }, { a: 2 }, {});
|
|
assert.throws(
|
|
() =>
|
|
validateCohortSelection(cohort, { a: 1, b: 4 }, { b: 2 }, { a: 2 }, {}),
|
|
/current physical/,
|
|
);
|
|
assert.doesNotThrow(() =>
|
|
validateCohortSelection(
|
|
cohort,
|
|
{ a: 1, b: 3 },
|
|
{ b: 1 },
|
|
{ a: 1, b: 3 },
|
|
{ b: 1 },
|
|
),
|
|
);
|
|
});
|
|
|
|
void test('marker selection binds exact identity, event and origin and cannot be reused with a new event', () => {
|
|
const marker = {
|
|
tokenId: 'opaque-id',
|
|
event: 'before-move',
|
|
from: 'shield_wall:8',
|
|
};
|
|
const unmoved = createRemainingCohort({ a: 1 }, {}, { a: 1 }, {}, marker);
|
|
assert.deepEqual(unmoved, { forces: {}, elites: {}, noField: marker });
|
|
assert.notEqual(unmoved.noField, marker);
|
|
assert.doesNotThrow(() =>
|
|
validateCohortSelection(unmoved, {}, {}, {}, {}, marker),
|
|
);
|
|
for (const changed of [
|
|
{ ...marker, event: 'new-event' },
|
|
{ ...marker, tokenId: 'another' },
|
|
{ ...marker, from: 'arrakeen:10' },
|
|
]) {
|
|
assert.throws(
|
|
() => validateCohortSelection(unmoved, {}, {}, {}, {}, changed),
|
|
/not an unmoved/,
|
|
);
|
|
assert.throws(
|
|
() => createRemainingCohort({}, {}, {}, {}, marker, changed),
|
|
/not an unmoved/,
|
|
);
|
|
}
|
|
const moved = createRemainingCohort(
|
|
{ a: 2 },
|
|
{},
|
|
{ a: 1 },
|
|
{},
|
|
marker,
|
|
marker,
|
|
);
|
|
assert.equal(moved.noField, undefined);
|
|
for (const selected of [
|
|
marker,
|
|
{ ...marker, event: 'after-move', from: 'arrakeen:10' },
|
|
])
|
|
assert.throws(
|
|
() => validateCohortSelection(moved, { a: 1 }, {}, {}, {}, selected),
|
|
/not an unmoved/,
|
|
);
|
|
});
|
|
|
|
void test('marker cohort calculation neither reads nor projects any hidden denomination', () => {
|
|
for (const value of [0, 3, 5]) {
|
|
const marker = {
|
|
tokenId: 'same-opaque',
|
|
event: 'same-event',
|
|
from: 'polar_sink:0',
|
|
value,
|
|
};
|
|
const expected = {
|
|
forces: {},
|
|
elites: {},
|
|
noField: {
|
|
tokenId: marker.tokenId,
|
|
event: marker.event,
|
|
from: marker.from,
|
|
},
|
|
};
|
|
assert.deepEqual(createRemainingCohort({}, {}, {}, {}, marker), expected);
|
|
}
|
|
const marker = {
|
|
tokenId: 'opaque',
|
|
event: 'event',
|
|
from: 'polar_sink:0',
|
|
get value(): never {
|
|
throw new Error('Hidden denomination read');
|
|
},
|
|
};
|
|
const cohort = createRemainingCohort({}, {}, {}, {}, marker);
|
|
assert.doesNotThrow(() =>
|
|
validateCohortSelection(cohort, {}, {}, {}, {}, marker),
|
|
);
|
|
assert.equal(JSON.stringify(cohort).includes('value'), false);
|
|
});
|
|
|
|
void test('JSON-restored cohorts validate without mutation and outputs do not alias input records', () => {
|
|
const forces = { a: 3, b: 0 },
|
|
elites = { a: 1 },
|
|
selected = { a: 1 },
|
|
selectedElites = {};
|
|
const cohort = createRemainingCohort(
|
|
forces,
|
|
elites,
|
|
selected,
|
|
selectedElites,
|
|
);
|
|
const restored: MovementCohort = JSON.parse(JSON.stringify(cohort));
|
|
const before = structuredClone(restored);
|
|
validateCohortSelection(restored, { a: 2 }, { a: 1 }, { a: 2 }, { a: 1 });
|
|
assert.deepEqual(restored, before);
|
|
cohort.forces.a = 99;
|
|
cohort.elites.a = 99;
|
|
assert.deepEqual(forces, { a: 3, b: 0 });
|
|
assert.deepEqual(elites, { a: 1 });
|
|
assert.deepEqual(selected, { a: 1 });
|
|
assert.deepEqual(restored, { forces: { a: 2 }, elites: { a: 1 } });
|
|
});
|
|
|
|
void test('malformed counts, impossible typed pools and restored markers fail before mutation', () => {
|
|
for (const count of [
|
|
-1,
|
|
0.5,
|
|
NaN,
|
|
Infinity,
|
|
Number.MAX_SAFE_INTEGER + 1,
|
|
'1',
|
|
undefined,
|
|
]) {
|
|
const bad = { a: count } as Record<string, number>;
|
|
assert.throws(() => createRemainingCohort(bad, {}, {}, {}), /safe integer/);
|
|
assert.throws(
|
|
() => createRemainingCohort({ a: 2 }, {}, bad, {}),
|
|
/safe integer/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
validateCohortSelection({ forces: bad, elites: {} }, {}, {}, {}, {}),
|
|
/safe integer/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
validateCohortSelection({ forces: {}, elites: {} }, bad, {}, {}, {}),
|
|
/safe integer/,
|
|
);
|
|
}
|
|
for (const bad of [null, [], 2])
|
|
assert.throws(
|
|
() =>
|
|
createRemainingCohort(
|
|
bad as unknown as Record<string, number>,
|
|
{},
|
|
{},
|
|
{},
|
|
),
|
|
/record/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
createRemainingCohort({ a: Number.MAX_SAFE_INTEGER, b: 1 }, {}, {}, {}),
|
|
/total/,
|
|
);
|
|
assert.throws(
|
|
() => createRemainingCohort({ '': 1 }, {}, {}, {}),
|
|
/named locations/,
|
|
);
|
|
assert.throws(
|
|
() => createRemainingCohort({ a: 1 }, { a: 2 }, {}, {}),
|
|
/elite count/,
|
|
);
|
|
assert.throws(
|
|
() => createRemainingCohort({ a: 1 }, {}, { a: 1 }, { b: 1 }),
|
|
/elite count/,
|
|
);
|
|
for (const marker of [
|
|
null,
|
|
{},
|
|
{ tokenId: 'id', event: '', from: 'a' },
|
|
{ tokenId: 'id', event: 'event', from: 1 },
|
|
]) {
|
|
const invalid = marker as unknown as MovementMarker;
|
|
assert.throws(
|
|
() => createRemainingCohort({}, {}, {}, {}, invalid),
|
|
/marker/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
validateCohortSelection(
|
|
{ forces: {}, elites: {}, noField: invalid },
|
|
{},
|
|
{},
|
|
{},
|
|
{},
|
|
),
|
|
/marker/,
|
|
);
|
|
}
|
|
});
|