143 lines
9.6 KiB
TypeScript
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);
|
|
}
|
|
},
|
|
);
|