import assert from 'node:assert/strict'; import test from 'node:test'; import { createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; import { moveSemutaEmperorCard } from './fixture-semuta-emperor-share'; import { act, advance, clean, inventory, player } from './fixture-kull-richese-semuta'; /** A fresh public Basic lobby and actual native setup. For the targeted phase * window, all original hands are returned to the deck, then four existing * physical cards are moved to their owners; the phase and turn are staged. */ function fixture(native: 'ecaz' | 'ixians' = 'ecaz') { let game = createGame('KULLINDEPENDENT', newPlayer('choam','CHOAM','choam'), false, ['choam','ix','ecaz']); for (const faction of ['richese','atreides',native] as const) joinGame(game,newPlayer(faction,faction,faction)); game = act(game,'choam',{type:'moduleOption',module:'ecazTreachery',enabled:true}); assert.equal(viewGame(game,'choam').lobbyModules?.kullDefault,true); assert.equal(viewGame(game,'choam').lobbyModules?.kullPreview,true); for (const seat of game.players) game=act(game,seat.id,{type:'ready'}); game=act(game,'choam',{type:'start'}); assert.equal(game.status,'setup'); assert.equal(game.advanced,false); assert.equal(game.kullPreview,true); assert.equal(game.semutaRecruitsPreview,true); assert.equal(game.deck.length,50); assert.equal(game.richeseCache?.length,10); const cards=inventory(game); assert.equal(cards.length,60); assert.ok(cards.includes('ix-kull-wahad')); assert.ok(cards.includes(SEMUTA_DRUG_ID)); for (let count=0;game.status==='setup' && count<200;count++) { const step=nextRicheseStrongholdsNativeStep(game); assert.ok(step); game=act(game,step.actor,step.action); } assert.equal(game.status,'playing'); for (const seat of game.players) game.deck.push(...seat.hand.splice(0)); const karamas=game.deck.filter(card=>card.effect==='karama').map(card=>card.id); assert.ok(karamas.length>=2); const [karama,counter]=karamas; moveSemutaEmperorCard(game,'ix-kull-wahad','choam'); moveSemutaEmperorCard(game,karama,'atreides'); moveSemutaEmperorCard(game,counter,native); moveSemutaEmperorCard(game,SEMUTA_DRUG_ID,'richese'); assert.deepEqual(inventory(game),cards); Object.assign(game,{turn:2,phase:5,active:'atreides',ready:[],response:null, decision:null,phaseOpening:null,choamMarket:null, movementRemaining:game.players.map(seat=>seat.id)}); for (const seat of game.players) {seat.shipped=false;seat.moved=0;} return {game,cards,karama,counter,native,kull:'ix-kull-wahad',holder:'richese',actor:'atreides'}; } type Fixture=ReturnType; const held=(game:Game,seat:string,card:string)=>player(game,seat).hand.some(row=>row.id===card); function offer(f:Fixture) { const game=act(f.game,f.actor,{type:'card',mode:'shipment',card:f.karama,target:f.actor}); assert.equal(game.pendingKull?.stage,'offer'); const reaction=viewGame(game,'choam').kullReaction; assert.ok(reaction); assert.ok(reaction.plays.some(play=>play.source==='printed' && play.card.id===f.kull && !play.blocked)); assert.ok(held(game,f.actor,f.karama)); return game; } function declare(f:Fixture,game:Game) { const reaction=viewGame(game,'choam').kullReaction; assert.ok(reaction); game=act(game,'choam',{type:'kullDecision',event:reaction.event,source:'printed',card:f.kull}); assert.equal(game.pendingKull?.stage,'counter'); return game; } function semuta(game:Game,f:Fixture,card:string,discardedBy:string,claim:boolean) { const pending=game.pendingTreacheryDiscard; assert.ok(pending?.reaction); assert.equal(pending.reaction.stage,'offer'); assert.deepEqual(pending.batch.entries.map(entry=>({id:entry.card.id,discardedBy:entry.discardedBy,publicFace:entry.publicFace})), [{id:card,discardedBy,publicFace:true}]); assert.equal(viewGame(game,f.holder).semutaReaction?.canCommit,true); const event=pending.batch.event; if (claim) { game=act(game,f.holder,{type:'semutaCommit',event}); if (game.pendingTreacheryDiscard?.reaction?.stage==='select') game=act(game,f.holder,{type:'semutaSelect',event,card}); } else { for (let count=0;game.pendingTreacheryDiscard?.batch.event===event && count!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; } for (const native of ['ecaz','ixians'] as const) void test(`fresh Basic independent Ecaz cards and native ${native} offer default Kull; decline resumes Karama`,()=>{ const f=fixture(native), pending=offer(f); const reaction=viewGame(pending,'choam').kullReaction; assert.ok(reaction); for (const difficulty of DIFFICULTIES) { const view=viewGame(pending,'choam'); view.players.find(row=>row.id==='choam')!.bot=difficulty; const action=botActions(view)[0]; assert.ok(action,difficulty); assert.equal(action.type,'kullDecision',difficulty); assert.doesNotThrow(()=>act(pending,'choam',action),difficulty); } let game=act(pending,'choam',{type:'kullDecision',event:reaction.event,decline:true}); game=advance(game,clean); assert.equal(game.karamaShipping?.owner,f.actor); assert.equal(game.pendingKull,null); assert.ok(held(game,'choam',f.kull)); assert.equal(game.discard.filter(row=>row.id===f.karama).length,1); assert.deepEqual(inventory(game),f.cards); }); for (const claim of [false,true]) void test(`Basic independent native Richese can ${claim?'claim':'pass'} original shipment Karama after Kull declines`,()=>{ const f=fixture(), offered=offer(f); const reaction=viewGame(offered,'choam').kullReaction; assert.ok(reaction); const pending=act(offered,'choam',{type:'kullDecision',event:reaction.event,decline:true}); assert.equal(pending.karamaShipping,null); let game=semuta(pending,f,f.karama,f.actor,claim); game=advance(game,clean); assert.equal(game.karamaShipping?.owner,f.actor); assert.equal(held(game,f.holder,f.karama),claim); assert.equal(held(game,'choam',f.kull),true); assert.deepEqual(inventory(game),f.cards); }); for (const claim of [false,true]) void test(`Basic independent native Richese can ${claim?'claim':'pass'} successful printed Kull`,()=>{ const f=fixture(), pending=advance(declare(f,offer(f)),state=>!!state.pendingTreacheryDiscard); assert.ok(held(pending,f.actor,f.karama)); assert.equal(pending.karamaShipping,null); for (const difficulty of DIFFICULTIES) { const view=viewGame(pending,f.holder); view.players.find(row=>row.id===f.holder)!.bot=difficulty; const action=botActions(view)[0]; assert.ok(action,difficulty); assert.ok(action.type==='semutaPass'||action.type==='semutaCommit',difficulty); assert.doesNotThrow(()=>act(pending,f.holder,action),difficulty); } let game=semuta(pending,f,f.kull,'choam',claim); game=advance(game,clean); assert.equal(game.pendingKull,null); assert.equal(game.karamaShipping,null); assert.ok(held(game,f.actor,f.karama)); assert.ok(viewGame(game,f.actor).karamaBlocked); assert.equal(held(game,f.holder,f.kull),claim); assert.equal(held(game,f.holder,SEMUTA_DRUG_ID),!claim); assert.deepEqual(inventory(game),f.cards); }); for (const claim of [false,true]) void test(`Basic independent native Richese can ${claim?'claim':'pass'} distinct Kull-counter Karama`,()=>{ const f=fixture(), declared=declare(f,offer(f)); assert.ok(viewGame(declared,f.native).responseControls?.cancelCards.includes(f.counter)); const pending=act(declared,f.native,{type:'card',mode:'cancel',card:f.counter}); assert.ok(held(pending,f.actor,f.karama)); assert.ok(held(pending,'choam',f.kull)); let game=semuta(pending,f,f.counter,f.native,claim); game=advance(game,clean); assert.equal(game.pendingKull,null); assert.equal(game.karamaShipping?.owner,f.actor); assert.ok(held(game,'choam',f.kull)); assert.equal(game.discard.filter(row=>row.id===f.karama).length,1); assert.equal(held(game,f.holder,f.counter),claim); assert.equal(held(game,f.holder,SEMUTA_DRUG_ID),!claim); assert.deepEqual(inventory(game),f.cards); });