Files
Dune/tests/tleilaxu-ambassador-forces.test.ts
2026-09-08 07:37:52 +10:00

230 lines
7.0 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { FACTIONS } from '../game/catalog';
import {
quoteTleilaxuAmbassadorForces as quote,
TleilaxuAmbassadorForceError,
type TleilaxuAmbassadorForceContext as Context,
} from '../game/tleilaxu-ambassador-forces';
const ordinary = (): Context => ({ faction: 'ecaz', reserves: 12, tanks: 8 });
const typed = (
faction: 'fremen' | 'emperor' | 'ixians' = 'fremen',
): Context => ({
faction,
reserves: 12,
tanks: 8,
elites: { reserves: 1, tanks: 3, revived: 0 },
});
function reject(player: Context, amount = 1, elite?: number) {
const before = structuredClone(player);
assert.throws(
() => quote(player, amount, elite),
TleilaxuAmbassadorForceError,
);
assert.deepEqual(player, before);
}
void test('all twelve factions can return one through four ordinary physical forces without normal-rate or quota inputs', () => {
for (const { id } of FACTIONS)
for (const amount of [1, 2, 3, 4]) {
const player = { ...ordinary(), faction: id };
const before = structuredClone(player);
const result = quote(player, amount);
assert.deepEqual(result, {
amount,
ordinary: amount,
elite: 0,
before: { reserves: 12, tanks: 8, eliteReserves: 0, eliteTanks: 0 },
after: {
reserves: 12 + amount,
tanks: 8 - amount,
eliteReserves: 0,
eliteTanks: 0,
},
eliteRevivedNext: 0,
});
assert.deepEqual(player, before);
}
});
void test('mixed force selection conserves ordinary and elite custody and uses only necessary elites by default', () => {
const player: Context = {
faction: 'fremen',
reserves: 16,
tanks: 4,
elites: { reserves: 2, tanks: 1, revived: 0 },
};
assert.equal(quote(player, 3).elite, 0);
assert.equal(quote(player, 4).elite, 1);
const result = quote(player, 2, 1);
assert.equal(result.ordinary, 1);
assert.equal(result.eliteRevivedNext, 1);
assert.equal(
result.before.reserves + result.before.tanks,
result.after.reserves + result.after.tanks,
);
assert.equal(
result.before.eliteReserves + result.before.eliteTanks,
result.after.eliteReserves + result.after.eliteTanks,
);
reject(player, 4, 0);
reject(player, 2, 2);
});
void test('Fedaykin and Sardaukar share their existing elite turn usage across independent returns', () => {
for (const faction of ['fremen', 'emperor'] as const) {
const first = quote(typed(faction), 4, 1);
const next: Context = {
faction,
reserves: first.after.reserves,
tanks: first.after.tanks,
elites: {
reserves: first.after.eliteReserves,
tanks: first.after.eliteTanks,
revived: first.eliteRevivedNext,
},
};
reject(next, 1, 1);
assert.equal(quote(next, 1, 0).eliteRevivedNext, 1);
reject(typed(faction), 2, 2);
}
});
void test('the automatic minimum elite selection cannot bypass already exhausted turn usage', () => {
const player: Context = {
faction: 'fremen',
reserves: 16,
tanks: 4,
elites: { reserves: 2, tanks: 3, revived: 1 },
};
reject(player, 2);
assert.equal(quote(player, 1).elite, 0);
});
void test('Ixian cyborgs count once and are not subject to the Sardaukar or Fedaykin cap', () => {
const player: Context = {
faction: 'ixians',
reserves: 16,
tanks: 4,
elites: { reserves: 3, tanks: 4, revived: 3 },
};
const result = quote(player, 4);
assert.equal(result.elite, 4);
assert.equal(result.ordinary, 0);
assert.equal(result.after.reserves, 20);
assert.equal(result.after.eliteReserves, 7);
assert.equal(result.eliteRevivedNext, 7);
});
void test('missing elite state permits ordinary returns but cannot supply a selected elite', () => {
for (const faction of ['ecaz', 'fremen', 'emperor', 'ixians'] as const) {
const player = { ...ordinary(), faction };
assert.equal(quote(player, 4).elite, 0);
reject(player, 1, 1);
}
});
void test('malformed amounts, elite selections and empty Tanks reject rather than coercing or clamping', () => {
for (const value of [
-1,
0,
5,
9,
1.5,
NaN,
Infinity,
Number.MAX_SAFE_INTEGER + 1,
'1',
null,
])
reject(ordinary(), value as number);
reject({ ...ordinary(), tanks: 0 });
for (const value of [
-1,
1.5,
NaN,
Infinity,
Number.MAX_SAFE_INTEGER + 1,
'1',
false,
null,
])
reject(typed(), 1, value as number);
});
void test('invalid before custody, unsupported elite factions and unsafe numeric inventories reject', () => {
for (const value of [
-1,
1.5,
NaN,
Infinity,
Number.MAX_SAFE_INTEGER + 1,
'2',
null,
]) {
for (const field of ['reserves', 'tanks'] as const)
reject({ ...ordinary(), [field]: value } as Context);
for (const field of ['reserves', 'tanks', 'revived'] as const)
reject({
...typed(),
elites: { ...typed().elites!, [field]: value },
} as Context);
}
reject({ ...typed(), elites: { reserves: 13, tanks: 3, revived: 0 } });
reject({ ...typed(), elites: { reserves: 1, tanks: 9, revived: 0 } });
reject({ ...typed(), faction: 'ecaz' });
reject({ ...ordinary(), elites: null } as unknown as Context);
reject({ ...ordinary(), elites: [] } as unknown as Context);
reject({ ...ordinary(), faction: 'unknown' } as unknown as Context);
});
void test('unsafe post-return reserves and elite usage reject while safe boundaries stay exact', () => {
const max = Number.MAX_SAFE_INTEGER;
reject({ ...ordinary(), reserves: max }, 1);
reject(
{ ...typed('ixians'), elites: { reserves: 1, tanks: 3, revived: max } },
1,
1,
);
assert.equal(
quote({ ...ordinary(), reserves: max - 1 }, 1).after.reserves,
max,
);
assert.equal(
quote(
{
...typed('ixians'),
elites: { reserves: 1, tanks: 3, revived: max - 1 },
},
1,
1,
).eliteRevivedNext,
max,
);
});
void test('quotes are detached and cannot mutate their input or later quotes', () => {
const player = typed();
const before = structuredClone(player);
const first = quote(player, 4, 1);
first.before.tanks = 100;
first.after.eliteTanks = 100;
assert.deepEqual(player, before);
assert.equal(quote(player, 4, 1).before.tanks, 8);
assert.equal(quote(player, 4, 1).after.eliteTanks, 2);
});
void test('only physical inputs are read; private data, ordinary allowances and random state are irrelevant', () => {
const base = typed('ixians');
const guarded = new Proxy(base, {
get(target, key, receiver) {
assert.ok(
['faction', 'reserves', 'tanks', 'elites'].includes(String(key)),
String(key),
);
return Reflect.get(target, key, receiver);
},
});
const random = Math.random;
try {
Math.random = () => {
throw new Error('Randomness is not part of a physical return quote');
};
const expected = quote(base, 4, 2);
for (let i = 0; i < 5; i++)
assert.deepEqual(quote(guarded, 4, 2), expected);
} finally {
Math.random = random;
}
});