Files
Dune/tests/bureaucrat-occupied-guild.test.ts
T

285 lines
15 KiB
TypeScript

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);
});