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 {TERRITORIES} from '../game/board'; import {traitorDeck} from '../game/traitors'; import {validateNexusTraitorSnapshot} from '../game/nexus-traitor-exchange'; import {nextHarkonnenBetrayalNativeStep, settleHarkonnenBetrayalNativeCounters, } from './fixture-nexus-harkonnen-betrayal'; import {nextRicheseStrongholdsNativeStep} from './fixture-richese-strongholds'; const act = (game:Game, actor:string, action:Action) => applyAction(game,actor,action); const player = (game:Game, id:string) => {const seat=game.players.find(p=>p.id===id);assert.ok(seat);return seat;}; const step = (game:Game) => { if(game.pendingGuildBetrayal) {const frame=game.pendingGuildBetrayal;const actor=frame.required.find(id=>!frame.passed.includes(id));assert.ok(actor);return act(game,actor,{type:'guildBetrayalPass',event:frame.invoice.event});} const next=(!game.response && !game.phaseOpening && (game.decision?.kind.startsWith('richese') || game.richeseAuction && !game.richeseAuction.outcome)) ? nextRicheseStrongholdsNativeStep(game)! : nextHarkonnenBetrayalNativeStep(game); try {return act(game,next.actor,next.action);} catch (error) {throw Error(`Step ${game.turn}/${game.phase}/${game.decision?.kind} ${JSON.stringify(next)}: ${String(error)}`);}}; function advance(game:Game, until:(game:Game)=>boolean, limit=1800):Game { for(let i=0;ip.hand)].map(card=>card.id).sort(); } function traitors(game:Game):void { validateNexusTraitorSnapshot({reserve:game.traitorReserve!,players:game.players.map(({id,faction,traitors,faceDancers})=>({id,faction,traitors,...(faceDancers?{faceDancers}: {})}))},traitorDeck(game.players,game.expansions.includes('ix'))); } /** Native public selected-deck start, original setup and second-turn Nexus * draw. Conserved unopened deck order, one held traitor and two physical * reserve positions are staged; battle selection, plans and call are real. */ function fixture() { let game=createGame('HARKEXPANDED',newPlayer('i','Ixians','ixians'),false,['ix','choam','ecaz']); for(const [id,faction] of [['h','harkonnen'],['r','richese'],['g','guild']] as const) joinGame(game,newPlayer(id,faction,faction)); game=act(game,'i',{type:'moduleOption',module:'nexusCards',enabled:true}); for(const seat of game.players) game=act(game,seat.id,{type:'ready'}); game=act(game,'i',{type:'start'}); assert.equal(game.nexusHarkonnenBetrayalPreview,true); game=advance(game,state=>state.status==='playing'); const physical=inventory(game);traitors(game); assert.ok(game.richeseCache?.length,'Native Richese retains its separate cache'); assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card=>card===null)); const identity=player(game,'i').leaders.find(leader=>!leader.dead)!.id; const h=player(game,'h'); if(!h.traitors.includes(identity)) { const replacement=h.traitors[0]; const owner=game.players.find(seat=>seat.id!=='h'&&seat.traitors.includes(identity)); if(owner) owner.traitors[owner.traitors.indexOf(identity)]=replacement; else { const dancer=game.players.flatMap(seat=>seat.faceDancers??[]).find(card=>card.leader===identity); if(dancer) dancer.leader=replacement; else {const index=game.traitorReserve!.indexOf(identity);assert.ok(index>=0);game.traitorReserve![index]=replacement;} } h.traitors[0]=identity; } traitors(game); const nexus=game.nexusCards!.cards!; const nx=nexus.deck.indexOf('harkonnen');assert.ok(nx>=0); nexus.deck.unshift(nexus.deck.splice(nx,1)[0]); for(let pos=0;pos<2;pos++) { const index=game.spiceDeck.findIndex((card,i)=>i>=pos&&'territory'in card);assert.ok(index>=pos); game.spiceDeck.splice(pos,0,game.spiceDeck.splice(index,1)[0]); } game=advance(game,state=>state.turn===2&&state.phase===1&&!state.response&&!state.decision); const worm=game.spiceDeck.findIndex(card=>'worm'in card&&!card.greatMaker&&!card.suppressed);assert.ok(worm>=0); game.spiceDeck.unshift(game.spiceDeck.splice(worm,1)[0]); game=advance(game,state=>state.nexus&&!state.spiceWindow&&!state.spiceResolution&&!state.response&&!state.decision); game=act(game,'h',{type:'alliance',target:'r'});game=act(game,'r',{type:'alliance',target:'h'}); game=advance(game,state=>state.nexusCards?.phase?.stage==='drawing'); assert.ok(game.nexusCards!.phase!.eligible.includes('i')); game=act(game,'i',{type:'nexusCardChoice',turn:game.turn,card:null,choice:'draw',ownRedraws:0}); assert.equal(game.nexusCards!.cards!.hands.i,'harkonnen'); game=advance(game,state=>state.phase===5&&!state.response&&!state.decision); const territory=TERRITORIES.find(t=>t.type==='sand'&&!t.sectors.includes(game.storm)&& game.players.every(p=>!Object.keys(p.forces).some(key=>key.startsWith(`${t.id}:`))));assert.ok(territory); for(const id of ['h','i']) {const seat=player(game,id);assert.ok(seat.reserves>0);seat.reserves--;seat.forces[`${territory.id}:${territory.sectors[0]}`]=1;} game=advance(game,state=>state.phase===6&&!!state.active&&!state.phaseOpening&&!state.response&&!state.decision); const chooser=game.active!;assert.ok(['h','i'].includes(chooser), `chooser ${chooser}; battle ${JSON.stringify(game.battle)}; decision ${JSON.stringify(game.decision)}; pending ${JSON.stringify(game.pendingGuildBetrayal)}; armies ${JSON.stringify(game.players.map(p=>[p.id,p.forces]))}`); game=act(game,chooser,{type:'chooseBattle',territory:territory.id,target:chooser==='h'?'i':'h'}); game=advance(game,state=>!!state.battle&&!state.response&&!state.decision&&!state.battle.preparation&& (!state.battle.preLeader||state.battle.preLeader.closed)); for(const id of [game.battle!.attacker,game.battle!.defender]) { const leader=id==='i'?identity:player(game,id).leaders.find(l=>!l.dead&&!l.usedAt)!.id; game=act(game,id,{type:'battlePlan',dial:0,leader}); } assert.equal(game.battle!.revealed,true); game=advance(game,state=>!state.response&&!state.decision); game=act(game,'h',{type:'traitorCall',call:true}); game=settleHarkonnenBetrayalNativeCounters(game); const reaction=viewGame(game,'i').nexusHarkonnenBetrayalReaction; assert.ok(reaction,'The actual expansion-roster traitor call opens native Betrayal'); assert.equal(reaction.canUse,true);assert.equal(reaction.identity,identity); assert.deepEqual(inventory(game),physical);traitors(game); return {game,event:reaction.event,identity,physical}; } for(const use of [false,true]) void test(`Basic public Ix/CHOAM roster with unused Ecaz deck Harkonnen Nexus Betrayal ${use?'Use':'Pass'} after native Ixian leader traitor call`,()=>{ const f=fixture();let game=f.game; if(use) game=act(game,'i',{type:'nexusHarkonnenBetrayalUse',event:f.event}); else { for(const id of game.pendingNexusHarkonnenBetrayal!.required) game=act(game,id,{type:'nexusHarkonnenBetrayalPass',event:f.event}); } assert.equal(game.pendingNexusHarkonnenBetrayal,null); assert.equal(game.nexusCards!.cards!.hands.i,use?null:'harkonnen'); assert.equal(game.battle!.traitorCalls.h,!use); assert.equal(player(game,'h').traitors.includes(f.identity),!use); assert.equal(game.pendingNexusHarkonnenReplacement?.status??null,use?'due':null); assert.deepEqual(inventory(game),f.physical);traitors(game); game=advance(game,state=>state.phase===8); assert.equal(game.phaseOpening?.initialize,true, 'Ix opening precedes actual Mentat initialization'); assert.equal(game.pendingNexusHarkonnenReplacement?.status??null,use?'due':null); game=advance(game,state=>state.phase===8&&!state.pendingNexusHarkonnenReplacement&&!state.phaseOpening); assert.equal(game.lastBattleContext!.result,use?'normal':'traitor'); assert.equal(game.nexusHarkonnenReplacementHistory!.length,use?1:0); assert.equal(player(game,'h').traitors.length,4); assert.deepEqual(inventory(game),f.physical);traitors(game); });