import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { nextHarkonnenBetrayalNativeStep } from './fixture-nexus-harkonnen-betrayal'; import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; function inventory(game: Game): string[] { return [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), ...(game.auction?.cards.slice(game.auction.index) ?? []), ...(game.ixAuction?.cards ?? []), ...game.players.flatMap(player => player.hand)].map(card => card.id).sort(); } function advance(game:Game, until:(state:Game)=>boolean):Game { for(let i=0;i<1800;i++) { if(until(game)) return game; if(game.pendingGuildBetrayal) { const pending=game.pendingGuildBetrayal; const actor=pending.required.find(id=>!pending.passed.includes(id)); assert.ok(actor); game=applyAction(game,actor,{type:'guildBetrayalPass',event:pending.invoice.event}); continue; } const next=(!game.response && !game.phaseOpening && (game.decision?.kind.startsWith('richese') || game.richeseAuction && !game.richeseAuction.outcome)) ? nextRicheseStrongholdsNativeStep(game) : nextHarkonnenBetrayalNativeStep(game); assert.ok(next); try { game=applyAction(game,next.actor,next.action); } catch(error) { throw Error(`Native step ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind} ${JSON.stringify(next)}: ${String(error)}`); } } throw Error(`Native Ixian journey stalled at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`); } /** Public start and real setup, Nexus draw and Ixian Bidding attempt. Conserved * unopened Spice and Nexus cards are reordered to reach this window; no hand, * phase or pending event is injected. */ function fixture() { let game = createGame('IXSELECTED', newPlayer('ix', 'Ixians', 'ixians'), false, ['ix', 'choam', 'ecaz']); for (const [id, faction] of [['a', 'atreides'], ['r', 'richese'], ['g', 'guild']] as const) joinGame(game, newPlayer(id, faction, faction)); game = applyAction(game, 'ix', {type:'moduleOption', module:'nexusCards', enabled:true}); for (const player of game.players) game = applyAction(game, player.id, {type:'ready'}); game = applyAction(game, 'ix', {type:'start'}); assert.equal(game.nexusIxianBetrayalPreview, true); assert.ok(game.richeseCache?.length); assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null)); game=advance(game,state=>state.status==='playing'); const nx=game.nexusCards!.cards!; const face=nx.deck.indexOf('ixians'); assert.ok(face>=0); nx.deck.unshift(nx.deck.splice(face,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=applyAction(game,'ix',{type:'alliance',target:'r'}); game=applyAction(game,'r',{type:'alliance',target:'ix'}); game=advance(game,state=>state.nexusCards?.phase?.stage==='drawing'); assert.ok(game.nexusCards!.phase!.eligible.includes('a')); game=applyAction(game,'a',{type:'nexusCardChoice',turn:game.turn,card:null,choice:'draw',ownRedraws:0}); game=advance(game,state=>state.response?.kind==='ixAuction'||!!state.pendingNexusIxianBetrayal); const nativeCount=game.ixAuction?.count; assert.ok(nativeCount); game=advance(game,state=>!!state.pendingNexusIxianBetrayal); const event=game.pendingNexusIxianBetrayal!.source.event; return {game,event,nativeCount,provider:'ix',holder:'a'}; } for (const use of [false, true]) void test( `Basic public mixed selected decks and Richese retain Ixian Nexus Betrayal ${use ? 'use' : 'pass'}`, () => { const f = fixture(), before = f.game, original = inventory(before); assert.equal(f.provider, 'ix'); assert.equal(f.holder, 'a'); assert.equal(before.nexusCards!.cards!.hands.a, 'ixians'); assert.equal(viewGame(before, 'a').nexusIxianBetrayalReaction?.canUse, true); const botView = viewGame(before, 'a'); botView.players.find(player => player.id === 'a')!.bot = 'Easy'; const bot = botActions(botView).find(action => action.type === 'nexusIxianBetrayalUse' || action.type === 'nexusIxianBetrayalPass'); assert.ok(bot); assert.doesNotThrow(() => applyAction(before, 'a', bot)); assert.ok(before.richeseCache?.length); let game = structuredClone(before); if (use) game = applyAction(game, f.holder, {type:'nexusIxianBetrayalUse', event:f.event}); else while (game.pendingNexusIxianBetrayal) { const pending = game.pendingNexusIxianBetrayal; const actor = pending.required.find(id => !pending.passed.includes(id)); assert.ok(actor); game = applyAction(game, actor, {type:'nexusIxianBetrayalPass', event:f.event}); } assert.equal(game.pendingNexusIxianBetrayal, null); assert.equal(game.nexusIxianBetrayalHistory!.at(-1)!.outcome, use ? 'use' : 'pass'); assert.equal(game.nexusIxianBetrayalCursor!.sequence, before.nexusIxianBetrayalCursor!.sequence + 1); assert.equal(game.nexusCards!.cards!.hands.a, use ? null : 'ixians'); assert.equal(game.nexusCards!.cards!.discard.length, before.nexusCards!.cards!.discard.length + Number(use)); assert.equal(before.deck.length - game.deck.length, f.nativeCount + Number(!use)); if (use) { assert.equal(game.ixAuction, null); assert.equal(game.auction!.cards.length, f.nativeCount); } else { assert.equal(game.decision?.kind, 'ixAuction'); assert.equal(game.ixAuction!.cards.length, f.nativeCount + 1); assert.equal(game.auction, null); } assert.deepEqual(inventory(game), original); assert.deepEqual(game.richeseCache, before.richeseCache); if (!use) { const returned = game.ixAuction!.cards[0].id; game = applyAction(game, 'ix', {type:'decision', card:returned, position:'top'}); assert.equal(game.ixAuction, null); assert.equal(game.auction!.cards.length, f.nativeCount); assert.equal(game.deck[0].id, returned); assert.deepEqual(inventory(game), original); } });