230 lines
7.0 KiB
TypeScript
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;
|
|
}
|
|
});
|