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 { 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('low Guild and Emperor round a funded odd-odd invoice once, not each contributor', () => { for (const advanced of [false, true]) for (const [faction, kind] of [ ['guild', 'shipment'], ['emperor', 'treachery'], ] as const) { const context = fixture(faction, 4, advanced); const contributions = [5, 3]; const gross = contributions.reduce((sum, amount) => sum + amount, 0); assert.deepEqual( quoteHomeworldPaymentIncome(context, 'native', kind, gross), { gross: 8, income: 4, bank: 4, low: true }, ); } }); 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, ); });