import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { quoteStableHomeworldOccupation } from '../game/homeworld-stable-occupation'; import type { OccupiedPercentageReceipt } from '../game/homeworld-occupied-percentage'; import { assertClassicHomeworldSkillsCustody } from './fixture-homeworld-classic-skills'; import { allocateOccupiedGuild, allowOccupiedGuildIncome, cancelOccupiedGuildIncome, chooseOccupiedGuild, createOccupiedGuildFixture, declareOccupiedGuild, occupiedGuildArmies, occupiedGuildCards, occupiedGuildClean, occupiedGuildPlayer, occupiedGuildWallets, advanceOccupiedGuild, type OccupiedGuildFixture, } from './fixture-bureaucrat-occupied-guild'; const wallets = occupiedGuildWallets; const armies = occupiedGuildArmies; /** The frozen receipt keeps its original producer proof; read it only after the * discriminant is checked. */ function guildShippingSource(receipt: OccupiedPercentageReceipt) { const source = receipt.source; if (source.kind !== 'guild-shipping') throw new Error('Expected the original Guild shipping source.'); return source; } function reject(game: Game, actor: string, action: Action): void { const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before); } /** Exact wallet conservation: the payer and the pledged ally pay their original * shares once, the native keeps its half, the occupier takes the other half and * only the ruled diversion (or the canceled native half) leaves for the Bank. */ function outcome(f: OccupiedGuildFixture, done: Game, diverted: number, canceled = false): void { const expected = wallets(f.beforePledge); expected[f.payer] -= f.payerShare; expected[f.donor] -= f.donorShare; const amount = canceled ? f.gross : f.gross - diverted; if (!canceled) expected[f.payee] += Math.ceil(amount / 2); expected[f.occupier] += Math.floor(amount / 2); assert.deepEqual(wallets(done), expected); const before = Object.values(wallets(f.beforePledge)).reduce((a, b) => a + b, 0); const after = Object.values(wallets(done)).reduce((a, b) => a + b, 0); assert.equal(before - after, canceled ? Math.floor(amount / 2) : diverted, 'Only the ruled diversion or the canceled native half leaves the seated wallets for the Bank'); assert.equal(done.aid[f.donor]?.amount ?? 0, 0); assert.deepEqual(occupiedGuildCards(done), f.physical); assertClassicHomeworldSkillsCustody(done); } void test('native Guild occupation redirects two of a foreign shipping payment to the Bank after the full payer and ally debits', () => { const f = createOccupiedGuildFixture(); assert.equal(f.payerShare + f.donorShare, 8); assert.equal(quoteStableHomeworldOccupation(f.junctionEmpty, 'junction').entitlement, null); const authority = quoteStableHomeworldOccupation(f.occupied, 'junction'); assert.equal(authority.blocked, null); assert.equal(authority.entitlement?.occupier, f.occupier); assert.equal(authority.entitlement?.native, f.payee); assert.deepEqual(f.occupied.homeworlds!.custody!.visitors['homeworld:guild'], { harkonnen: { normal: 1, elite: 0 } }); assert.equal(occupiedGuildPlayer(f.junctionEmpty, f.payee).reserves, 0); const declared = declareOccupiedGuild(f); assert.equal(declared.response?.kind, 'guildIncome'); assert.equal(declared.response?.amount, f.gross); assert.equal(occupiedGuildPlayer(declared, f.payer).spice, occupiedGuildPlayer(f.beforePledge, f.payer).spice - f.payerShare); assert.equal(occupiedGuildPlayer(declared, f.donor).spice, occupiedGuildPlayer(f.beforePledge, f.donor).spice - f.donorShare); assert.equal(occupiedGuildPlayer(declared, f.payee).spice, occupiedGuildPlayer(f.beforePledge, f.payee).spice); assert.deepEqual(occupiedGuildPlayer(declared, f.payer).forces, { ...occupiedGuildPlayer(f.beforePledge, f.payer).forces, [`${f.territory}:${f.sector}`]: f.gross / 2, }); assert.equal(occupiedGuildPlayer(declared, f.payer).reserves, occupiedGuildPlayer(f.beforePledge, f.payer).reserves - f.gross / 2); assert.equal(occupiedGuildPlayer(declared, f.payer).shipped, true); for (const other of declared.players.filter(player => player.id !== f.payer)) { assert.deepEqual(other.forces, occupiedGuildPlayer(f.beforePledge, other.id).forces); assert.equal(other.reserves, occupiedGuildPlayer(f.beforePledge, other.id).reserves); } const pending = allowOccupiedGuildIncome(declared); assert.equal(pending.decision?.kind, 'bureaucratPayment'); assert.equal(pending.decision?.player, f.trainer); for (const viewer of pending.players) { const view = viewGame(pending, viewer.id), invoice = view.bureaucrat.pending!; assert.equal(invoice.payer, null); assert.equal(invoice.payee, f.payee); assert.equal(invoice.owner, f.trainer); assert.equal(invoice.amount, f.gross); assert.equal(invoice.homeworldAllocation, true); assert.equal(view.bureaucrat.usedThisPhase, false); for (const field of ['contributions', 'shares', 'qualifyingPayer', 'signature', 'source', 'privateFunding']) assert.equal(field in invoice, false, `${field} is private funding evidence, not a public invoice`); assert.equal('homeworldOccupiedPercentageLedger' in view, false); } reject(pending, f.donor, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true }); reject(pending, f.trainer, { type: 'decision', event: 'expired-bureaucrat-event', redirect: true }); reject(pending, f.payer, { type: 'endMovement' }); const redirected = chooseOccupiedGuild(pending, true); assert.equal(redirected.decision?.kind, 'homeworldOccupiedPercentage'); assert.equal(redirected.decision?.player, f.occupier); assert.equal(occupiedGuildPlayer(redirected, f.payee).spice, occupiedGuildPlayer(f.beforePledge, f.payee).spice + 3); const offer = viewGame(redirected, f.occupier).homeworldOccupiedPercentage!; assert.equal(offer.world, 'homeworld:guild'); assert.equal(offer.name, 'Junction'); assert.equal(offer.owner, f.occupier); assert.equal(offer.ally, f.donor); assert.equal(offer.amount, 3); assert.equal(offer.minOwnAmount, 0); assert.equal(offer.maxOwnAmount, 3); assert.equal(offer.blocked, null); const done = allocateOccupiedGuild(redirected); const receipt = done.homeworldOccupiedPercentageLedger!.receipts.at(-1)!; const source = guildShippingSource(receipt); assert.equal(source.nativeIncome, 'received'); assert.equal(source.native, f.payee); assert.equal(source.amount, 6); assert.equal(source.payment.recipient, f.payee); assert.ok(source.payment.id.length > 0); assert.ok(source.payment.binding.length > 0); assert.equal(source.payment.paid, 8, 'the receipt keeps the original paid amount'); assert.equal(source.payment.bankDiversion, 2); assert.deepEqual(source.payment.contributions, [ { payer: f.payer, amount: 6 }, { payer: f.donor, amount: 2 }, ]); assert.equal(receipt.status, 'settled'); assert.equal(receipt.nativeRetained, 3); assert.equal(receipt.occupiedAmount, 3); assert.equal(receipt.bankRetained, 0); assert.equal(receipt.ownAmount, 3); assert.deepEqual(receipt.credits, [{ player: f.occupier, amount: 3 }]); assert.equal(receipt.entitlement?.occupier, f.occupier); assert.equal(receipt.entitlement?.ally, f.donor); assert.equal(occupiedGuildPlayer(done, f.payer).forces[`${f.territory}:${f.sector}`], f.gross / 2); assert.equal(occupiedGuildPlayer(done, f.payer).reserves, occupiedGuildPlayer(f.beforePledge, f.payer).reserves - f.gross / 2); assert.equal(occupiedGuildPlayer(done, f.payer).shipped, true); assert.deepEqual(armies(done), armies(declared), 'The ruled diversion never rewrites the committed force shipment'); assert.equal(done.bureaucratPayments?.used.length, 1); assert.equal(viewGame(done, f.trainer).bureaucrat.usedThisPhase, true); outcome(f, done, 2); assert.notEqual(applyAction(done, done.active!, { type: 'endMovement' }).active, f.payer); }); void test('the same occupied shipping payment declined keeps four with the native Guild and four with the occupier', () => { const f = createOccupiedGuildFixture(); const decline = allocateOccupiedGuild(chooseOccupiedGuild(allowOccupiedGuildIncome(declareOccupiedGuild(f)), false)); const receipt = decline.homeworldOccupiedPercentageLedger!.receipts.at(-1)!; assert.equal(receipt.source.amount, 8); assert.equal(receipt.nativeRetained, 4); assert.equal(receipt.occupiedAmount, 4); assert.equal(receipt.bankRetained, 0); assert.equal(receipt.ownAmount, 4); assert.deepEqual(receipt.credits, [{ player: f.occupier, amount: 4 }]); assert.equal(decline.bureaucratPayments?.used.length ?? 0, 0); assert.equal(viewGame(decline, f.trainer).bureaucrat.usedThisPhase, false); outcome(f, decline, 0); }); void test('an original Karama cancellation removes the offer and banks the native half while the occupied half still settles', () => { const f = createOccupiedGuildFixture(); const paid = declareOccupiedGuild(f); const canceled = cancelOccupiedGuildIncome(f, paid); assert.equal(canceled.response, null); assert.equal(canceled.decision?.kind, 'homeworldOccupiedPercentage'); assert.equal(canceled.bureaucratPayments?.pending, undefined); assert.equal(canceled.discard.filter(card => card.id === f.karama).length, 1); reject(canceled, f.payee, { type: 'card', mode: 'cancel', card: f.karama }); const done = allocateOccupiedGuild(canceled); const receipt = done.homeworldOccupiedPercentageLedger!.receipts.at(-1)!; const source = guildShippingSource(receipt); assert.equal(source.nativeIncome, 'karama-canceled'); assert.equal(source.amount, 8); assert.equal(source.payment.paid, 8); assert.equal(source.payment.bankDiversion, undefined); assert.deepEqual(source.payment.contributions, [ { payer: f.payer, amount: 6 }, { payer: f.donor, amount: 2 }, ]); assert.equal(receipt.nativeRetained, 0); assert.equal(receipt.occupiedAmount, 4); assert.equal(receipt.bankRetained, 4); assert.deepEqual(receipt.credits, [{ player: f.occupier, amount: 4 }]); assert.equal(done.bureaucratPayments?.used.length ?? 0, 0); assert.equal(occupiedGuildPlayer(done, f.payee).spice, occupiedGuildPlayer(f.beforePledge, f.payee).spice); outcome(f, done, 0, true); }); void test('sub-five occupied Junction payer shares do not create a Bureaucrat offer', () => { const subFive = createOccupiedGuildFixture({ payerShare: 2, donorShare: 2 }); const settled = allocateOccupiedGuild(allowOccupiedGuildIncome(declareOccupiedGuild(subFive))); assert.equal(settled.bureaucratPayments?.used.length ?? 0, 0); assert.equal(settled.homeworldOccupiedPercentageLedger!.receipts.at(-1)!.occupiedAmount, 2); assert.equal(settled.homeworldOccupiedPercentageLedger!.receipts.at(-1)!.nativeRetained, 2); outcome(subFive, settled, 0); }); void test('a single payer and a fully pledged ally both settle the occupied Junction payment once', () => { const single = createOccupiedGuildFixture({ payerShare: 8, donorShare: 0 }); const singleDone = allocateOccupiedGuild(chooseOccupiedGuild( allowOccupiedGuildIncome(declareOccupiedGuild(single)), true)); assert.equal(singleDone.bureaucratPayments?.used.length, 1); assert.equal(singleDone.homeworldOccupiedPercentageLedger!.receipts.at(-1)!.source.amount, 6); outcome(single, singleDone, 2); const pledged = createOccupiedGuildFixture({ payer: 'emperor', donor: 'harkonnen', occupier: 'harkonnen', payerShare: 0, donorShare: 8, }); const declared = declareOccupiedGuild(pledged); assert.equal(occupiedGuildPlayer(declared, pledged.payer).spice, occupiedGuildPlayer(pledged.beforePledge, pledged.payer).spice, 'The payer contributes nothing of its own spice'); const pledgedDone = allocateOccupiedGuild(chooseOccupiedGuild( allowOccupiedGuildIncome(declared), true)); assert.equal(pledgedDone.bureaucratPayments?.used.length, 1); outcome(pledged, pledgedDone, 2); }); void test('the Bureaucrat use is once per phase and a later genuine payment in a new phase renews it', () => { for (const redirect of [false, true]) { const f = createOccupiedGuildFixture(); const settled = allocateOccupiedGuild(chooseOccupiedGuild( allowOccupiedGuildIncome(declareOccupiedGuild(f)), redirect)); const following = applyAction(settled, f.payer, { type: 'bribe', target: f.payee, amount: 5 }); let used: Game; if (redirect) { assert.notEqual(following.decision?.kind, 'bureaucratPayment'); assert.equal(occupiedGuildPlayer(following, f.payee).bribes, occupiedGuildPlayer(settled, f.payee).bribes + 5); used = following; } else { assert.equal(following.decision?.kind, 'bureaucratPayment'); used = chooseOccupiedGuild(following, true); assert.equal(occupiedGuildPlayer(used, f.payee).bribes, occupiedGuildPlayer(settled, f.payee).bribes + 3); } assert.equal(used.bureaucratPayments!.used.length, 1); const ended = applyAction(used, f.payer, { type: 'endMovement' }); const next = advanceOccupiedGuild(ended, g => g.turn === 3 && g.phase === 1 && occupiedGuildClean(g)); assert.equal(viewGame(next, f.trainer).bureaucrat.usedThisPhase, false); const renewed = chooseOccupiedGuild(applyAction(next, f.payer, { type: 'bribe', target: f.payee, amount: 5 }), true); assert.equal(renewed.bureaucratPayments!.used.length, 2); assert.deepEqual(renewed.bureaucratPayments!.used.map(use => [use.turn, use.phase]), [[2, 5], [3, 1]]); assert.equal(viewGame(renewed, f.trainer).bureaucrat.usedThisPhase, true); assertClassicHomeworldSkillsCustody(renewed); } }); void test('every owner bot resolves the occupied Junction offer and the native payment stays in real custody', () => { const f = createOccupiedGuildFixture(); const pending = allowOccupiedGuildIncome(declareOccupiedGuild(f)); for (const bot of DIFFICULTIES) { const view = viewGame(pending, f.trainer); view.players.find(p => p.id === f.trainer)!.bot = bot; const actions = botActions(view); assert.equal(actions.length, 1, `${bot} has exactly one legal Bureaucrat choice`); const done = allocateOccupiedGuild(applyAction(pending, f.trainer, actions[0])); assert.equal(done.bureaucratPayments?.used.length, 1); assert.equal(done.homeworldOccupationPreview, true); assert.equal(done.homeworlds!.custody!.visitors['homeworld:guild'][f.occupier].normal, 1); outcome(f, done, 2); } }); for (const redirect of [false, true]) void test(`Advanced occupied Guild ${redirect ? 'diversion' : 'allowance'} follows actual flexible timing without replaying a past phase`, () => { const f = createOccupiedGuildFixture({ advanced: true }); assert.ok(f.beforePledge.turn > f.occupied.turn); const paid = declareOccupiedGuild(f); const pending = allowOccupiedGuildIncome(paid); const done = allocateOccupiedGuild(chooseOccupiedGuild(pending, redirect)); outcome(f, done, redirect ? 2 : 0); assert.deepEqual(armies(done), armies(paid)); const receipt = done.homeworldOccupiedPercentageLedger!.receipts.at(-1)!; assert.equal(receipt.nativeRetained, redirect ? 3 : 4); assert.equal(receipt.occupiedAmount, redirect ? 3 : 4); assert.equal(guildShippingSource(receipt).payment.paid, 8); assert.deepEqual(guildShippingSource(receipt).payment.contributions, [{payer:f.payer,amount:6},{payer:f.donor,amount:2}]); assert.equal(quoteStableHomeworldOccupation(done, 'junction').entitlement!.qualification, quoteStableHomeworldOccupation(f.occupied, 'junction').entitlement!.qualification); });