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