Files
Dune/tests/kull-richese-emperor-income-runtime.test.ts
T
Quintine e7520e8679
Verify and publish checkpoint container / container (push) Successful in 10m57s
Continue Kull across paid sale responses
2026-10-29 02:00:59 +11:00

170 lines
10 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { act, advance, clean, createKullRicheseSemutaFixture, player } from './fixture-kull-richese-semuta';
/** The live sold lot remains an auction reference; its physical card is in
* the buyer's hand. Later lots remain in the original auction custody. */
function inventory(game: Game): string[] {
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
...(game.richeseRemoved ?? []), ...game.players.flatMap(seat => seat.hand),
...(game.auction?.cards.slice(game.auction.index + Number(!!game.currentAuctionSale)) ?? [])]
.map(card => card.id).sort();
assert.equal(new Set(cards).size, cards.length);
return cards;
}
/** Genuine Basic setup, the real first-turn Richese Semuta purchase, and a
* paid turn-two normal lot. The existing fixture selects unopened cards and
* Spice order; no live hand, wallet, payment, response or promise is staged. */
function source() {
const f = createKullRicheseSemutaFixture();
let game = advance(f.game, state => state.turn === 2 && state.phase === 3 &&
!state.response && !state.phaseOpening && state.decision?.kind === 'richeseDeclaration');
const round = game.richeseBidding; assert.ok(round);
game = act(game, 'richese', {type:'decision', event:round.event, position:'last'});
game = advance(game, state => state.phase === 3 && !!state.auction &&
state.auction.active === 'harkonnen' && state.auction.bid === 0 && clean(state));
const index = game.auction!.index, lot = game.auction!.cards[index].id;
const buyerSpice = player(game, 'harkonnen').spice;
const emperorSpice = player(game, 'emperor').spice;
game = act(game, 'harkonnen', {type:'bid', amount:1, allyPayment:0});
game = advance(game, state => state.response?.kind === 'emperorIncome');
assert.equal(game.response?.owner, 'emperor');
assert.equal(game.currentAuctionSale?.origin, 'normal');
assert.equal(game.currentAuctionSale?.winner, 'harkonnen');
assert.equal(game.currentAuctionSale?.amount, 1);
assert.equal(game.auction?.index, index);
assert.ok(player(game, 'harkonnen').hand.some(card => card.id === lot));
assert.equal(player(game, 'harkonnen').spice, buyerSpice - 1);
assert.equal(player(game, 'emperor').spice, emperorSpice);
assert.ok(player(game, 'choam').hand.some(card => card.id === f.kull));
assert.ok(player(game, 'harkonnen').hand.some(card => card.id === f.counter));
assert.ok(player(game, 'emperor').hand.some(card => card.id === f.karama));
assert.ok(player(game, 'richese').hand.some(card => card.id === f.semuta));
assert.deepEqual(inventory(game), f.originalCards);
return {...f, game, index, lot, buyerSpice:buyerSpice - 1, emperorSpice};
}
function passDiscard(game: Game): Game {
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction?.stage === 'offer');
const event = pending.batch.event;
for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < game.players.length; count++) {
const reaction = game.pendingTreacheryDiscard!.reaction; assert.ok(reaction?.stage === 'offer');
const seat = game.players.find(row => !reaction.passed.includes(row.id)); assert.ok(seat);
game = act(game, seat.id, {type:'semutaPass', event});
}
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event);
return game;
}
function claimDiscard(game: Game, card: string): Game {
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction?.stage === 'offer');
assert.ok(pending.batch.entries.some(entry => entry.card.id === card));
assert.equal(viewGame(game, 'richese').semutaReaction?.canCommit, true);
const event = pending.batch.event;
game = act(game, 'richese', {type:'semutaCommit', event});
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = act(game, 'richese', {type:'semutaSelect', event, card});
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event);
return game;
}
for (const outcome of ['decline','success','counter'] as const)
for (const claim of [false,true])
void test(`paid Emperor income: Kull ${outcome}, Semuta ${claim?'claim':'pass'}`, () => {
const f = source(); let game = f.game;
assert.ok(viewGame(game, 'harkonnen').responseControls?.cancelCards.includes(f.counter));
game = act(game, 'harkonnen', {type:'card', mode:'cancel', card:f.counter});
const reaction = viewGame(game, 'choam').kullReaction; assert.ok(reaction);
assert.ok(reaction.plays.some(play => play.source === 'printed' && play.card.id === f.kull));
game = outcome === 'decline'
? act(game, 'choam', {type:'kullDecision', event:reaction.event, decline:true})
: act(game, 'choam', {type:'kullDecision', event:reaction.event, source:'printed', card:f.kull});
if (outcome === 'counter') {
assert.ok(viewGame(game, 'emperor').responseControls?.cancelCards.includes(f.karama));
game = act(game, 'emperor', {type:'card', mode:'cancel', card:f.karama});
game = passDiscard(game);
}
if (outcome === 'success') game = advance(game, state => !!state.pendingTreacheryDiscard);
const discarded = outcome === 'success' ? f.kull : f.counter;
assert.ok(game.pendingTreacheryDiscard?.batch.entries.some(entry => entry.card.id === discarded));
game = claim ? claimDiscard(game, discarded) : passDiscard(game);
assert.equal(game.pendingKull ?? null, null);
assert.ok(player(game, 'harkonnen').hand.some(card => card.id === f.lot));
assert.equal(player(game, 'harkonnen').spice, f.buyerSpice);
assert.equal(player(game, 'emperor').spice, f.emperorSpice + Number(outcome === 'success'));
assert.equal(player(game, 'harkonnen').hand.some(card => card.id === f.counter), outcome === 'success');
assert.equal(player(game, 'richese').hand.some(card => card.id === discarded), claim);
assert.equal(player(game, 'richese').hand.some(card => card.id === f.semuta), !claim);
assert.deepEqual(inventory(game), f.originalCards);
});
/** The owner buys a real Richese cache card, so the native price goes to
* Emperor. No auction, wallet, held card or pending response is staged. */
function cacheSource() {
const f = createKullRicheseSemutaFixture();
let game = advance(f.game, state => state.turn === 2 && state.phase === 3 &&
!state.response && !state.phaseOpening && state.decision?.kind === 'richeseDeclaration');
const round = game.richeseBidding; assert.ok(round);
game = act(game, 'richese', {type:'decision', event:round.event, position:'first'});
for (let count = 0; game.response?.kind === 'richeseAuction' && count < game.players.length; count++) {
const seat = game.players.find(row => !game.response!.passed.includes(row.id)); assert.ok(seat);
game = act(game, seat.id, {type:'passResponse'});
}
assert.equal(game.decision?.kind, 'richeseCache');
const card = game.richeseCache?.[0]; assert.ok(card);
const buyerSpice = player(game, 'richese').spice;
const emperorSpice = player(game, 'emperor').spice;
game = act(game, 'richese', {type:'decision', event:game.richeseBidding!.event,
card:card.id, method:'silent'});
const lot = game.richeseAuction; assert.ok(lot?.method === 'silent');
for (let count = 0; game.richeseAuction?.event === lot.event && !game.response && count < game.players.length; count++) {
const current = game.richeseAuction!;
const actor = current.eligible.find(id => !Object.hasOwn(current.sealed, id)); assert.ok(actor);
game = act(game, actor, {type:'richeseBid', event:current.event,
amount:actor === 'richese' ? 1 : 0, allyPayment:0});
}
assert.equal(game.response?.kind, 'emperorIncome');
assert.equal(game.response?.owner, 'emperor');
assert.equal(game.currentAuctionSale?.origin, 'cache');
assert.equal(game.currentAuctionSale?.winner, 'richese');
assert.equal(game.currentAuctionSale?.amount, 1);
assert.ok(player(game, 'richese').hand.some(held => held.id === card.id));
assert.equal(player(game, 'richese').spice, buyerSpice - 1);
assert.equal(player(game, 'emperor').spice, emperorSpice);
assert.deepEqual(inventory(game), f.originalCards);
return {...f, game, purchased:card.id, buyerSpice:buyerSpice - 1, emperorSpice};
}
for (const outcome of ['decline','success','counter'] as const)
for (const claim of [false,true])
void test(`paid Richese cache Emperor income: Kull ${outcome}, Semuta ${claim?'claim':'pass'}`, () => {
const f = cacheSource(); let game = f.game;
assert.ok(viewGame(game, 'harkonnen').responseControls?.cancelCards.includes(f.counter));
game = act(game, 'harkonnen', {type:'card', mode:'cancel', card:f.counter});
const reaction = viewGame(game, 'choam').kullReaction; assert.ok(reaction);
assert.ok(reaction.plays.some(play => play.source === 'printed' && play.card.id === f.kull));
game = outcome === 'decline'
? act(game, 'choam', {type:'kullDecision', event:reaction.event, decline:true})
: act(game, 'choam', {type:'kullDecision', event:reaction.event, source:'printed', card:f.kull});
if (outcome === 'counter') {
assert.ok(viewGame(game, 'emperor').responseControls?.cancelCards.includes(f.karama));
game = act(game, 'emperor', {type:'card', mode:'cancel', card:f.karama});
game = passDiscard(game);
}
if (outcome === 'success') game = advance(game, state => !!state.pendingTreacheryDiscard);
const discarded = outcome === 'success' ? f.kull : f.counter;
assert.ok(game.pendingTreacheryDiscard?.batch.entries.some(entry => entry.card.id === discarded));
game = claim ? claimDiscard(game, discarded) : passDiscard(game);
if (game.response?.kind === 'emperorIncome')
game = advance(game, state => state.response?.kind !== 'emperorIncome' && !state.pendingTreacheryDiscard);
assert.equal(game.pendingKull ?? null, null);
assert.ok(player(game, 'richese').hand.some(card => card.id === f.purchased));
assert.equal(player(game, 'richese').spice, f.buyerSpice);
assert.equal(player(game, 'emperor').spice, f.emperorSpice + Number(outcome === 'success'));
assert.equal(player(game, 'harkonnen').hand.some(card => card.id === f.counter), outcome === 'success');
assert.equal(player(game, 'richese').hand.some(card => card.id === discarded), claim);
assert.equal(player(game, 'richese').hand.some(card => card.id === f.semuta), !claim);
assert.deepEqual(inventory(game), f.originalCards);
});