import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; import { emperorNexusBankPurchaseModeSupported } from '../game/nexus-emperor-purchase'; import { advanceSemutaNexusTrade, semutaNexusTradeInventory, semutaNexusTradePlayer, swapSemutaNexusTradeIdentity } from './fixture-semuta-nexus-trade'; import { nextNexusEmperorBankStep, stepNexusEmperorBank } from './fixture-nexus-emperor-bank'; const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard && !game.truthtrance; function frontSpice(game: Game, kinds: ('land' | 'worm')[]) { for (let i = 0; i < kinds.length; i++) { const index = game.spiceDeck.findIndex((card, n) => n >= i && (kinds[i] === 'land' ? 'territory' in card : 'worm' in card)); assert.ok(index >= i); game.spiceDeck.splice(i, 0, game.spiceDeck.splice(index, 1)[0]); } } /** Real initial setup, original Nexus and auction. Only unopened Spice order, * already-drawn Nexus identity and conserved board positions are staged. */ function fixture() { let game = createGame('SEMUTANEXUSBANK', newPlayer('r', 'Richese', 'richese'), false, ['choam']); for (const [id, faction] of [['g','guild'],['a','atreides'],['h','harkonnen']] as const) joinGame(game, newPlayer(id, faction, faction)); game.nexusCards = { cards: null, phase: null }; for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' }); game = initializeSemutaGameForAudit(game); assert.equal(game.semutaPreview, true); game = advanceSemutaNexusTrade(game, g => g.status === 'playing'); assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null)); game = advanceSemutaNexusTrade(game, g => g.phase === 1 && clean(g)); frontSpice(game, ['land','land']); game = advanceSemutaNexusTrade(game, g => g.phase === 5 && clean(g)); for (const player of game.players) { player.reserves += Object.values(player.forces).reduce((total, count) => total + count, 0); player.forces = {}; if (player.elites) { player.elites.reserves += Object.values(player.elites.forces).reduce((total, count) => total + count, 0); player.elites.forces = {}; } } game = advanceSemutaNexusTrade(game, g => g.turn === 2 && g.phase === 1 && clean(g)); frontSpice(game, ['worm','land','land']); game = advanceSemutaNexusTrade(game, g => g.nexus && !g.spiceWindow && clean(g)); game = applyAction(game, 'g', {type:'alliance', target:'a'}); game = applyAction(game, 'a', {type:'alliance', target:'g'}); game = advanceSemutaNexusTrade(game, g => g.nexusCards?.phase?.stage === 'drawing'); game = applyAction(game, 'r', {type:'nexusCardChoice', turn:game.turn, card:null, choice:'draw', ownRedraws:0}); swapSemutaNexusTradeIdentity(game, 'r', 'emperor'); game = advanceSemutaNexusTrade(game, g => g.phase === 3 && !!g.auction && !g.richeseAuction && clean(g)); while (game.auction!.active !== 'r') game = applyAction(game, game.auction!.active, {type:'passBid'}); game = applyAction(game, 'r', {type:'bid', amount:2, allyPayment:0}); game = advanceSemutaNexusTrade(game, g => g.decision?.kind === 'auctionPayment'); assert.equal(game.decision!.player, 'r'); assert.equal(game.auction!.bid, 2); return game; } for (const use of [false, true]) void test(`Basic Semuta Nexus bank auction ${use ? 'uses Emperor Secret Ally' : 'pays original price'} once`, () => { let game = fixture(); const inventory = semutaNexusTradeInventory(game), before = structuredClone(game); const buyer = semutaNexusTradePlayer(game, 'r'), card = game.auction!.cards[game.auction!.index]; const offer = viewGame(game, 'r').nexusEmperorSecretAlly; assert.ok(offer?.purchase && !offer.purchase.blocked); assert.ok(buyer.spice >= 2); const price = buyer.spice; game = applyAction(game, 'r', use ? {type:'nexusEmperorPurchase', event:offer.event} : {type:'decision', karama:false}); assert.deepEqual(before.auction!.cards[before.auction!.index], card); assert.equal(semutaNexusTradePlayer(game, 'r').spice, price - (use ? 0 : 2)); assert.ok(semutaNexusTradePlayer(game, 'r').hand.some(held => held.id === card.id)); assert.equal(game.nexusCards!.cards!.hands.r, use ? null : 'emperor'); assert.equal(game.nexusCards!.cards!.discard.filter(face => face === 'emperor').length, Number(use)); for (let n = 0; n < 100 && game.auction?.index === before.auction!.index; n++) { assert.notEqual(game.decision?.kind, 'auctionPayment', 'Original lot must not charge twice'); game = stepNexusEmperorBank(game, nextNexusEmperorBankStep(game)); } assert.notEqual(game.auction?.index, before.auction!.index); assert.equal(semutaNexusTradePlayer(game, 'r').spice, price - (use ? 0 : 2)); assert.deepEqual(semutaNexusTradeInventory(game), inventory); }); void test('Basic Semuta bank purchase uses the supported native roster, not unrelated overlay flags', () => { const game = createGame('BANKPROFILE', newPlayer('r','Richese','richese'), false, ['choam']); joinGame(game, newPlayer('g','Guild','guild')); game.nexusCards = {cards:null,phase:null}; game.semutaPreview = true; assert.equal(emperorNexusBankPurchaseModeSupported(game), true); const unusedIx = structuredClone(game); unusedIx.expansions.push('ix'); assert.equal(emperorNexusBankPurchaseModeSupported(unusedIx), true); for (const change of [ (g:Game) => { g.advanced = true; }, (g:Game) => { g.homeworlds = {custody:null}; }, (g:Game) => { g.discoveryEnabled = true; }, (g:Game) => { g.expansions.push('ix', 'ix'); }, (g:Game) => { g.nexusCards = undefined; }, (g:Game) => { joinGame(g,newPlayer('e','Emperor','emperor')); }, ]) { const unsupported = structuredClone(game); change(unsupported); assert.equal(emperorNexusBankPurchaseModeSupported(unsupported), false); } }); for (const claim of [false, true]) for (const use of [false, true]) void test( `Semuta ${claim ? 'claim' : 'pass'} after Truthtrance retains the original Emperor Nexus bank offer and ${use ? 'Use' : 'Pay'}`, () => { let game = fixture(); // Move only existing physical cards into holders before using the already // opened native window; exchange a held card to retain each hand size. const place = (owner: string, matches: (card: Game['deck'][number]) => boolean) => { const seat = semutaNexusTradePlayer(game, owner); const existing = seat.hand.find(matches); if (existing) return existing.id; const zones = [game.deck, game.discard, game.richeseCache!, game.richeseRemoved!, ...game.players.filter(player => player.id !== owner).map(player => player.hand)]; const source = zones.find(cards => cards.some(matches)); assert.ok(source); const index = source.findIndex(matches), displaced = seat.hand.pop(); assert.ok(displaced); const [card] = source.splice(index, 1, displaced); seat.hand.push(card); return card.id; }; const truth = place('h', card => card.effect === 'truthtrance'); const semuta = place('a', card => card.effect === 'semutaDrug'); const original = semutaNexusTradeInventory(game), spice = semutaNexusTradePlayer(game,'r').spice; const originalOffer = viewGame(game,'r').nexusEmperorSecretAlly!; game = applyAction(game, 'h', {type:'card', card:truth}); while (game.truthtrance?.stage === 'priority') { const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id))!.id; game = applyAction(game, actor, {type:'truthPass'}); } const name = semutaNexusTradePlayer(game,'r').hand[0].name; game = applyAction(game, 'h', {type:'truthAsk',question:{kind:'fact',target:'r',fact:{kind:'hand',name}}}); game = applyAction(game, 'r', {type:'truthAnswer',answer:'yes'}); assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer'); const event = game.pendingTreacheryDiscard!.batch.event; if (claim) game = applyAction(game, 'a', {type:'semutaCommit',event}); else for (const seat of game.players) game = applyAction(game, seat.id, {type:'semutaPass',event}); assert.equal(semutaNexusTradePlayer(game,'a').hand.some(card => card.id === truth), claim); assert.equal(game.discard.some(card => card.id === semuta), claim); assert.equal(game.discard.some(card => card.id === truth), !claim); assert.equal(viewGame(game,'r').nexusEmperorSecretAlly?.event, originalOffer.event); game = applyAction(game, 'r', use ? {type:'nexusEmperorPurchase',event:originalOffer.event} : {type:'decision',karama:false}); assert.equal(semutaNexusTradePlayer(game,'r').spice,spice - (use ? 0 : 2)); assert.equal(game.nexusCards!.cards!.hands.r,use ? null : 'emperor'); // Paying retains the held Emperor Nexus, so the newly initialized neutral // Ixian Secret Ally purchase window must be passed before the lot closes. if (!use) { assert.ok(game.pendingNexusIxianReplacement); game = applyAction(game, 'r', {type:'nexusIxianReplacementPass',event:game.pendingNexusIxianReplacement.event}); } assert.deepEqual(semutaNexusTradeInventory(game),original); });