251 lines
16 KiB
TypeScript
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);
|
|
},
|
|
);
|