import assert from 'node:assert/strict'; import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine'; import { placeAmbassador, validateAmbassadors } from '../game/ecaz-ambassadors'; import { placeTerror } from '../game/moritani-terror'; import { fremenAmbassadorMovement } from '../game/fremen-ambassador'; import { guildAmbassadorShipments } from '../game/guild-ambassador'; import { advanceBasicSemutaAmbassador as advance, actBasicSemutaAmbassador as act, basicSemutaAmbassadorClean as clean, basicSemutaAmbassadorPlayer as player } from './fixture-basic-semuta-ambassador'; export { act, player }; export type ChildEffect = 'fremen' | 'guild'; export type First = 'ambassador' | 'terror'; /** Public Advanced native setup and an actual paid faction shipment. The chosen * original Ambassador cohort member and the single original Terror marker are * controlled, physical placements at the clean turn boundary. No player force, * purse, order, pending decision or receipt is staged. */ export function advancedOverlapChildFixture(effect: ChildEffect, first: First, withBG = false) { let game = createGame('ADVOVERLAPCHILD', newPlayer('ecaz','Ecaz','ecaz'), true, ['ecaz']); for (const faction of ['atreides','emperor',...(withBG ? ['beneGesserit'] as const : []), ...(withBG ? [] : ['guild'] as const),'moritani'] as const) joinGame(game, newPlayer(faction, faction, faction)); const send = (actor: string, action: Action) => { game = act(game, actor, action); }; const reach = (until: (state: Game) => boolean) => { game = advance(game, until); }; send(withBG ? 'ecaz' : 'guild', { type:'seatPosition', position:6 }); if (withBG) { send('atreides', { type:'seatPosition', position:1 }); send('emperor', { type:'seatPosition', position:2 }); send('beneGesserit', { type:'seatPosition', position:3 }); } if (first === 'terror') send('moritani', { type:'seatPosition', position:4 }); send('ecaz', { type:'seatPosition', position:first === 'ambassador' ? 4 : 5 }); if (!withBG) { send('atreides', { type:'seatPosition', position:1 }); send('emperor', { type:'seatPosition', position:2 }); } if (withBG) joinGame(game, newPlayer('guild','guild','guild')); for (const seat of game.players) send(seat.id, { type:'ready' }); send('ecaz', { type:'start', advancedPreview:true }); reach(state => state.status === 'playing'); assert.ok(game.advanced && !game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens && !game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery); // Select existing unopened ordinary Spice cards, never an invented draw. for (const position of [0,1]) { const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && !card.discovery); assert.ok(index >= position); [game.spiceDeck[position], game.spiceDeck[index]] = [game.spiceDeck[index], game.spiceDeck[position]]; } const entrant = effect === 'guild' ? 'emperor' : 'guild'; reach(state => state.turn === 1 && state.phase === 5 && state.active === entrant && clean(state)); assert.equal(game.order.indexOf('ecaz') < game.order.indexOf('moritani'), first === 'ambassador'); const candidates = fremenAmbassadorMovement(game, 'ecaz').sources.flatMap(source => source.sectors.filter(sector => sector.forces > 0).flatMap(sector => source.destinations.filter(destination => destination.territory !== source.territory && ['arrakeen','carthag','sietch_tabr','habbanya_ridge_sietch','tueks_sietch'].includes(destination.territory)) .map(destination => ({ source, sector, destination })))) .filter(option => guildAmbassadorShipments(game, 'ecaz').destinations.some(destination => destination.territory === option.destination.territory && destination.sector === option.destination.sector)); const candidate = candidates[0]; assert.ok(candidate, 'Ecaz needs a reachable physical stronghold and legal reserve destination'); const destination = candidate.destination.territory, sector = candidate.destination.sector; const ambassadors = game.ecazAmbassadors!; const token = ambassadors.tokens.find(item => item.effect === effect); assert.ok(token); if (token.zone === 'pool') { const replacement = ambassadors.tokens.find(item => item.zone === 'supply' && item.effect !== 'ecaz'); assert.ok(replacement); const index = ambassadors.cohort.indexOf(replacement.id); assert.ok(index >= 0); ambassadors.cohort[index] = token.id; replacement.zone = 'pool'; token.zone = 'supply'; } assert.equal(token.zone, 'supply'); validateAmbassadors(ambassadors); game.ecazAmbassadors = placeAmbassador(ambassadors, token.id, { turn:game.turn, availableSpice:player(game,'ecaz').spice, destination:{id:destination,stronghold:true,inStorm:false,allowed:true} }).state; const terror = game.moritaniTerror!.tokens.find(item => item.kind === 'robbery' && item.status === 'available'); assert.ok(terror); game.moritaniTerror = placeTerror(game.moritaniTerror!, terror.id, destination, game.turn); const alternate = candidates.find(item => item.destination.territory !== destination); let sneak: string | undefined; if (withBG) { assert.ok(alternate, 'The Sneak fixture requires a second reachable stronghold'); const child = game.moritaniTerror.tokens.find(item => item.kind === 'sneakAttack' && item.status === 'available'); assert.ok(child); // A second existing marker is staged without adding a force or resource. child.status = 'placed'; child.location = alternate.destination.territory; sneak = child.id; } const shipment: Action = { type:'ship', territory:destination, sector, amount:1, elite:0, allyPayment:0 }; return { game, effect, first, destination, sector, token:token.id, terror:terror.id, shipment, moveFrom:candidate.sector.key, alternate, sneak, entrant, owner:'ecaz', moritani:'moritani' }; }