Files
Dune/tests/kull-richese-ix-replacement-runtime.test.ts
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

141 lines
8.3 KiB
TypeScript

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);
});