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 { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { advanceSemutaNexusTrade, nextSemutaNexusTradeNativeStep, swapSemutaNexusTradeIdentity } from './fixture-semuta-nexus-trade'; const act = (game: Game, id: string, action: Action) => applyAction(game, id, action); const seat = (game: Game, id: string) => { const player = game.players.find(row => row.id === id); assert.ok(player); return player; }; const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.truthtrance && !game.pendingGuildBetrayal && !game.pendingTreacheryDiscard; function inventory(game: Game): string[] { const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), ...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? []), ...(game.ixAuction?.cards ?? []), ...game.players.flatMap(player => player.hand)].map(card => card.id).sort(); assert.equal(new Set(cards).size, cards.length, 'Each physical Treachery card has one live location'); return cards; } /** Public Basic setup and a genuine qualifying Nexus draw. Original unopened * Spice/Nexus order, board counters and card custody are staged conservatively; * the alliance, bidding, payment and purchased-card response are real actions. */ function fixture(exhaustDeck = false, cacheSale = false) { let game = createGame('SEMUTANEXUSIX', newPlayer('a','Atreides','atreides'), false, ['choam']); for (const [id, faction] of [['r','richese'], ['e','emperor'], ['g','guild']] as const) joinGame(game, newPlayer(id, faction, faction)); game = act(game, 'a', {type:'moduleOption', module:'nexusCards', enabled:true}); for (const player of game.players) game = act(game, player.id, {type:'ready'}); game = act(game, 'a', {type:'start'}); assert.equal(game.semutaPreview, true); assert.equal(game.nexusIxianReplacementPreview, true); game = advanceSemutaNexusTrade(game, state => state.status === 'playing'); const original = inventory(game); for (const player of game.players) { player.reserves += Object.values(player.forces).reduce((sum, count) => sum + count, 0); player.forces = {}; if (player.elites) { player.elites.reserves += Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0); player.elites.forces = {}; } } for (let position = 0; position < 2; position++) { const index = game.spiceDeck.findIndex((card, n) => n >= position && 'territory' in card); assert.ok(index >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]); } game = advanceSemutaNexusTrade(game, state => state.turn === 2 && state.phase === 1 && clean(state)); 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 = advanceSemutaNexusTrade(game, state => !!state.nexus && !state.spiceWindow && clean(state)); game = act(game,'e',{type:'alliance', target:'g'}); game = act(game,'g',{type:'alliance', target:'e'}); game = advanceSemutaNexusTrade(game, state => state.nexusCards?.phase?.stage === 'drawing' && clean(state)); assert.ok(game.nexusCards!.phase!.eligible.includes('a')); game = act(game,'a',{type:'nexusCardChoice', turn:game.turn, card:null, choice:'draw', ownRedraws:0}); swapSemutaNexusTradeIdentity(game,'a','ixians'); // Move the physical Semuta to Richese, exchanging one existing card if held. const richese = seat(game,'r'), zones = [game.deck, game.discard, game.richeseCache!, ...game.players.map(player => player.hand)]; const source = zones.find(cards => cards.some(card => card.id === SEMUTA_DRUG_ID)); assert.ok(source); if (source !== richese.hand) { const index = source.findIndex(card => card.id === SEMUTA_DRUG_ID), [semuta] = source.splice(index,1); const displaced = richese.hand.pop(); if (displaced) source.push(displaced); richese.hand.push(semuta); } // A real held Karama keeps the native paid-or-free auction decision open. const buyer = seat(game,'a'); if (!buyer.hand.some(card => card.effect === 'karama')) { const holders = [game.deck, game.discard, game.richeseCache!, ...game.players.filter(player => player.id !== 'a').map(player => player.hand)]; const cards = holders.find(zone => zone.some(card => card.effect === 'karama')); assert.ok(cards); const index = cards.findIndex(card => card.effect === 'karama'), [karama] = cards.splice(index,1); const displaced = buyer.hand.pop(); if (displaced) cards.push(displaced); buyer.hand.push(karama); } assert.deepEqual(inventory(game), original); if (cacheSale) { game = advanceSemutaNexusTrade(game,state => state.decision?.kind === 'richeseCache'); const lot = game.richeseCache!.find(card => card.id !== SEMUTA_DRUG_ID); assert.ok(lot); const bought = structuredClone(lot), beforeSpice = seat(game,'a').spice; game = act(game,'r',{type:'decision', event:game.richeseBidding!.event, card:bought.id, method:'onceAround', direction:'clockwise'}); let bid = false; for (let n = 0; !game.pendingNexusIxianReplacement && n < 150; n++) { if (game.richeseAuction && !game.richeseAuction.outcome && !game.decision && !game.response && game.richeseAuction.active) { const actor = game.richeseAuction.active; const ownBid = actor === 'a' && !bid; game = act(game,actor,{type:'richeseBid',event:game.richeseAuction.event, amount:ownBid ? 2 : null, ...(ownBid ? {allyPayment:0} : {})}); if (ownBid) bid = true; } else { const next = nextSemutaNexusTradeNativeStep(game); game = act(game,next.actor,next.action); } } assert.ok(bid); assert.ok(game.pendingNexusIxianReplacement); assert.equal(game.currentAuctionSale?.origin,'cache'); assert.equal(game.pendingNexusIxianReplacement!.card.id,bought.id); assert.equal(seat(game,'a').spice,beforeSpice - 2); assert.ok(game.deck[0]); return {game, original, purchased:bought, replacement:structuredClone(game.deck[0]), beforeSpice}; } game = advanceSemutaNexusTrade(game, state => state.phase === 3 && !!state.auction && clean(state)); while (game.auction!.active !== 'a') game = act(game, game.auction!.active, {type:'passBid'}); game = act(game,'a',{type:'bid', amount:2, allyPayment:0}); game = advanceSemutaNexusTrade(game, state => state.decision?.kind === 'auctionPayment' && state.decision.player === 'a'); const purchased = structuredClone(game.auction!.cards[game.auction!.index]), beforeSpice = seat(game,'a').spice; // An empty draw deck uses the real discard recycling path. Stage only // original, unallocated cards before the purchase source is committed. if (exhaustDeck) game.discard.push(...game.deck.splice(0)); game = act(game,'a',{type:'decision',karama:false}); game = advanceSemutaNexusTrade(game, state => !!state.pendingNexusIxianReplacement); assert.equal(seat(game,'a').spice, beforeSpice - 2); assert.equal(game.pendingNexusIxianReplacement!.card.id,purchased.id); assert.equal(game.deck.length === 0,exhaustDeck); return {game, original, purchased, replacement:game.deck[0] ? structuredClone(game.deck[0]) : null, beforeSpice}; } for (const claim of [false,true]) void test(`Basic public Ixian Nexus purchased-card discard offers Semuta ${claim ? 'claim' : 'pass'} before replacement`, () => { const f = fixture(), before = structuredClone(f.game), bought = f.purchased.id; assert.ok(f.replacement); const pending = act(f.game,'a',{type:'nexusIxianReplacementUse',event:f.game.pendingNexusIxianReplacement!.event}); assert.deepEqual(f.game,before, 'Original input remains unchanged'); assert.equal(pending.nexusCards!.cards!.hands.a,null); assert.equal(pending.nexusCards!.cards!.discard.filter(face => face === 'ixians').length,1); assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage,'offer'); assert.equal(pending.pendingTreacheryDiscard!.batch.entries.length,1); assert.equal(pending.pendingTreacheryDiscard!.batch.entries[0].card.id,bought); assert.equal(pending.pendingTreacheryDiscard!.batch.entries[0].publicFace,false); assert.equal(pending.pendingTreacheryDiscard!.batch.entries[0].discardedBy,'a'); assert.deepEqual(pending.deck,before.deck, 'Replacement draw waits for the fresh discard response'); assert.deepEqual(viewGame(pending,'r').semutaReaction!.candidates,[]); assert.deepEqual(viewGame(pending,'g').semutaReaction!.candidates,[]); const event = pending.pendingTreacheryDiscard!.batch.event; for (const difficulty of DIFFICULTIES) { const view = viewGame(pending,'r'); view.players.find(player => player.id === 'r')!.bot = difficulty; const action = botActions(view).find(row => row.type === 'semutaCommit' || row.type === 'semutaPass'); assert.ok(action); assert.doesNotThrow(() => act(pending,'r',action)); } let game = pending; if (claim) game = act(game,'r',{type:'semutaCommit',event}); else for (const player of game.players) game = act(game,player.id,{type:'semutaPass',event}); assert.equal(game.pendingTreacheryDiscard,null); assert.equal(game.pendingNexusIxianReplacement,null); assert.equal(seat(game,'a').spice,f.beforeSpice-2); assert.equal(seat(game,'a').hand.some(card => card.id === f.replacement!.id),true); assert.equal(seat(game,'r').hand.some(card => card.id === bought),claim); assert.equal(game.discard.some(card => card.id === bought),!claim); assert.equal(game.discard.some(card => card.id === SEMUTA_DRUG_ID),claim); assert.equal(game.nexusIxianReplacementHistory!.length,1); assert.equal(game.nexusIxianReplacementHistory![0].source.card.id,bought); assert.equal(game.nexusIxianReplacementHistory![0].replacement!.id,f.replacement.id); assert.deepEqual(game.deck.map(card => card.id),before.deck.slice(1).map(card => card.id)); assert.deepEqual(inventory(game),f.original); }); for (const claim of [false,true]) void test(`Basic Richese cache sale offers its Ixian Nexus discard to Semuta ${claim ? 'claim' : 'pass'}`, () => { const f = fixture(false,true), before = structuredClone(f.game), bought = f.purchased.id; assert.ok(f.replacement); assert.equal(before.currentAuctionSale?.origin,'cache'); const sellerSpice = seat(before,'r').spice; const pending = act(f.game,'a',{type:'nexusIxianReplacementUse',event:f.game.pendingNexusIxianReplacement!.event}); assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage,'offer'); assert.equal(pending.pendingTreacheryDiscard!.batch.entries[0].card.id,bought); assert.equal(pending.pendingTreacheryDiscard!.batch.entries[0].publicFace,false); assert.deepEqual(pending.deck,before.deck); assert.equal(pending.nexusCards!.cards!.hands.a,null); assert.equal(seat(pending,'r').spice,sellerSpice, 'The original seller receipt waits for the replacement'); const event = pending.pendingTreacheryDiscard!.batch.event; let game = pending; if (claim) game = act(game,'r',{type:'semutaCommit',event}); else for (const player of game.players) game = act(game,player.id,{type:'semutaPass',event}); assert.equal(game.pendingTreacheryDiscard,null); assert.equal(game.pendingNexusIxianReplacement,null); assert.equal(seat(game,'a').spice,f.beforeSpice-2); assert.equal(seat(game,'a').hand.some(card => card.id === f.replacement!.id),true); assert.equal(seat(game,'r').hand.some(card => card.id === bought),claim); assert.equal(seat(game,'r').spice,sellerSpice+2, 'The original seller receipt settles once'); assert.equal(game.nexusIxianReplacementHistory!.length,1); assert.equal(game.nexusIxianReplacementHistory![0].replacement!.id,f.replacement.id); assert.deepEqual(inventory(game),f.original); }); for (const claim of [false,true]) void test(`Basic Ixian Nexus replacement recycles an empty deck after Semuta ${claim ? 'claim' : 'pass'}`, () => { const f = fixture(true), before = structuredClone(f.game), bought = f.purchased.id; assert.equal(before.deck.length,0); assert.ok(before.discard.length > 0); const pending = act(f.game,'a',{type:'nexusIxianReplacementUse',event:f.game.pendingNexusIxianReplacement!.event}); assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage,'offer'); assert.equal(pending.deck.length,0, 'Recycling and replacement wait for Semuta'); assert.equal(pending.discard.filter(card => card.id === bought).length,1); assert.equal(pending.nexusCards!.cards!.hands.a,null); const event = pending.pendingTreacheryDiscard!.batch.event; let game = pending; if (claim) game = act(game,'r',{type:'semutaCommit',event}); else for (const player of game.players) game = act(game,player.id,{type:'semutaPass',event}); assert.equal(game.pendingTreacheryDiscard,null); assert.equal(game.pendingNexusIxianReplacement,null); assert.equal(game.nexusIxianReplacementHistory!.length,1); const replacement = game.nexusIxianReplacementHistory![0].replacement; assert.ok(replacement); assert.equal(seat(game,'a').hand.filter(card => card.id === replacement.id).length,1); assert.equal(seat(game,'a').hand.length,seat(before,'a').hand.length); assert.equal(seat(game,'a').spice,f.beforeSpice-2); assert.equal(seat(game,'r').hand.some(card => card.id === bought),claim); assert.equal(game.discard.length,0, 'The real discard pile was shuffled into the exhausted deck'); assert.equal(game.nexusCards!.cards!.discard.filter(face => face === 'ixians').length,1); assert.deepEqual(inventory(game),f.original); });