204 lines
14 KiB
TypeScript
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);
|
|
});
|