Connect typed Homeworld custody, transport, battle, income, revival and Caladan reinforcement flows with private multiplayer recovery and controls. Preserve the optimized test workflow and keep unfinished rules modes gated. Verified with 3682 offline tests, typecheck, lint, production build and 40 HTTP tests; existing saved games remain unchanged.
289 lines
8.4 KiB
TypeScript
289 lines
8.4 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { newPlayer } from '../game/engine';
|
|
import {
|
|
createHomeworldCustody,
|
|
HomeworldCustodyError,
|
|
} from '../game/homeworld-custody';
|
|
import { homeworldContext } from '../game/homeworld-game';
|
|
import {
|
|
quoteGuildPaymentRounding,
|
|
quoteHomeworldPaymentIncome,
|
|
} from '../game/homeworld-payment-income';
|
|
|
|
function fixture(
|
|
faction: 'guild' | 'emperor',
|
|
reserves: number,
|
|
advanced = true,
|
|
) {
|
|
const players = [
|
|
newPlayer('native', 'Native', faction),
|
|
newPlayer('visitor', 'Visitor', 'harkonnen'),
|
|
];
|
|
players[0].reserves = reserves;
|
|
if (players[0].elites) players[0].elites.reserves = 0;
|
|
const context = { advanced, players };
|
|
return {
|
|
...context,
|
|
homeworlds: { custody: createHomeworldCustody(homeworldContext(context)) },
|
|
};
|
|
}
|
|
|
|
void test('Junction and Kaitain halve one eligible payment below five native forces in either mode', () => {
|
|
for (const advanced of [false, true])
|
|
for (const [faction, kind] of [
|
|
['guild', 'shipment'],
|
|
['emperor', 'treachery'],
|
|
] as const)
|
|
for (const reserves of [0, 4, 5, 20]) {
|
|
const context = fixture(faction, reserves, advanced);
|
|
assert.deepEqual(
|
|
quoteHomeworldPaymentIncome(context, 'native', kind, 5),
|
|
{
|
|
gross: 5,
|
|
income: reserves < 5 ? 3 : 5,
|
|
bank: reserves < 5 ? 2 : 0,
|
|
low: reserves < 5,
|
|
},
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('odd, even, zero and maximal safe single payment conserve spice', () => {
|
|
const context = fixture('guild', 4);
|
|
for (const [gross, income] of [
|
|
[0, 0],
|
|
[1, 1],
|
|
[2, 1],
|
|
[3, 2],
|
|
[5, 3],
|
|
[6, 3],
|
|
[Number.MAX_SAFE_INTEGER, 4_503_599_627_370_496],
|
|
]) {
|
|
const quote = quoteHomeworldPaymentIncome(
|
|
context,
|
|
'native',
|
|
'shipment',
|
|
gross,
|
|
);
|
|
assert.equal(quote.income, income);
|
|
assert.equal(quote.bank + quote.income, gross);
|
|
assert.equal(quote.low, true);
|
|
}
|
|
});
|
|
|
|
void test('Guild rounding comparisons agree for single payer, even-even and mixed parity contributions', () => {
|
|
for (const [gross, contributions, income] of [
|
|
[0, [], 0],
|
|
[0, [0, 0], 0],
|
|
[1, [1], 1],
|
|
[5, [5], 3],
|
|
[5, [0, 5], 3],
|
|
[6, [2, 4], 3],
|
|
[5, [2, 3], 3],
|
|
[Number.MAX_SAFE_INTEGER, [Number.MAX_SAFE_INTEGER], 4_503_599_627_370_496],
|
|
] as [number, number[], number][]) {
|
|
const before = JSON.stringify(contributions);
|
|
Object.freeze(contributions);
|
|
assert.deepEqual(quoteGuildPaymentRounding(gross, contributions), {
|
|
transaction: income,
|
|
contribution: income,
|
|
unambiguous: true,
|
|
});
|
|
assert.equal(JSON.stringify(contributions), before);
|
|
}
|
|
});
|
|
|
|
void test('Guild rounding exposes odd-odd disagreement without selecting either interpretation', () => {
|
|
assert.deepEqual(quoteGuildPaymentRounding(2, [1, 1]), {
|
|
transaction: 1,
|
|
contribution: 2,
|
|
unambiguous: false,
|
|
});
|
|
const restored = JSON.parse('[3,3]') as number[];
|
|
assert.deepEqual(quoteGuildPaymentRounding(6, restored), {
|
|
transaction: 3,
|
|
contribution: 4,
|
|
unambiguous: false,
|
|
});
|
|
assert.deepEqual(restored, [3, 3]);
|
|
});
|
|
|
|
void test('Guild rounding rejects malformed amounts, overflow, sparse arrays and mismatched payment totals', () => {
|
|
const invalid: [unknown, unknown][] = [
|
|
[-1, []],
|
|
[NaN, []],
|
|
[Infinity, []],
|
|
[0.5, [0.5]],
|
|
[Number.MAX_SAFE_INTEGER + 1, [Number.MAX_SAFE_INTEGER, 1]],
|
|
[0, undefined],
|
|
[0, null],
|
|
[0, {}],
|
|
[1, '1'],
|
|
[0, [0, 0, 0]],
|
|
[0, [-1, 1]],
|
|
[0, [NaN]],
|
|
[1, [Infinity]],
|
|
[1, [0.5, 0.5]],
|
|
[1, ['1']],
|
|
[0, Array(1)],
|
|
[1, []],
|
|
[3, [1, 1]],
|
|
[Number.MAX_SAFE_INTEGER, [Number.MAX_SAFE_INTEGER, 1]],
|
|
];
|
|
for (const [gross, contributions] of invalid) {
|
|
const before = JSON.stringify(contributions);
|
|
assert.throws(
|
|
() =>
|
|
quoteGuildPaymentRounding(gross as number, contributions as number[]),
|
|
HomeworldCustodyError,
|
|
);
|
|
assert.equal(JSON.stringify(contributions), before);
|
|
}
|
|
});
|
|
|
|
void test('typed Kaitain natives count while Salusa allocation and visiting armies do not', () => {
|
|
const context = fixture('emperor', 10);
|
|
context.players[0].elites = { reserves: 5, tanks: 0, forces: {}, revived: 0 };
|
|
context.homeworlds.custody.salusa = { normal: 1, elite: 5 };
|
|
context.players[1].reserves = 0;
|
|
context.homeworlds.custody.visitors['homeworld:emperor'] = {
|
|
visitor: { normal: 20, elite: 0 },
|
|
};
|
|
assert.equal(
|
|
quoteHomeworldPaymentIncome(context, 'native', 'treachery', 5).income,
|
|
3,
|
|
);
|
|
context.homeworlds.custody.salusa.elite--;
|
|
assert.equal(
|
|
quoteHomeworldPaymentIncome(context, 'native', 'treachery', 5).income,
|
|
5,
|
|
);
|
|
context.homeworlds.custody.salusa.normal++;
|
|
assert.equal(
|
|
quoteHomeworldPaymentIncome(context, 'native', 'treachery', 5).income,
|
|
3,
|
|
);
|
|
});
|
|
|
|
void test('Junction visitors do not raise physical native population or earn a new payment', () => {
|
|
const context = fixture('guild', 4);
|
|
context.players[1].reserves = 0;
|
|
context.homeworlds.custody.visitors['homeworld:guild'] = {
|
|
visitor: { normal: 20, elite: 0 },
|
|
};
|
|
assert.deepEqual(
|
|
quoteHomeworldPaymentIncome(context, 'native', 'shipment', 5),
|
|
{
|
|
gross: 5,
|
|
income: 3,
|
|
bank: 2,
|
|
low: true,
|
|
},
|
|
);
|
|
assert.throws(
|
|
() => quoteHomeworldPaymentIncome(context, 'visitor', 'shipment', 5),
|
|
/Only the Guild/,
|
|
);
|
|
});
|
|
|
|
void test('absent and uninitialized module preserve native income and current population is rechecked', () => {
|
|
for (const homeworlds of [undefined, null, { custody: null }]) {
|
|
const context = { ...fixture('emperor', 0), homeworlds };
|
|
assert.deepEqual(
|
|
quoteHomeworldPaymentIncome(context, 'native', 'treachery', 5),
|
|
{
|
|
gross: 5,
|
|
income: 5,
|
|
bank: 0,
|
|
low: false,
|
|
},
|
|
);
|
|
}
|
|
const context = fixture('guild', 4);
|
|
assert.equal(
|
|
quoteHomeworldPaymentIncome(context, 'native', 'shipment', 5).low,
|
|
true,
|
|
);
|
|
context.players[0].reserves++;
|
|
assert.equal(
|
|
quoteHomeworldPaymentIncome(context, 'native', 'shipment', 5).low,
|
|
false,
|
|
);
|
|
context.players[0].reserves--;
|
|
assert.equal(
|
|
quoteHomeworldPaymentIncome(context, 'native', 'shipment', 5).low,
|
|
true,
|
|
);
|
|
});
|
|
|
|
void test('invalid payment, owner, kind and physical custody reject without mutation', () => {
|
|
const context = fixture('emperor', 4);
|
|
for (const gross of [-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
|
|
const before = JSON.stringify(context);
|
|
assert.throws(
|
|
() => quoteHomeworldPaymentIncome(context, 'native', 'treachery', gross),
|
|
HomeworldCustodyError,
|
|
);
|
|
assert.equal(JSON.stringify(context), before);
|
|
}
|
|
for (const [owner, kind] of [
|
|
['native', 'shipment'],
|
|
['visitor', 'treachery'],
|
|
['absent', 'treachery'],
|
|
['native', 'unknown'],
|
|
] as const) {
|
|
const before = JSON.stringify(context);
|
|
assert.throws(
|
|
() => quoteHomeworldPaymentIncome(context, owner, kind as 'treachery', 5),
|
|
HomeworldCustodyError,
|
|
);
|
|
assert.equal(JSON.stringify(context), before);
|
|
}
|
|
context.homeworlds.custody.salusa = { normal: 0, elite: 1 };
|
|
const before = JSON.stringify(context);
|
|
assert.throws(
|
|
() => quoteHomeworldPaymentIncome(context, 'native', 'treachery', 5),
|
|
HomeworldCustodyError,
|
|
);
|
|
assert.equal(JSON.stringify(context), before);
|
|
});
|
|
|
|
void test('payment quotes survive JSON, frozen inputs and throwing private getters on every player', () => {
|
|
const context = fixture('emperor', 4);
|
|
const restored = JSON.parse(JSON.stringify(context)) as typeof context;
|
|
const expected = quoteHomeworldPaymentIncome(
|
|
restored,
|
|
'native',
|
|
'treachery',
|
|
5,
|
|
);
|
|
for (const player of context.players) {
|
|
for (const field of [
|
|
'hand',
|
|
'spice',
|
|
'traitors',
|
|
'prediction',
|
|
'knownTraitors',
|
|
'faceDancers',
|
|
])
|
|
Object.defineProperty(player, field, {
|
|
get() {
|
|
throw new Error(`private ${field}`);
|
|
},
|
|
});
|
|
Object.freeze(player.elites);
|
|
Object.freeze(player);
|
|
}
|
|
Object.freeze(context.players);
|
|
Object.freeze(context.homeworlds.custody.salusa);
|
|
Object.freeze(context.homeworlds.custody.visitors);
|
|
Object.freeze(context.homeworlds.custody);
|
|
Object.freeze(context.homeworlds);
|
|
Object.freeze(context);
|
|
assert.deepEqual(
|
|
quoteHomeworldPaymentIncome(context, 'native', 'treachery', 5),
|
|
expected,
|
|
);
|
|
});
|