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

143 lines
9.6 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { semutaEmperorPlayer } from './fixture-semuta-emperor-share';
import {
createSemutaEmperorIncomeFixture, nextSemutaEmperorIncomeStep,
reactSemutaEmperorIncome, semutaEmperorIncomeCards,
type SemutaEmperorIncomeKind,
} from './fixture-semuta-emperor-income';
for (const kind of ['emperorIncome', 'richesePurchaseIncome'] as SemutaEmperorIncomeKind[]) {
for (const claim of [false, true]) void test(
`Semuta ${claim ? 'claims' : 'passes'} the foreign Karama canceling native ${kind} without repeating its paid source`, () => {
const f = createSemutaEmperorIncomeFixture(kind);
const originalBuyer = semutaEmperorPlayer(f.beforePayment, f.buyer);
const originalOwner = semutaEmperorPlayer(f.beforePayment, f.owner);
assert.equal(semutaEmperorPlayer(f.game, f.buyer).spice, originalBuyer.spice - f.amount);
assert.equal(semutaEmperorPlayer(f.game, f.owner).spice, originalOwner.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);
const wallets = f.game.players.map(player => ({ id: player.id, spice: player.spice }));
const pending = applyAction(f.game, f.canceler, f.cancel);
const cost = pending.pendingTreacheryDiscard!;
assert.ok(cost, 'The actual paid income cancellation must first offer its printed Karama cost');
assert.equal(cost.batch.cause, `karama:${kind}`);
assert.deepEqual(cost.batch.entries, [{ card: f.karamaCard, discardedBy: f.canceler, publicFace: true }]);
assert.equal(viewGame(pending, f.holder).semutaReaction!.canCommit, true,
'A genuine full hand can exchange Semuta for the foreign cancellation cost');
assert.deepEqual(viewGame(pending, f.holder).semutaReaction!.candidates, []);
assert.equal(semutaEmperorPlayer(pending, f.buyer).hand.filter(card => card.id === f.acquired).length, 1);
assert.deepEqual(pending.players.map(player => ({ id: player.id, spice: player.spice })), wallets);
if (kind === 'emperorIncome') {
assert.equal(pending.auction!.index, f.game.auction!.index, 'The original next lot must wait for Semuta');
assert.equal(pending.currentAuctionSale!.winner, f.buyer);
} else {
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,
'Richese never claims its own separately spent activation');
assert.equal(semutaEmperorPlayer(pending, f.buyer).specialKaramaUsed, true);
}
const done = reactSemutaEmperorIncome(pending, f, claim);
assert.equal(done.resolvedTreacheryDiscardSequence, cost.sequence);
assert.deepEqual(done.players.map(player => ({ id: player.id, spice: player.spice })), wallets,
'No refund, repeated debit or canceled Emperor credit occurs');
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.deepEqual(semutaEmperorIncomeCards(done), f.originalCards);
assert.throws(() => applyAction(done, f.canceler, f.cancel));
assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event: cost.batch.event }));
if (kind === 'emperorIncome') {
assert.equal(done.auction!.index, f.game.auction!.index + 1);
assert.deepEqual(done.auction!.cards.map(card => card.id), f.game.auction!.cards.map(card => card.id));
assert.equal(done.response!.kind, 'atreidesAuction');
assert.equal(done.response!.owner, 'a', 'Original Atreides inspection owns the next paid-auction suffix');
let resumed = done;
while (resumed.response?.kind === 'atreidesAuction') {
const player = resumed.players.find(seat => !resumed.response!.passed.includes(seat.id))!;
resumed = applyAction(resumed, player.id, { type: 'passResponse' });
}
assert.equal(resumed.auction!.index, done.auction!.index, 'Inspection must not advance a second lot');
const bidder = resumed.auction!.active;
resumed = applyAction(resumed, bidder, { type: 'bid', amount: 1 });
assert.equal(resumed.auction!.bidder, bidder, 'The original next lot is usable by its actual active bidder');
assert.equal(resumed.auction!.index, done.auction!.index);
assert.deepEqual(resumed.players.map(player => ({ id: player.id, spice: player.spice })), wallets);
} else {
assert.deepEqual(done.decision, f.beforePayment.decision,
'The native Richese declaration, including its original owner, is restored');
assert.equal(done.richeseCache!.length, f.beforePayment.richeseCache!.length - 1);
assert.equal(done.discard.filter(card => card.id === f.special).length, 1);
const next = nextSemutaEmperorIncomeStep(done);
assert.equal(next.actor, f.buyer);
assert.throws(() => applyAction(done, f.owner, next.action), 'Emperor cannot answer the original Richese decision');
let resumed = applyAction(done, next.actor, next.action);
// Seats still holding an eligible Karama answer the declaration window explicitly.
for (let n = 0; resumed.response?.kind === 'richeseAuction' && n < resumed.players.length; n++) {
const passer = resumed.players.find(seat => !resumed.response!.passed.includes(seat.id) && seat.id !== resumed.response!.owner)!;
resumed = applyAction(resumed, passer.id, { type: 'passResponse' });
}
assert.equal(resumed.decision!.kind, 'richeseCache');
assert.equal(resumed.decision!.player, f.buyer,
'Native declaration opens its actual cache choice, after automatic uncanceled acknowledgements');
assert.deepEqual(resumed.players.map(player => ({ id: player.id, spice: player.spice })), wallets);
assert.equal(semutaEmperorPlayer(resumed, f.buyer).hand.filter(card => card.id === f.acquired).length, 1);
assert.throws(() => applyAction(resumed, next.actor, next.action), 'Declaration is not replayable');
}
},
);
}
for (const claim of [false, true]) void test(
`a real paid-auction winner spends its newly purchased Karama and Semuta ${claim ? 'claims' : 'passes'} that same physical card`, () => {
const f = createSemutaEmperorIncomeFixture('emperorIncome', { acquiredKarama: true });
assert.equal(f.acquired, f.karama);
assert.equal(semutaEmperorPlayer(f.game, f.buyer).spice,
semutaEmperorPlayer(f.beforePayment, f.buyer).spice - 1);
const pending = applyAction(f.game, f.canceler, f.cancel);
assert.ok(pending.pendingTreacheryDiscard);
assert.equal(pending.pendingTreacheryDiscard.batch.entries[0].discardedBy, f.buyer);
assert.equal(semutaEmperorPlayer(pending, f.buyer).hand.some(card => card.id === f.acquired), false);
const done = reactSemutaEmperorIncome(pending, f, claim);
assert.equal(semutaEmperorPlayer(done, f.buyer).spice, semutaEmperorPlayer(f.game, f.buyer).spice,
'Buying then spending the card does not refund its paid auction');
assert.equal(semutaEmperorPlayer(done, f.owner).spice, semutaEmperorPlayer(f.beforePayment, f.owner).spice);
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));
assert.equal(done.auction!.index, f.game.auction!.index + 1);
assert.equal(done.response!.kind, 'atreidesAuction');
assert.deepEqual(semutaEmperorIncomeCards(done), f.originalCards);
},
);
for (const kind of ['emperorIncome', 'richesePurchaseIncome'] as SemutaEmperorIncomeKind[]) void test(
`a legal Easy bot completes the full-hand native ${kind} Semuta cost and returns its original controls`, () => {
const f = createSemutaEmperorIncomeFixture(kind);
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');
const 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(semutaEmperorPlayer(done, f.buyer).spice, semutaEmperorPlayer(f.game, f.buyer).spice);
assert.equal(semutaEmperorPlayer(done, f.owner).spice, semutaEmperorPlayer(f.beforePayment, f.owner).spice);
if (kind === 'emperorIncome') {
assert.equal(done.auction!.index, f.game.auction!.index + 1);
assert.equal(done.response!.owner, 'a');
} else {
assert.deepEqual(done.decision, f.beforePayment.decision);
assert.equal(semutaEmperorPlayer(done, f.buyer).specialKaramaUsed, true);
}
},
);