import assert from 'node:assert/strict'; import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine'; import { TERRITORIES } from '../game/board'; import { RECRUITS_CARD_ID } from '../game/recruits'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; import { actBasicSemutaAmbassador, basicSemutaAmbassadorClean, basicSemutaAmbassadorInventory, basicSemutaAmbassadorPlayer, type BasicSemutaAmbassadorStep, } from './fixture-basic-semuta-ambassador'; export const actSemutaRecruits = actBasicSemutaAmbassador; export const semutaRecruitsInventory = basicSemutaAmbassadorInventory; export const semutaRecruitsPlayer = basicSemutaAmbassadorPlayer; export const semutaRecruitsClean = basicSemutaAmbassadorClean; function next(game: Game): BasicSemutaAmbassadorStep | null { if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') { const reaction = game.pendingTreacheryDiscard.reaction; const actor = game.players.find(player => !reaction.passed.includes(player.id)); assert.ok(actor); return { actor: actor.id, action: {type: 'semutaPass', event: game.pendingTreacheryDiscard.batch.event} }; } return nextRicheseStrongholdsNativeStep(game); } /** Public Basic selected-card start. Only unopened deal/Spice order is * selected: Guild buys the physical Semuta, Atreides buys the original Recruits, * then pays for a real shipment that the next Storm sends to the Tanks. */ export function createSemutaRecruitsFixture() { let game = createGame('SEMUTARECRUITS', newPlayer('atreides', 'Atreides', 'atreides'), false, ['choam']); for (const faction of ['emperor', 'richese', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction)); const owner = 'atreides', holder = 'guild', actions: BasicSemutaAmbassadorStep[] = []; const act = (actor: string, action: Action) => { game = actSemutaRecruits(game, actor, action, actions); }; const reach = (until: (state: Game) => boolean) => { for (let count = 0; count < 1600; count++) { if (until(game)) return; assert.notEqual(game.status, 'finished'); const step = next(game); assert.ok(step, `Native action required at ${game.turn}/${game.phase}/${game.decision?.kind}`); act(step.actor, step.action); } throw Error('The original Semuta/Recruits source did not reach its requested boundary.'); }; act(owner, { type: 'moduleOption', module: 'ecazTreachery', enabled: true }); for (const player of game.players) act(player.id, { type: 'ready' }); const lobby = structuredClone(game); act(owner, { type: 'start' }); const setup = structuredClone(game); assert.equal(game.status, 'setup'); assert.equal(game.semutaRecruitsPreview, true); assert.ok(game.ecazTreachery && !game.techTokens && !game.leaderSkills && !game.nexusCards && !game.homeworlds); const originalInventory = semutaRecruitsInventory(game); const starting = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)) .slice(0, game.players.length).map(card => card.id); assert.equal(starting.length, game.players.length); const selectedDeal = [...starting, RECRUITS_CARD_ID]; for (let position = 0; position < selectedDeal.length; position++) { const from = game.deck.findIndex(card => card.id === selectedDeal[position]); assert.ok(from >= position); [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; } for (let count = 0; game.status === 'setup' && count < 100; count++) { const step = next(game); assert.ok(step); act(step.actor, step.action); } assert.equal(game.status, 'playing'); for (let position = 0; position < 4; position++) { const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery); assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card); } reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache'); act('richese', { type: 'decision', event: game.richeseBidding!.event, card: SEMUTA_DRUG_ID, method: 'onceAround', direction: 'counterclockwise' }); reach(state => semutaRecruitsClean(state) && !!state.richeseAuction && !state.richeseAuction.outcome); for (let count = 0; !semutaRecruitsPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID) && count < 8; count++) { const lot = game.richeseAuction; assert.ok(lot && lot.active && !lot.outcome); act(lot.active, { type: 'richeseBid', event: lot.event, amount: lot.active === holder ? 1 : null, allyPayment: 0 }); reach(semutaRecruitsClean); } assert.ok(semutaRecruitsPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID)); reach(state => state.phase === 3 && semutaRecruitsClean(state) && state.auction?.active === owner); assert.equal(game.auction!.cards[game.auction!.index].id, RECRUITS_CARD_ID); act(owner, { type: 'bid', amount: 1, allyPayment: 0 }); reach(state => semutaRecruitsClean(state) && semutaRecruitsPlayer(state, owner).hand.some(card => card.id === RECRUITS_CARD_ID)); reach(state => state.phase === 5 && state.active === owner && semutaRecruitsClean(state)); const sector = game.storm % 18 + 1; const destination = TERRITORIES.find(place => place.type === 'sand' && place.sectors.includes(sector) && game.players.every(player => !Object.keys(player.forces).some(key => key.startsWith(`${place.id}:`)))); assert.ok(destination, 'The next Storm path has an empty legal sand sector'); const casualtyLocation = `${destination.id}:${sector}`; act(owner, { type: 'ship', territory: destination.id, sector, amount: 3, elite: 0, allyPayment: 0 }); reach(state => state.turn === 2 && state.phase === 4 && semutaRecruitsClean(state)); assert.equal(semutaRecruitsPlayer(game, owner).forces[casualtyLocation], undefined); assert.equal(semutaRecruitsPlayer(game, owner).tanks, 3); assert.deepEqual(semutaRecruitsInventory(game), originalInventory); return { game, owner, holder, lobby, setup, actions, originalInventory, casualtyLocation, play: {type: 'card', card: RECRUITS_CARD_ID} as Action }; }