Files
Dune/tests/semuta-homeworld-emperor-income.test.ts

251 lines
16 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { quoteStableHomeworldOccupation } from '../game/homeworld-stable-occupation';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { semutaEmperorPlayer } from './fixture-semuta-emperor-share';
import { semutaEmperorIncomeCards } from './fixture-semuta-emperor-income';
import {
createSemutaHomeworldEmperorIncomeFixture, nextSemutaHomeworldEmperorIncomeStep,
reactSemutaHomeworldEmperorIncome, semutaHomeworldEmperorForceTotals,
type SemutaHomeworldEmperorIncomeFixture, type SemutaHomeworldEmperorIncomeKind,
} from './fixture-semuta-homeworld-emperor-income';
const configurations: { kind: SemutaHomeworldEmperorIncomeKind; advanced: boolean; occupied: boolean }[] = [
{ kind: 'emperorIncome', advanced: false, occupied: false },
{ kind: 'emperorIncome', advanced: true, occupied: false },
{ kind: 'emperorIncome', advanced: true, occupied: true },
{ kind: 'richesePurchaseIncome', advanced: true, occupied: false },
{ kind: 'richesePurchaseIncome', advanced: true, occupied: true },
];
/** Consume the original suffix, not a forwarded or reconstructed source. */
function exerciseOriginalIncomeSuffix(game: Game, fixture: SemutaHomeworldEmperorIncomeFixture): Game {
const wallets = game.players.map(player => ({ id: player.id, spice: player.spice }));
if (fixture.kind === 'emperorIncome') {
assert.equal(game.auction!.index, fixture.game.auction!.index + 1);
assert.deepEqual(game.auction!.cards.map(card => card.id), fixture.game.auction!.cards.map(card => card.id));
assert.equal(game.response?.kind, 'atreidesAuction');
assert.equal(game.response!.owner, 'a');
for (let n = 0; game.response?.kind === 'atreidesAuction' && n < game.players.length; n++) {
const next = nextSemutaHomeworldEmperorIncomeStep(game);
game = applyAction(game, next.actor, next.action);
}
assert.equal(game.auction!.index, fixture.game.auction!.index + 1);
const bidder = game.auction!.active;
game = applyAction(game, bidder, { type: 'bid', amount: 1 });
assert.equal(game.auction!.bidder, bidder);
assert.equal(game.auction!.index, fixture.game.auction!.index + 1);
} else {
assert.deepEqual(game.decision, fixture.beforePayment.decision);
assert.equal(game.decision!.player, fixture.buyer);
assert.equal(game.richeseCache!.length, fixture.beforePayment.richeseCache!.length - 1);
assert.equal(semutaEmperorPlayer(game, fixture.buyer).specialKaramaUsed, true);
assert.equal(game.discard.filter(card => card.id === fixture.special).length, 1);
const next = nextSemutaHomeworldEmperorIncomeStep(game);
assert.equal(next.actor, fixture.buyer);
assert.throws(() => applyAction(game, fixture.owner, next.action));
game = applyAction(game, next.actor, next.action);
// An uncanceled-income baseline still holds its Karama and can answer the
// original Richese declaration response. Follow that real queue first.
for (let n = 0; game.response?.kind === 'richeseAuction' && n < game.players.length; n++) {
const response = nextSemutaHomeworldEmperorIncomeStep(game);
game = applyAction(game, response.actor, response.action);
}
assert.equal(game.decision?.kind, 'richeseCache');
assert.equal(game.decision!.player, fixture.buyer);
assert.throws(() => applyAction(game, next.actor, next.action), 'The source declaration cannot replay');
}
assert.deepEqual(game.players.map(player => ({ id: player.id, spice: player.spice })), wallets);
return game;
}
for (const config of configurations) {
for (const claim of [false, true]) void test(
`Homeworld ${config.advanced ? 'Advanced' : 'Basic'} ${config.occupied ? 'occupied' : 'clean low'} ${config.kind}: Semuta ${claim ? 'claims' : 'passes'} before native denial and original allocation`, () => {
const f = createSemutaHomeworldEmperorIncomeFixture(config.kind, config);
assert.equal(semutaEmperorPlayer(f.game, f.buyer).spice,
semutaEmperorPlayer(f.beforePayment, f.buyer).spice - f.amount);
assert.equal(semutaEmperorPlayer(f.game, f.owner).spice,
semutaEmperorPlayer(f.beforePayment, f.owner).spice);
assert.equal(semutaEmperorPlayer(f.game, f.buyer).hand.filter(card => card.id === f.acquired).length, 1);
assert.equal(semutaEmperorPlayer(f.game, f.holder).hand.length, 4);
if (config.occupied) {
assert.ok(f.occupied);
const originalAuthority = quoteStableHomeworldOccupation(f.occupied!, 'kaitain').entitlement!;
assert.equal(originalAuthority.occupier, f.allocationOwner);
assert.equal(originalAuthority.ally, f.allocationAlly);
assert.equal(quoteStableHomeworldOccupation(f.game, 'kaitain').entitlement!.qualification,
originalAuthority.qualification, 'The original invasion epoch still owns this later source');
assert.equal(semutaEmperorPlayer(f.game, f.owner).reserves,
semutaEmperorPlayer(f.game, f.owner).elites!.reserves,
'Salusa elites are included in reserves; Kaitain has no invented extra elite stock');
}
const wallets = f.game.players.map(player => ({ id: player.id, spice: player.spice }));
const forces = semutaHomeworldEmperorForceTotals(f.game);
const receiptCount = f.game.homeworldOccupiedPercentageLedger!.receipts.length;
const pending = applyAction(f.game, f.canceler, f.cancel);
const cost = pending.pendingTreacheryDiscard;
assert.ok(cost, 'The real printed income-cancellation cost must pause for Semuta');
assert.equal(cost.batch.cause, `karama:${f.kind}`);
assert.deepEqual(cost.batch.entries, [{ card: f.karamaCard, discardedBy: f.canceler, publicFace: true }]);
assert.equal(viewGame(pending, f.holder).semutaReaction!.canCommit, true);
assert.deepEqual(viewGame(pending, f.holder).semutaReaction!.candidates, []);
assert.equal(pending.homeworldOccupiedPercentageLedger!.receipts.length, receiptCount);
assert.deepEqual(pending.players.map(player => ({ id: player.id, spice: player.spice })), wallets);
assert.equal(semutaEmperorPlayer(pending, f.buyer).hand.filter(card => card.id === f.acquired).length, 1);
assert.throws(() => applyAction(pending, f.buyer, { type: 'bid', amount: 1 }));
assert.throws(() => applyAction(pending, f.owner,
{ type: 'decision', event: cost.batch.event, ownAmount: f.occupiedAmount }));
if (f.kind === 'emperorIncome') {
assert.equal(pending.auction!.index, f.game.auction!.index);
assert.equal(pending.currentAuctionSale!.winner, f.buyer);
} else {
assert.notEqual(f.special, f.karama);
assert.equal(pending.richeseCache!.some(card => card.id === f.acquired), false);
assert.equal(pending.discard.filter(card => card.id === f.special).length, 1);
assert.equal(cost.batch.entries.some(entry => entry.card.id === f.special), false);
assert.equal(semutaEmperorPlayer(pending, f.buyer).specialKaramaUsed, true);
}
let done = reactSemutaHomeworldEmperorIncome(pending, f.holder, claim);
assert.equal(done.resolvedTreacheryDiscardSequence, cost.sequence);
assert.deepEqual(done.players.map(player => ({ id: player.id, spice: player.spice })), wallets);
assert.equal(semutaEmperorPlayer(done, f.owner).spice,
semutaEmperorPlayer(f.beforePayment, f.owner).spice, 'Native canceled credit is zero');
assert.equal(semutaEmperorPlayer(done, f.buyer).hand.filter(card => card.id === f.acquired).length, 1);
assert.equal(semutaEmperorPlayer(done, f.holder).hand.length, 4);
assert.equal(semutaEmperorPlayer(done, f.holder).hand.some(card => card.id === f.karama), claim);
assert.equal(semutaEmperorPlayer(done, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), !claim);
assert.equal(done.discard.filter(card => card.id === f.karama).length, Number(!claim));
assert.equal(done.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claim));
assert.throws(() => applyAction(done, f.canceler, f.cancel));
assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event: cost.batch.event }));
const receipt = done.homeworldOccupiedPercentageLedger!.receipts[receiptCount];
assert.ok(receipt);
assert.equal(receipt.nativeRetained, 0);
assert.equal(receipt.source.kind, 'emperor-treachery');
if (receipt.source.kind !== 'emperor-treachery') throw new Error('Wrong native source');
assert.equal(receipt.source.nativeIncome, 'karama-canceled');
assert.equal(receipt.source.amount, f.amount);
assert.equal(receipt.source.payment.paid, f.amount);
assert.deepEqual(receipt.source.payment.contributions, [{ payer: f.buyer, amount: f.amount }]);
assert.equal(receipt.source.payment.recipient, f.owner);
assert.equal(receipt.occupiedAmount, f.occupiedAmount);
assert.equal(receipt.bankRetained, f.amount - f.occupiedAmount);
if (config.occupied) {
const allocation = viewGame(done, f.allocationOwner!).homeworldOccupiedPercentage!;
assert.equal(done.decision!.kind, 'homeworldOccupiedPercentage');
assert.equal(done.decision!.player, f.allocationOwner);
assert.equal(allocation.owner, 'g');
assert.equal(allocation.ally, 'a');
assert.equal(allocation.amount, f.occupiedAmount);
assert.equal(allocation.minOwnAmount, 0);
assert.equal(allocation.maxOwnAmount, f.occupiedAmount);
assert.equal(allocation.blocked, null);
assert.deepEqual(receipt.entitlement, quoteStableHomeworldOccupation(f.game, 'kaitain').entitlement);
assert.equal(done.pendingHomeworldOccupiedPercentage!.suffix.kind,
f.kind === 'emperorIncome' ? 'auctionBonus' : 'richesePurchase');
const action = { type: 'decision', event: allocation.event, ownAmount: claim ? 0 : f.occupiedAmount };
assert.throws(() => applyAction(done, f.allocationAlly!, action), 'Only the original occupier chooses');
assert.throws(() => applyAction(done, f.owner, action));
assert.throws(() => applyAction(done, f.allocationOwner!, { ...action, event: 'stale-allocation' }));
assert.throws(() => applyAction(done, f.allocationOwner!, { ...action, ownAmount: f.occupiedAmount + 1 }));
const receiver = claim ? f.allocationAlly! : f.allocationOwner!;
done = applyAction(done, f.allocationOwner!, action);
for (const player of done.players) assert.equal(player.spice,
wallets.find(row => row.id === player.id)!.spice + (player.id === receiver ? f.occupiedAmount : 0));
const settled = done.homeworldOccupiedPercentageLedger!.receipts[receiptCount];
assert.equal(settled.status, 'settled');
assert.deepEqual(settled.source, receipt.source);
assert.deepEqual(settled.entitlement, receipt.entitlement);
assert.deepEqual(settled.credits, [{ player: receiver, amount: f.occupiedAmount }]);
assert.throws(() => applyAction(done, f.allocationOwner!, action), 'The printed share is available once');
} else {
assert.equal(receipt.status, 'settled');
assert.equal(receipt.entitlement, null);
assert.deepEqual(receipt.credits, []);
}
done = exerciseOriginalIncomeSuffix(done, f);
assert.deepEqual(semutaEmperorIncomeCards(done), f.originalCards);
assert.deepEqual(semutaHomeworldEmperorForceTotals(done), forces);
},
);
}
for (const config of configurations) void test(
`uncanceled ${config.advanced ? 'Advanced' : 'Basic'} ${config.occupied ? 'occupied' : 'clean low'} ${config.kind} retains original ceil native income`, () => {
const f = createSemutaHomeworldEmperorIncomeFixture(config.kind, config);
const native = semutaEmperorPlayer(f.game, f.owner).spice;
const buyer = semutaEmperorPlayer(f.game, f.buyer).spice;
let done = f.game;
for (let n = 0; done.response?.kind === f.kind && n < done.players.length; n++) {
const next = nextSemutaHomeworldEmperorIncomeStep(done);
done = applyAction(done, next.actor, next.action);
}
assert.notEqual(done.response?.kind, f.kind);
assert.equal(semutaEmperorPlayer(done, f.owner).spice, native + f.nativeIncome);
assert.equal(semutaEmperorPlayer(done, f.buyer).spice, buyer);
if (config.occupied) {
const allocation = viewGame(done, f.allocationOwner!).homeworldOccupiedPercentage!;
assert.equal(allocation.amount, f.occupiedAmount);
done = applyAction(done, allocation.owner, { type: 'decision', event: allocation.event, ownAmount: f.occupiedAmount });
}
exerciseOriginalIncomeSuffix(done, f);
},
);
for (const kind of ['emperorIncome', 'richesePurchaseIncome'] as const) void test(
`a legal full-hand Easy bot completes occupied ${kind} cost before original owner allocation`, () => {
const f = createSemutaHomeworldEmperorIncomeFixture(kind, { occupied: true });
const pending = applyAction(f.game, f.canceler, f.cancel);
const view = viewGame(pending, f.holder);
view.players.find(player => player.id === f.holder)!.bot = 'Easy';
const action = botActions(view)[0];
assert.ok(action);
assert.equal(action.type, 'semutaCommit');
let done = applyAction(pending, f.holder, action);
assert.equal(semutaEmperorPlayer(done, f.holder).hand.length, 4);
assert.equal(semutaEmperorPlayer(done, f.holder).hand.some(card => card.id === f.karama), true);
assert.equal(done.decision!.player, f.allocationOwner);
const ownerView = viewGame(done, f.allocationOwner!);
const ownerBalance = semutaEmperorPlayer(done, f.allocationOwner!).spice;
const allyBalance = semutaEmperorPlayer(done, f.allocationAlly!).spice;
ownerView.players.find(player => player.id === f.allocationOwner)!.bot = 'Easy';
const allocationAction = botActions(ownerView)[0];
assert.ok(allocationAction);
done = applyAction(done, f.allocationOwner!, allocationAction);
assert.equal(semutaEmperorPlayer(done, f.owner).spice, semutaEmperorPlayer(f.game, f.owner).spice);
const ownerCredit = semutaEmperorPlayer(done, f.allocationOwner!).spice - ownerBalance;
const allyCredit = semutaEmperorPlayer(done, f.allocationAlly!).spice - allyBalance;
assert.ok(ownerCredit >= 0 && allyCredit >= 0);
assert.equal(ownerCredit + allyCredit, f.occupiedAmount,
'A legal bot allocates the original printed share without pinning its strategy');
assert.equal(done.homeworldOccupiedPercentageLedger!.receipts.at(-1)!.status, 'settled');
exerciseOriginalIncomeSuffix(done, f);
},
);
for (const claim of [false, true]) void test(
`occupied Kaitain newly purchased original Karama is its own foreign cost, Semuta ${claim ? 'claims' : 'passes'}`, () => {
const f = createSemutaHomeworldEmperorIncomeFixture('emperorIncome', { occupied: true, acquiredKarama: true });
assert.equal(f.acquired, f.karama);
const pending = applyAction(f.game, f.canceler, f.cancel);
assert.ok(pending.pendingTreacheryDiscard);
assert.equal(pending.pendingTreacheryDiscard.batch.entries[0].card.id, f.acquired);
assert.equal(pending.pendingTreacheryDiscard.batch.entries[0].discardedBy, f.buyer);
assert.equal(semutaEmperorPlayer(pending, f.buyer).hand.some(card => card.id === f.acquired), false);
let done = reactSemutaHomeworldEmperorIncome(pending, f.holder, claim);
assert.equal(semutaEmperorPlayer(done, f.buyer).spice,
semutaEmperorPlayer(f.beforePayment, f.buyer).spice - 5, 'Buying then spending never refunds the source');
assert.equal(semutaEmperorPlayer(done, f.holder).hand.some(card => card.id === f.acquired), claim);
assert.equal(done.discard.filter(card => card.id === f.acquired).length, Number(!claim));
const allocation = viewGame(done, f.allocationOwner!).homeworldOccupiedPercentage!;
assert.equal(allocation.amount, 2);
done = applyAction(done, allocation.owner, { type: 'decision', event: allocation.event, ownAmount: 2 });
assert.equal(semutaEmperorPlayer(done, f.owner).spice, semutaEmperorPlayer(f.beforePayment, f.owner).spice);
exerciseOriginalIncomeSuffix(done, f);
assert.deepEqual(semutaEmperorIncomeCards(done), f.originalCards);
},
);