Continue Kull across paid sale responses
Verify and publish checkpoint container / container (push) Successful in 10m57s

This commit is contained in:
Quintine committed 2026-10-29 02:00:59 +11:00
1 parent 3d2470b7f8
commit e7520e8679
9 files changed
+487 -18

No files matched your search

+90
View File
@@ -0,0 +1,90 @@
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';
/** These use the public Basic Kull/Richese fixture and its actual purchase of
* Semuta. On turn two, select the unopened first ordinary lot so CHOAM cannot
* fund an alleged second payment with a newly drawn Karama. No live purse,
* hand, bidding state, response or pending transaction is staged. */
function auctionStart(winner:'choam'|'harkonnen') {
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);
const nonKarama=game.deck.findIndex(card=>card.effect!=='karama'); assert.ok(nonKarama>=0);
game.deck.unshift(game.deck.splice(nonKarama,1)[0]);
game=act(game,'richese',{type:'decision',event:round.event,position:'last'});
game=advance(game,state=>state.phase===3 && !!state.auction &&
state.auction.active===winner && state.auction.bid===0 && clean(state));
assert.equal(game.auction?.cards[game.auction.index].effect==='karama',false);
assert.ok(player(game,winner).spice>0);
assert.ok(player(game,'choam').hand.some(card=>card.id===f.kull));
assert.ok(player(game,'harkonnen').hand.some(card=>card.id===f.counter));
return {...f,game};
}
function paidIncome(winner:'choam'|'harkonnen') {
const f=auctionStart(winner); let game=f.game;
const paid=player(game,winner).spice, lot=game.auction!.cards[game.auction!.index].id;
game=act(game,winner,{type:'bid',amount:paid,allyPayment:0});
game=advance(game,state=>state.response?.kind==='emperorIncome');
assert.equal(game.currentAuctionSale?.origin,'normal');
assert.equal(game.currentAuctionSale?.winner,winner);
assert.equal(game.currentAuctionSale?.amount,paid);
assert.equal(player(game,winner).spice,0);
assert.ok(player(game,winner).hand.some(card=>card.id===lot));
assert.ok(player(game,'harkonnen').hand.some(card=>card.id===f.counter));
return {...f,game,paid,lot};
}
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;
}
void test('CHOAM may use Kull after spending its entire winning bid',()=>{
const f=paidIncome('choam'); let game=f.game;
const emperorBefore=player(game,'emperor').spice;
assert.ok(!player(game,'choam').hand.some(card=>card.effect==='karama'));
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=act(game,'choam',{type:'kullDecision',event:reaction.event,source:'printed',card:f.kull});
game=advance(game,state=>!!state.pendingTreacheryDiscard);
game=passDiscard(game);
assert.equal(player(game,'choam').spice,0);
assert.equal(player(game,'emperor').spice,emperorBefore+f.paid);
assert.ok(player(game,'choam').hand.some(card=>card.id===f.lot));
assert.ok(player(game,'harkonnen').hand.some(card=>card.id===f.counter));
});
void test('a winner may attempt to cancel Emperor income after spending its entire bid',()=>{
const f=paidIncome('harkonnen'); let game=f.game;
const emperorBefore=player(game,'emperor').spice;
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=act(game,'choam',{type:'kullDecision',event:reaction.event,decline:true});
game=advance(game,state=>!!state.pendingTreacheryDiscard);
game=passDiscard(game);
assert.equal(player(game,'harkonnen').spice,0);
assert.equal(player(game,'emperor').spice,emperorBefore);
assert.ok(player(game,'harkonnen').hand.some(card=>card.id===f.lot));
assert.ok(!player(game,'harkonnen').hand.some(card=>card.id===f.counter));
});
void test('an unpaid non-CHOAM Karama-dependent overbid remains rejected',()=>{
const f=auctionStart('harkonnen'), before=structuredClone(f.game);
const amount=player(f.game,'harkonnen').spice+1;
assert.throws(()=>act(f.game,'harkonnen',{type:'bid',amount,allyPayment:0}),/unfunded Karama-dependent overbid/);
assert.deepEqual(f.game,before);
});
@@ -0,0 +1,169 @@
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);
});
@@ -0,0 +1,140 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, joinGame, newPlayer, viewGame } from '../game/engine';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import {
actSemutaIxAllyCost as act, advanceSemutaIxAllyCost as advance,
allowSemutaIxAllyCost as allow, finishSemutaIxAllyCost as finish,
passSemutaIxAllyCost as pass, claimSemutaIxAllyCost as claim,
semutaIxAllyCostPlayer as player, semutaIxAllyCostInventory as inventory,
} from './fixture-semuta-ix-ally-cost';
import { nextSemutaIxAllyCost } from './fixture-semuta-ix-ally-cost';
/** Public Basic R/C/I/F/G game. Only unopened initial deal and Spice Deck
* positions are selected. CHOAM really buys Semuta; Ixians and Fremen form
* a real turn-two Nexus alliance; Fremen pays for the current ordinary lot and
* requests the native Ixian replacement. No resources or pending state are
* installed. CHOAM holds both the initial Kull and purchased Semuta, so it can
* recover another player's cost but cannot recover its own Kull discard. */
function source() {
let game=createGame('KULLIXALLY',newPlayer('richese','Richese','richese'),false,['choam','ix']);
for (const id of ['choam','ixians','fremen','guild'] as const)
joinGame(game,newPlayer(id,id,id));
for (const seat of game.players) game=act(game,seat.id,{type:'ready'});
game=act(game,'richese',{type:'start'});
assert.equal(game.status,'setup'); assert.equal(game.kullPreview,true); assert.equal(game.semutaPreview,true);
const original=inventory(game);
const worthless=game.deck.find(card=>card.kind==='worthless' && card.id!=='ix-kull-wahad');
const kull=game.deck.find(card=>card.id==='ix-kull-wahad');
const shield=game.deck.find(card=>card.kind==='shield');
const karamas=game.deck.filter(card=>card.effect==='karama').slice(0,2);
assert.ok(worthless && kull && shield); assert.equal(karamas.length,2);
const selected=[worthless,kull,shield,...karamas];
selected.forEach((card,position)=>{
const index=game.deck.findIndex(row=>row.id===card.id); assert.ok(index>=position);
[game.deck[position],game.deck[index]]=[game.deck[index],game.deck[position]];
});
game=advance(game,state=>state.status==='playing');
assert.ok(player(game,'choam').hand.some(card=>card.id===kull.id));
assert.ok(player(game,'fremen').hand.some(card=>card.id===karamas[0].id));
assert.ok(player(game,'guild').hand.some(card=>card.id===karamas[1].id));
function selectSpice(faces:string[]) {
faces.forEach((face,position)=>{
const index=game.spiceDeck.findIndex((card,i)=>i>=position && (face==='worm'
? 'worm' in card && !card.suppressed && !card.thumper && !card.greatMaker
: 'territory' in card && card.territory===face && !card.discovery));
assert.ok(index>=position); const [card]=game.spiceDeck.splice(index,1); game.spiceDeck.splice(position,0,card);
});
}
selectSpice(['funeral_plain']);
game=advance(game,state=>player(state,'choam').hand.some(card=>card.id===SEMUTA_DRUG_ID));
game=advance(game,state=>state.turn===2 && state.phase===1 && !state.response && !state.phaseOpening &&
!state.decision && !state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && state.ready.length===0);
selectSpice(['worm','the_great_flat']);
game=advance(game,state=>state.turn===2 && state.phase===1 && !!state.nexus &&
!state.spiceWindow && !state.spiceResolution && !state.response && !state.phaseOpening && !state.decision);
game=act(game,'ixians',{type:'alliance',target:'fremen'});
game=act(game,'fremen',{type:'alliance',target:'ixians'});
game=advance(game,state=>state.turn===2 && state.phase===3 && !!state.auction &&
state.auction.index===0 && state.auction.active==='fremen' && !state.response && !state.decision && !state.phaseOpening);
const beforeSpice=player(game,'fremen').spice;
game=act(game,'fremen',{type:'bid',amount:1,allyPayment:0});
for (let count=0;game.decision?.kind!=='ixAllyCard' && count<100;count++) {
if (!game.response && !game.phaseOpening && game.decision?.kind==='auctionPayment')
game=act(game,'fremen',{type:'decision',karama:false});
else { const next=nextSemutaIxAllyCost(game); assert.ok(next); game=act(game,next.actor,next.action); }
}
assert.equal(game.decision?.kind,'ixAllyCard');
assert.equal(player(game,'fremen').spice,beforeSpice-1);
assert.equal(game.currentAuctionSale?.origin,'normal');
const pending=game.pendingIxAlly; assert.ok(pending);
const delivered=pending.card, index=game.auction!.index;
assert.ok(player(game,'fremen').hand.some(card=>card.id===delivered));
game=act(game,'fremen',{type:'decision',accept:true});
assert.equal(game.response?.kind,'ixAllyCard');
assert.deepEqual(inventory(game),original);
return {game,original,kull:kull.id,counter:karamas[0].id,karama:karamas[1].id,
delivered,index,paidSpice:player(game,'fremen').spice, replacement:game.deck[0]?.id};
}
for (const outcome of ['decline','success','counter'] as const)
for (const claimed of [false,true]) {
// CHOAM cannot recover its own successful Kull discard with Semuta.
if (outcome==='success' && claimed) continue;
void test(`paid Ixian ally replacement: Kull ${outcome}, Semuta ${claimed?'claim':'pass'}`,()=>{
const f=source(); let game=f.game;
assert.ok(viewGame(game,'guild').responseControls?.cancelCards.includes(f.karama));
game=act(game,'guild',{type:'card',mode:'cancel',card:f.karama});
const offer=viewGame(game,'choam').kullReaction; assert.ok(offer);
assert.ok(offer.plays.some(row=>row.source==='printed' && row.card.id===f.kull));
game=outcome==='decline' ? act(game,'choam',{type:'kullDecision',event:offer.event,decline:true}) :
act(game,'choam',{type:'kullDecision',event:offer.event,source:'printed',card:f.kull});
if (outcome==='counter') {
assert.ok(viewGame(game,'fremen').responseControls?.cancelCards.includes(f.counter));
game=act(game,'fremen',{type:'card',mode:'cancel',card:f.counter});
game=pass(game);
}
if (outcome==='success') game=advance(game,state=>!!state.pendingTreacheryDiscard);
const spent=outcome==='success'?f.kull:f.karama;
assert.ok(game.pendingTreacheryDiscard?.batch.entries.some(entry=>entry.card.id===spent));
if (claimed) {
assert.equal(viewGame(game,'choam').semutaReaction?.canCommit,true);
game=claim(game,'choam');
} else game=pass(game);
assert.equal(game.pendingKull ?? null,null);
if (outcome==='success') {
if (game.response?.kind==='ixAllyCard') game=allow(game);
game=finish(game);
assert.ok(player(game,'guild').hand.some(card=>card.id===f.karama));
assert.ok(!player(game,'fremen').hand.some(card=>card.id===f.delivered));
assert.ok(player(game,'fremen').hand.some(card=>card.id===f.replacement));
assert.ok(game.discard.some(card=>card.id===f.delivered));
} else {
game=finish(game);
assert.ok(player(game,'fremen').hand.some(card=>card.id===f.delivered));
assert.ok(!player(game,'guild').hand.some(card=>card.id===f.karama));
}
assert.equal(player(game,'fremen').spice,f.paidSpice);
assert.equal(game.auction?.index,f.index+1);
assert.equal(player(game,'choam').hand.some(card=>card.id===f.karama),claimed);
assert.equal(player(game,'choam').hand.some(card=>card.id===SEMUTA_DRUG_ID),!claimed);
assert.deepEqual(inventory(game),f.original);
});
}
void test('Semuta may recover the distinct purchased-card discard after Kull preserves Ixian replacement',()=>{
const f=source(); let game=act(f.game,'guild',{type:'card',mode:'cancel',card:f.karama});
const offer=viewGame(game,'choam').kullReaction; assert.ok(offer);
game=act(game,'choam',{type:'kullDecision',event:offer.event,source:'printed',card:f.kull});
game=advance(game,state=>!!state.pendingTreacheryDiscard);
game=pass(game);
if (game.response?.kind==='ixAllyCard') game=allow(game);
assert.ok(game.pendingTreacheryDiscard?.batch.entries.some(entry=>entry.card.id===f.delivered));
assert.equal(viewGame(game,'choam').semutaReaction?.canCommit,true);
game=claim(game,'choam');
game=finish(game);
assert.ok(player(game,'choam').hand.some(card=>card.id===f.delivered));
assert.ok(player(game,'fremen').hand.some(card=>card.id===f.replacement));
assert.equal(player(game,'fremen').spice,f.paidSpice);
assert.deepEqual(inventory(game),f.original);
});
+8 -1
View File
@@ -59,7 +59,14 @@ function nativeSource(f: SemutaIxAllyCostFixture) {
assert.deepEqual(inventory(f.game), f.originalInventory); assert.equal(f.sourceActionCount, f.actions.length);
}
function open(f: SemutaIxAllyCostFixture) {
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
let game = act(structuredClone(f.game), f.spender, f.action);
// Basic Kull now gets its public pre-cost opportunity even when CHOAM has
// no eligible card. Declining resumes this original Semuta-only scenario.
if (game.pendingKull) {
const choice = viewGame(game, 'choam').kullReaction; assert.ok(choice);
game = act(game, 'choam', {type:'kullDecision', event:choice.event, decline:true});
}
const pending = game.pendingTreacheryDiscard;
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaMarketDiscard');
if (pending.continuation.kind !== 'karamaMarketDiscard') throw Error('Use the original paid ally replacement cost');
assert.equal(pending.batch.cause, 'karama:ixAllyCard'); assert.equal(pending.reaction?.stage, 'offer');