import assert from 'node:assert/strict'; import test from 'node:test'; import {applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game} from '../game/engine'; import {territory} from '../game/board'; import {placeTerror} from '../game/moritani-terror'; import {takeKullCard} from './fixture-choam-kull'; import {nextStrongholdFactionsNativeStep} from './fixture-stronghold-factions'; const act = (g:Game, id:string, action:Action) => applyAction(g, id, action); const player = (g:Game, id:string) => { const p=g.players.find(row=>row.id===id); assert.ok(p); return p; }; function passResponse(g:Game) { for(let n=0;g.response&&n!g.response!.passed.includes(p.id)); assert.ok(seat); g=act(g,seat.id,{type:'passResponse'}); } return g; } function passSemuta(g:Game) { for(let n=0;g.pendingTreacheryDiscard?.reaction?.stage==='offer'&&n!reaction.passed.includes(p.id)); assert.ok(seat); g=act(g,seat.id,{type:'semutaPass',event:pending.batch.event}); } return g; } function payment(g:Game,pay:boolean):Action { assert.ok(g.decision?.kind==='moritaniExtortion'); return {type:'decision',event:g.decision.event,pay}; } function parent(g:Game) { return {decision:structuredClone(g.decision),obligation:structuredClone(g.moritaniExtortion), order:[...g.order],turn:g.turn,phase:g.phase}; } /** Real public mixed-faction Basic start and native setup. Stage the alliance, * physical token, hands and clean movement boundary; a real shipment reveals * Extortion and native Mentat awards bank spice and opens the payer queue. */ function fixture(counterId='karama') { let g=createGame('EMPEXTKULL',newPlayer('m','Moritani','moritani'),false,['ecaz','choam','ix']); for(const [id,faction] of [['e','emperor'],['a','atreides'],['c','choam'],['r','richese']] as const) joinGame(g,newPlayer(id,faction,faction)); for(const p of g.players)g=act(g,p.id,{type:'ready'}); g=act(g,'m',{type:'start'}); assert.equal(g.kullPreview,true); assert.equal(g.semutaPreview,true); for(let n=0;n<100&&g.status==='setup';n++) { if(g.decision?.kind==='moritaniSetup') g=act(g,'m',{type:'decision',territory:'sietch_tabr',sector:territory('sietch_tabr').sectors[0]}); else { const next=nextStrongholdFactionsNativeStep(g); assert.ok(next); g=act(g,next.actor,next.action); } } assert.equal(g.status,'playing'); player(g,'e').ally='a'; player(g,'a').ally='e'; const token=g.moritaniTerror!.tokens.find(row=>row.kind==='extortion'); assert.ok(token); g.moritaniTerror=placeTerror(g.moritaniTerror!,token.id,'habbanya_ridge_sietch',1); Object.assign(g,{phase:5,turn:2,storm:18,order:['e','a','c','r','m'],active:'e', movementRemaining:['e','a','c','r','m'],response:null,decision:null,phaseOpening:null,ready:[]}); player(g,'e').spice=10; player(g,'a').spice=2; for(const p of g.players)g.deck.push(...p.hand.splice(0)); const kull=takeKullCard(g,'ix-kull-wahad'); player(g,'c').hand.push(kull); const karama=takeKullCard(g,'karama'); player(g,'m').hand.push(karama); // A Richese Karama counter is a conserved physical cache card, staged as // already acquired by the ally; ordinary counters come from the real deck. const counter=takeKullCard(g,counterId); player(g,'a').hand.push(counter); const semuta=takeKullCard(g,'richese-semuta-drug'); player(g,'r').hand.push(semuta); g=act(g,'e',{type:'ship',territory:'habbanya_ridge_sietch',sector:17,amount:1}); assert.equal(g.decision?.kind,'moritaniTerror'); g=act(g,'m',{type:'decision',reveal:true}); assert.equal(g.moritaniTerror!.tokens.find(row=>row.id===token.id)?.status,'extortion'); const beforeAward=player(g,'m').spice; g.phase=7; g.ready=[]; for(const p of g.players)g=act(g,p.id,{type:'ready'}); for(let n=0;g.decision?.kind!=='moritaniPlacement'&&n<30;n++) { const next=nextStrongholdFactionsNativeStep(g); assert.ok(next, 'Original Collection continuation must remain playable'); g=act(g,next.actor,next.action); } assert.equal(g.phase,8); assert.equal(g.decision?.kind,'moritaniPlacement'); g=act(g,'m',{type:'decision',decline:true}); assert.equal(player(g,'m').spice,beforeAward+5); assert.equal(g.decision?.kind,'moritaniExtortion'); assert.equal(g.decision.player,'e'); g=act(g,'e',payment(g,false)); assert.equal(g.decision?.kind,'moritaniExtortion'); assert.equal(g.decision.player,'a'); assert.equal(viewGame(g,'a').extortion.pending?.canPay,false); return {g,token:token.id,kull:kull.id,karama:karama.id,counter:counter.id,semuta:semuta.id,beforeAward}; } for(const success of [false,true]) for(const claim of [false,true]) void test( `Basic mixed-faction Extortion gift: Kull ${success?'succeeds':'declines'} and Semuta ${claim?'claims':'passes'}`,()=>{ const f=fixture(),before=parent(f.g),emperor=player(f.g,'e').spice; assert.deepEqual(viewGame(f.g,'e').emperorExtortionGift,{recipient:'a',maximum:emperor}); let g=act(f.g,'e',{type:'emperorGift',amount:1}); g=act(g,'m',{type:'card',mode:'cancel',card:f.karama}); assert.equal(g.pendingKull?.stage,'offer'); g=act(g,'c',success ? {type:'kullDecision',event:g.pendingKull!.event,source:'printed',card:f.kull} : {type:'kullDecision',event:g.pendingKull!.event,decline:true}); if(success) { assert.equal(g.pendingKull?.stage,'counter'); g=passResponse(g); } assert.equal(g.pendingTreacheryDiscard?.reaction?.stage,'offer'); const physical=success?f.kull:f.karama; assert.equal(g.pendingTreacheryDiscard!.batch.entries[0].card.id,physical); const event=g.pendingTreacheryDiscard!.batch.event; if(claim)g=act(g,'r',{type:'semutaCommit',event});else g=passSemuta(g); assert.equal(player(g,'r').hand.some(card=>card.id===physical),claim); if(g.pendingTreacheryDiscard?.reaction?.stage==='offer')g=passSemuta(g); if(g.response)g=passResponse(g); assert.deepEqual(parent(g),before); assert.equal(player(g,'e').spice,emperor-(success?1:0)); assert.equal(player(g,'a').spice,success?3:2); assert.equal(player(g,'m').hand.some(card=>card.id===f.karama),success); assert.equal(g.discard.some(card=>card.id===f.semuta),claim); assert.equal(player(g,'m').spice,f.beforeAward+5); assert.equal(g.moritaniTerror!.tokens.find(row=>row.id===f.token)?.status,'extortion'); assert.equal(viewGame(g,'a').extortion.pending?.canPay,success); g=act(g,'a',payment(g,success)); for(let n=0;g.decision?.kind==='moritaniExtortion'&&n{ const f=fixture(counterId),before=parent(f.g),emperor=player(f.g,'e').spice; const counter=player(f.g,'a').hand.find(card=>card.id===f.counter); assert.ok(counter); if(counterId==='richese-karama') { assert.equal(counter.id,'richese-karama'); assert.equal(f.g.richeseCache!.some(card=>card.id===counter.id),false); } let g=act(f.g,'e',{type:'emperorGift',amount:1}); g=act(g,'m',{type:'card',mode:'cancel',card:f.karama}); assert.equal(g.pendingKull?.stage,'offer'); g=act(g,'c',{type:'kullDecision',event:g.pendingKull!.event,source:'printed',card:f.kull}); assert.equal(g.pendingKull?.stage,'counter'); assert.ok(viewGame(g,'a').responseControls?.cancelCards.includes(counter.id)); g=act(g,'a',{type:'card',mode:'cancel',card:counter.id}); assert.equal(g.pendingTreacheryDiscard?.reaction?.stage,'offer'); assert.equal(g.pendingTreacheryDiscard!.batch.entries[0].card.id,counter.id); const event=g.pendingTreacheryDiscard!.batch.event; if(claim)g=act(g,'r',{type:'semutaCommit',event});else g=passSemuta(g); assert.equal(player(g,'r').hand.some(card=>card.id===counter.id),claim); if(g.pendingTreacheryDiscard?.reaction?.stage==='offer')g=passSemuta(g); if(g.response)g=passResponse(g); assert.deepEqual(parent(g),before); assert.equal(player(g,'e').spice,emperor); assert.equal(player(g,'a').spice,2); assert.equal(player(g,'c').hand.some(card=>card.id===f.kull),true); assert.equal(player(g,'m').hand.some(card=>card.id===f.karama),false); assert.equal(g.discard.some(card=>card.id===f.semuta),claim); assert.equal(viewGame(g,'a').extortion.pending?.canPay,false); g=act(g,'a',payment(g,false)); for(let n=0;g.decision?.kind==='moritaniExtortion'&&n