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

204 lines
14 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 { homeworldFundingCards } from './fixture-bureaucrat-homeworld-funding';
import { assertClassicHomeworldSkillsCustody } from './fixture-homeworld-classic-skills';
import {
advanceOccupiedEmperor, allocateOccupiedEmperor as allocate,
allowOccupiedEmperorResponses as allow, chooseOccupiedEmperor as choose,
createOccupiedEmperorFixture as fixture, occupiedEmperorClean,
occupiedEmperorPlayer as player, occupiedEmperorWallets as wallets,
payOccupiedEmperor as pay, type OccupiedEmperorFixture,
} from './fixture-bureaucrat-occupied-emperor';
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
/** Full debit evidence precedes either skill or occupation allocation. */
function assertPaid(f: OccupiedEmperorFixture, paid: Game): void {
const expected = wallets(f.beforePledge);
expected[f.payer] -= f.payerShare; expected[f.donor] -= f.donorShare;
assert.deepEqual(wallets(paid), expected);
assert.equal(paid.currentAuctionSale!.amount, f.gross);
assert.deepEqual(paid.currentAuctionSale!.occupiedContributions,
[{ payer: f.payer, amount: f.payerShare }, ...(f.donorShare ? [{ payer: f.donor, amount: f.donorShare }] : [])]);
assert.equal(paid.aid[f.donor]?.amount ?? 0, 0);
}
function assertOutcome(f: OccupiedEmperorFixture, allocation: Game, done: Game,
diverted: 0 | 2, canceled = false, ownAmount?: number): void {
const pending = allocation.pendingHomeworldOccupiedPercentage!;
const source = pending.source;
assert.ok(source.kind === 'emperor-treachery');
const grossAfterBank = f.gross - diverted;
const native = canceled ? 0 : Math.ceil(grossAfterBank / 2), occupied = Math.floor(grossAfterBank / 2);
const own = ownAmount ?? occupied;
assert.equal(source.amount, grossAfterBank); assert.equal(source.payment.paid, f.gross);
assert.deepEqual(source.payment.contributions,
[{ payer: f.payer, amount: f.payerShare }, ...(f.donorShare ? [{ payer: f.donor, amount: f.donorShare }] : [])]);
if (diverted) assert.equal(source.payment.bankDiversion, 2);
else assert.equal(Object.hasOwn(source.payment, 'bankDiversion'), false);
assert.equal(source.nativeIncome, canceled ? 'karama-canceled' : 'received');
const expected = wallets(f.beforePledge);
expected[f.payer] -= f.payerShare; expected[f.donor] -= f.donorShare;
expected[f.payee] += native; expected[f.occupier] += own;
const receipt = done.homeworldOccupiedPercentageLedger!.receipts.find(r => r.source.event === source.event)!;
if (occupied - own) expected[receipt.entitlement!.ally!] += occupied - own;
assert.deepEqual(wallets(done), expected);
assert.equal(receipt.nativeRetained, native); assert.equal(receipt.occupiedAmount, occupied);
assert.equal(receipt.status, 'settled'); assert.equal(receipt.ownAmount, own);
assert.deepEqual(receipt.credits, [{ player: f.occupier, amount: own },
...(occupied - own ? [{ player: receipt.entitlement!.ally!, amount: occupied - own }] : [])]);
assert.equal(Object.values(wallets(f.beforePledge)).reduce((a, b) => a + b, 0) -
Object.values(wallets(done)).reduce((a, b) => a + b, 0), canceled ? Math.ceil(f.gross / 2) : diverted);
assert.equal(done.bureaucratPayments?.used.length ?? 0, diverted ? 1 : 0);
assert.deepEqual(player(done, f.payer).hand.map(c => c.id),
[...player(f.beforeAction, f.payer).hand.map(c => c.id), f.lot, f.bonus]);
assert.equal(done.auction!.index, f.beforeAction.auction!.index + 1);
assert.equal(done.currentAuctionSale, null); assert.equal(done.pendingHomeworldOccupiedPercentage, null);
assert.deepEqual(homeworldFundingCards(done), f.physical);
assert.deepEqual(done.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites })),
f.beforePledge.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites })));
assert.deepEqual(done.homeworlds!.custody, f.beforePledge.homeworlds!.custody);
assertClassicHomeworldSkillsCustody(done);
}
void test('Basic native paid auction empties original Kaitain with a real twenty-counter shipment and qualifies a one-counter Guild invasion', () => {
const f = fixture();
assert.equal(f.setup.homeworldOccupationPreview, true);
assert.equal(f.setup.leaderSkills!.deck.length + f.setup.leaderSkills!.assignments.length, 14);
assert.equal(f.setup.leaderSkills!.assignments.find(a => a.skill === 'bureaucrat')!.owner, f.trainer);
assert.equal(player(f.fundedEmperor, f.payee).spice, 20);
assert.equal(player(f.emptyKaitain, f.payee).spice, 0);
assert.equal(player(f.emptyKaitain, f.payee).reserves, 0);
assert.equal(Object.values(player(f.emptyKaitain, f.payee).forces).reduce((a, b) => a + b, 0), 20);
assert.equal(f.occupied.homeworlds!.custody!.visitors['homeworld:emperor'][f.occupier].normal, 1);
const authority = quoteStableHomeworldOccupation(f.occupied, 'kaitain');
assert.equal(authority.blocked, null); assert.equal(authority.entitlement!.occupier, f.occupier);
assert.equal(player(f.alliance, f.payer).ally, f.donor);
assert.equal(player(f.alliance, f.donor).ally, f.payer);
for (const boundary of [f.setup, f.emptyKaitain, f.occupied, f.beforeAction]) assertClassicHomeworldSkillsCustody(boundary);
});
for (const redirect of [true, false]) void test(`occupied gross8 ${redirect ? 'diverts before splitting to Emperor3/Guild3/Bank2' : 'declines to Emperor4/Guild4'} and delivers the original lot/bonus once`, () => {
const f = fixture(), paid = pay(f); assertPaid(f, paid);
const pending = allow(paid);
assert.equal(pending.decision?.kind, 'bureaucratPayment'); assert.equal(pending.decision!.player, f.trainer);
const allocation = choose(pending, redirect);
assert.equal(allocation.decision?.kind, 'homeworldOccupiedPercentage');
assert.equal(allocation.decision!.player, f.occupier);
assert.equal(player(allocation, f.payee).spice - player(f.beforePledge, f.payee).spice, redirect ? 3 : 4);
assert.equal(player(allocation, f.occupier).spice, player(f.beforePledge, f.occupier).spice - 3);
const done = allocate(allocation); assertOutcome(f, allocation, done, redirect ? 2 : 0);
reject(done, f.trainer, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true });
reject(done, f.occupier, { type: 'decision', event: allocation.pendingHomeworldOccupiedPercentage!.source.event, ownAmount: redirect ? 3 : 4 });
});
void test('original occupied sharing suffix legally credits the reciprocal ally directly after gross-minus-two', () => {
const f = fixture(), allocation = choose(allow(pay(f)), true);
assert.equal(viewGame(allocation, f.occupier).homeworldOccupiedPercentage!.ally, f.payer);
const done = allocate(allocation, 1); assertOutcome(f, allocation, done, 2, false, 1);
reject(allocation, f.payer, { type: 'decision', event: allocation.pendingHomeworldOccupiedPercentage!.source.event, ownAmount: 1 });
reject(allocation, f.occupier, { type: 'decision', event: 'stale-occupied-source', ownAmount: 1 });
});
void test('actual 3+5 qualifies on the donor; actual 4+4 cannot pool two sub-five debits', () => {
const eligible = fixture({ payerShare: 3, donorShare: 5 }), paid = pay(eligible); assertPaid(eligible, paid);
const pending = allow(paid); assert.equal(pending.decision?.kind, 'bureaucratPayment');
const allocation = choose(pending, true); assertOutcome(eligible, allocation, allocate(allocation), 2);
const pooled = fixture({ payerShare: 4, donorShare: 4 }), pooledPaid = pay(pooled); assertPaid(pooled, pooledPaid);
const direct = allow(pooledPaid); assert.equal(direct.decision?.kind, 'homeworldOccupiedPercentage');
assertOutcome(pooled, direct, allocate(direct), 0);
});
for (const donor of ['atreides', 'emperor'] as const) void test(`occupied funding excludes ${donor === 'atreides' ? 'trainer' : 'native recipient'} share without masking the independent qualified buyer`, () => {
const excluded = fixture({ donor, payerShare: 3, donorShare: 5 }), paid = pay(excluded); assertPaid(excluded, paid);
const allocation = allow(paid); assert.equal(allocation.decision?.kind, 'homeworldOccupiedPercentage');
assertOutcome(excluded, allocation, allocate(allocation), 0);
const eligible = fixture({ donor, payerShare: 5, donorShare: 5 }), eligiblePaid = pay(eligible); assertPaid(eligible, eligiblePaid);
const pending = allow(eligiblePaid); assert.equal(pending.decision?.kind, 'bureaucratPayment');
const redirected = choose(pending, true); assertOutcome(eligible, redirected, allocate(redirected), 2);
});
void test('native Karama cancellation preserves occupied4, retains full paid8 and never offers or spends Bureaucrat', () => {
const f = fixture(), paid = pay(f); assertPaid(f, paid);
assert.equal(paid.response?.kind, 'emperorIncome');
assert.ok(viewGame(paid, f.trainer).responseControls!.cancelCards.includes(f.karama));
const allocation = allow(applyAction(paid, f.trainer, { type: 'card', mode: 'cancel', card: f.karama }));
assert.equal(allocation.decision?.kind, 'homeworldOccupiedPercentage');
const done = allocate(allocation); assertOutcome(f, allocation, done, 0, true);
assert.equal(done.discard.filter(c => c.id === f.karama).length, 1);
assert.ok(!player(done, f.trainer).hand.some(c => c.id === f.karama));
reject(done, f.trainer, { type: 'card', mode: 'cancel', card: f.karama });
});
void test('occupied gross8 invoice keeps private payer evidence private, rejects stale/wrong actors, and all four minimal owner bots settle real money', () => {
const f = fixture(), pending = allow(pay(f));
for (const viewer of pending.players) {
const invoice = viewGame(pending, viewer.id).bureaucrat.pending!;
assert.equal(invoice.amount, 8); assert.equal(invoice.payer, null); assert.equal(invoice.payee, f.payee);
for (const key of ['contributions', 'shares', 'qualifyingPayer', 'privateFunding', 'signature', 'source'])
assert.equal(key in invoice, false);
}
reject(pending, f.payer, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true });
reject(pending, f.trainer, { type: 'decision', event: 'stale-bureaucrat-source', redirect: true });
reject(pending, f.donor, { type: 'pledgeAid', amount: 1 });
for (const difficulty of DIFFICULTIES) {
const view = viewGame(pending, f.trainer); view.players.find(p => p.id === f.trainer)!.bot = difficulty;
const actions = botActions(view); assert.equal(actions.length, 1);
const allocation = applyAction(pending, f.trainer, actions[0]);
assert.equal(allocation.decision?.kind, 'homeworldOccupiedPercentage');
assertOutcome(f, allocation, allocate(allocation), 2);
}
});
void test('redirect spends only one phase use; decline leaves it for the next actual funded lot and each physical bonus occurs once', () => {
for (const firstRedirect of [true, false]) {
const f = fixture(), firstPending = allow(pay(f)), firstAllocation = choose(firstPending, firstRedirect);
const first = allocate(firstAllocation); assertOutcome(f, firstAllocation, first, firstRedirect ? 2 : 0);
let next = advanceOccupiedEmperor(first, g => !!g.auction && g.auction.active === f.payer && occupiedEmperorClean(g));
const beforePledge = structuredClone(next);
next = applyAction(next, f.donor, { type: 'pledgeAid', amount: 5 });
const second: OccupiedEmperorFixture = { ...f, game: next, beforePledge, beforeAction: structuredClone(next),
payerShare: 0, donorShare: 5, gross: 5, action: { type: 'bid', amount: 5, allyPayment: 5 },
lot: next.auction!.cards[next.auction!.index].id, bonus: next.deck[0].id };
const paid = pay(second); assertPaid(second, paid);
let allocation = allow(paid);
if (firstRedirect) assert.equal(allocation.decision?.kind, 'homeworldOccupiedPercentage');
else {
assert.equal(allocation.decision?.kind, 'bureaucratPayment');
reject(allocation, f.trainer, { type: 'decision', event: firstPending.bureaucratPaymentEvent, redirect: true });
allocation = choose(allocation, true);
}
const done = allocate(allocation);
const expected = wallets(beforePledge); expected[f.donor] -= 5;
expected[f.payee] += firstRedirect ? 3 : 2; expected[f.occupier] += firstRedirect ? 2 : 1;
assert.deepEqual(wallets(done), expected);
assert.equal(done.bureaucratPayments!.used.length, 1);
assert.deepEqual(player(done, f.payer).hand.map(c => c.id),
[...player(first, f.payer).hand.map(c => c.id), second.lot, second.bonus]);
assert.equal(done.auction!.index, first.auction!.index + 1);
assert.deepEqual(homeworldFundingCards(done), f.physical);
assertClassicHomeworldSkillsCustody(done);
}
});
for (const redirect of [false, true]) void test(`Advanced occupied Emperor ${redirect ? 'diversion' : 'allowance'} preserves the original two-world departure and next paid lot`, () => {
const f = fixture({ advanced: true });
assert.equal(player(f.setup, f.payee).elites!.reserves, 5);
assert.equal(player(f.emptyKaitain, f.payee).reserves, 0);
assert.equal(player(f.emptyKaitain, f.payee).elites!.reserves, 0);
assert.equal(Object.values(player(f.emptyKaitain, f.payee).forces).reduce((a,b) => a+b, 0), 20);
assert.equal(Object.values(player(f.emptyKaitain, f.payee).elites!.forces).reduce((a,b) => a+b, 0), 5);
const paid = allow(pay(f)); assertPaid(f, paid);
const split = choose(paid, redirect), done = allocate(split);
assertOutcome(f, split, done, redirect ? 2 : 0);
assert.equal(quoteStableHomeworldOccupation(done, 'kaitain').entitlement!.qualification,
quoteStableHomeworldOccupation(f.occupied, 'kaitain').entitlement!.qualification);
});