Files
Dune/tests/kull-richese-normal-bonus-runtime.test.ts
Quintine 3d2470b7f8
Verify and publish checkpoint container / container (push) Successful in 10m25s
Continue Kull through paid auction bonuses
2026-10-29 01:53:55 +11:00

104 lines
6.0 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';
/** Account for the current sold card as a historical auction reference, not a
* second physical card. Unopened 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;
}
/** Public Basic four-seat setup and actual first-turn paid Semuta acquisition.
* In turn two Richese announces its compulsory auction last, then Harkonnen
* purchases an actual normal lot with its own spice; no live purse, hand,
* auction, 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 beforeSpice = player(game,'harkonnen').spice;
game = act(game,'harkonnen',{type:'bid',amount:1,allyPayment:0});
game = advance(game,state => state.response?.kind === 'harkonnenBonus');
assert.equal(game.currentAuctionSale?.origin,'normal');
assert.equal(game.currentAuctionSale?.winner,'harkonnen');
assert.equal(game.auction?.index,index);
assert.equal(game.auction?.cards[index].id,lot);
assert.ok(player(game,'harkonnen').hand.some(card => card.id === lot));
assert.equal(player(game,'harkonnen').spice,beforeSpice-1);
assert.ok(player(game,'choam').hand.some(card => card.id === f.kull));
assert.ok(player(game,'emperor').hand.some(card => card.id === f.karama));
assert.ok(player(game,'harkonnen').hand.some(card => card.id === f.counter));
assert.ok(player(game,'richese').hand.some(card => card.id === f.semuta));
assert.deepEqual(inventory(game),f.originalCards);
return {...f,game,index,lot,paidSpice:player(game,'harkonnen').spice,
buyerCount:player(game,'harkonnen').hand.length};
}
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(`normal Harkonnen paid bonus: Kull ${outcome}, Semuta ${claim?'claim':'pass'}`,()=>{
const f=source(); let game=f.game;
assert.ok(viewGame(game,'emperor').responseControls?.cancelCards.includes(f.karama));
game=act(game,'emperor',{type:'card',mode:'cancel',card:f.karama});
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,'harkonnen').responseControls?.cancelCards.includes(f.counter));
game=act(game,'harkonnen',{type:'card',mode:'cancel',card:f.counter});
game=passDiscard(game);
}
if (outcome==='success') game=advance(game,state=>!!state.pendingTreacheryDiscard);
const discarded=outcome==='success'?f.kull:f.karama;
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.equal(game.currentAuctionSale ?? null,null);
assert.notEqual(game.response?.kind,'harkonnenBonus');
assert.equal(game.auction?.index,f.index+1);
assert.equal(player(game,'harkonnen').spice,f.paidSpice);
assert.ok(player(game,'harkonnen').hand.some(card=>card.id===f.lot));
assert.equal(player(game,'harkonnen').hand.length,
f.buyerCount+Number(outcome==='success')-Number(outcome==='counter'));
assert.equal(player(game,'emperor').hand.some(card=>card.id===f.karama),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);
});