import assert from 'node:assert/strict'; import { applyAction, createGame, initializeDiscoveryGameForAudit, initializeEcazTreacheryGameForAudit, joinGame, newPlayer, type Action, type Game, } from '../game/engine'; import { placeAmbassador, validateAmbassadors } from '../game/ecaz-ambassadors'; import { placeTerror } from '../game/moritani-terror'; import { nextBasicSemutaAmbassadorStep } from './fixture-basic-semuta-ambassador'; import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income'; export const act = (game: Game, actor: string, action: Action): Game => applyAction(structuredClone(game), actor, action); export function player(game: Game, id: string) { const seat = game.players.find(row => row.id === id); assert.ok(seat); return seat; } /** Original Advanced native setup, actual Great Maker votes and reserve ride. * The test controls a later turn/phase boundary, the existing unplayed Spice * order and original physical Terror/Ambassador placement. It does not stage * forces, player purses, a Great Maker encounter, a pending reaction or receipt. * Ecaz additionally selects the independent printed card variant to enter the * existing paired E3 Discovery profile. Paired tests control the public order * before the real Great Maker votes to exercise both original owner orders. */ export function greatMakerSneakFixture(withBG = false, withEcaz = false, ambassadorEffect: 'fremen' | 'emperor' = 'fremen', first: 'terror' | 'ambassador' = 'terror') { let game = createGame('MAKERSNEAK', newPlayer('moritani','Moritani','moritani'), true, ['ecaz']); for (const faction of ['fremen','atreides', ...(withBG ? ['beneGesserit'] as const : []), ...(withEcaz ? ['ecaz'] as const : [])] as const) joinGame(game, newPlayer(faction,faction,faction)); game.discoveryEnabled = true; for (const seat of game.players) game = act(game, seat.id, {type:'ready'}); game = withEcaz ? initializeEcazTreacheryGameForAudit(game) : initializeDiscoveryGameForAudit(game); for (let count = 0; game.status === 'setup' && count < 250; count++) { if (game.setupStage === 'forces' && player(game,'fremen').reserves === 20) { game = act(game,'fremen',{type:'fremenSetup',placements:{sietch_tabr:10}}); continue; } const next = withEcaz ? nextBasicSemutaAmbassadorStep(game) : nextSpiceBankerIncomeNativeStep(game); assert.ok(next, 'Native Discovery setup needs an existing legal choice'); game = act(game, next.actor, next.action); } assert.equal(game.status, 'playing'); assert.ok(game.advanced && game.discoveryEnabled && game.discoveries); const sneak = game.moritaniTerror!.tokens.find(row => row.kind === 'sneakAttack'); assert.ok(sneak); game.moritaniTerror = placeTerror(game.moritaniTerror!, sneak.id, 'carthag', 1); let ambassador: string | undefined; if (withEcaz) { const inventory = game.ecazAmbassadors!; const token = inventory.tokens.find(row => row.effect === ambassadorEffect); assert.ok(token); if (token.zone === 'pool') { const replacement = inventory.tokens.find(row => row.zone === 'supply' && row.effect !== 'ecaz'); assert.ok(replacement); const index = inventory.cohort.indexOf(replacement.id); assert.ok(index >= 0); inventory.cohort[index] = token.id; replacement.zone = 'pool'; token.zone = 'supply'; } assert.equal(token.zone, 'supply'); validateAmbassadors(inventory); game.ecazAmbassadors = placeAmbassador(inventory, token.id, {turn:1, availableSpice:player(game,'ecaz').spice, destination:{id:'carthag',stronghold:true,inStorm:false,allowed:true}}).state; ambassador = token.id; } const take = (matches: (card: Game['spiceDeck'][number]) => boolean) => { const index = game.spiceDeck.findIndex(matches); assert.ok(index >= 0); return game.spiceDeck.splice(index,1)[0]; }; const maker = take(card => 'worm' in card && card.greatMaker === true); const source = take(card => 'territory' in card && card.territory === 'broken_land' && !card.discovery); const replacement = take(card => 'territory' in card && !card.discovery); Object.assign(game, {turn:2, phase:1, storm:18, nexus:false, summonedBeforeBlow:false, wormRides:[], spiceSequence:null, spiceResolution:null, spiceWindow:null, response:null, decision:null, phaseOpening:null, ready:[]}); game.spiceDiscard = [[source], []]; game.spiceDeck = [maker, replacement, ...game.spiceDeck]; if (withEcaz && ambassadorEffect !== 'fremen') { const firstOwner = first === 'terror' ? 'moritani' : 'ecaz'; const secondOwner = first === 'terror' ? 'ecaz' : 'moritani'; game.order = [firstOwner, secondOwner, ...game.order.filter(id => id !== firstOwner && id !== secondOwner)]; } for (const seat of game.players) game = act(game, seat.id, {type:'ready'}); assert.equal(game.greatMaker?.stage, 'vote'); while (game.decision?.kind === 'greatMakerVote') { const decision = game.decision; game = act(game, decision.player, {type:'decision',event:decision.event,yes:false}); } assert.equal(game.decision?.kind, 'greatMakerRide'); const event = game.decision.event; const originalReserves = player(game,'fremen').reserves; game = act(game,'fremen',{type:'decision',event,accept:true, territory:'carthag',sector:11,amount:1,elite:0}); assert.equal(game.greatMaker?.stage, 'arrival'); if (withEcaz && ambassadorEffect !== 'fremen' && first === 'ambassador') assert.equal(game.pendingAmbassador?.token, ambassador); else assert.equal(game.pendingTerrorEntry?.token, sneak.id); assert.equal(player(game,'fremen').reserves, originalReserves - 1); return {game, event, sneak:sneak.id, ambassador, withBG, withEcaz, first, originalReserves, destination:'carthag', sector:11}; }