import assert from 'node:assert/strict'; import { viewGame, type Action, type Game } from '../game/engine'; import { territory } from '../game/board'; import { advancePairedE3Nexus, createPairedE3NexusSetup, nextPairedE3NexusStep, pairedE3NexusClean, pairedE3NexusInventory, pairedE3NexusPlayer, pairedE3NexusReload, quietPairedE3NexusChoice, stepPairedE3Nexus, type PairedE3NexusOptions, type PairedE3NexusStep, type PairedE3NexusTransition, } from './fixture-paired-e3-nexus-modules'; import { orderE3NexusEcazSpice } from './fixture-e3-nexus-ecaz'; import { quietPlan } from './fixture-nexus-duke-capture'; /** Original Ecaz/Moritani/Guild, with explicit original setup module choices. * The shared setup selects only original undealt entropy. Here the original * unplayed Treachery deck puts a Karama in the first real auction; Ecaz buys * that physical card for one spice. Only the conserved, unplayed Spice deck * is ordered for land blows on turn one and an actual Nexus on turn two. * No hand, wallet, army, leader, phase, alliance or response is staged. * * A one-force stronghold arrival has no rounded native-rate discount to * cancel (half/full both cost one). Larger native shipments retain the * separate prepayment rate window before this fixture's income path. */ export function createGuildAmbassadorIncomeArrival(options: Pick = {}) { const setup = createPairedE3NexusSetup({ roster: ['ecaz', 'moritani', 'guild'], advanced: true, skills: false, homeworlds: false, discovery: false, tech: false, strongholds: false, ...options }); const actions = setup.actions; const ecaz = setup.ecaz, entrant = setup.moritani; const guild = setup.afterSetup.players.find(p => p.faction === 'guild')!.id; let game = pairedE3NexusReload(setup.afterSetup); const karamaIndex = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(karamaIndex >= 0); const karama = game.deck.splice(karamaIndex, 1)[0]; game.deck.unshift(karama); orderE3NexusEcazSpice(game); game = advancePairedE3Nexus(game, s => s.phase === 3 && pairedE3NexusClean(s) && s.auction?.active === ecaz && s.auction.index === 0, actions, quietPairedE3NexusChoice); const purchaseBefore = pairedE3NexusReload(game); const bid: PairedE3NexusStep = { actor: ecaz, action: { type: 'bid', amount: 1 } }; game = stepPairedE3Nexus(game, bid, actions); game = advancePairedE3Nexus(game, s => pairedE3NexusPlayer(s, ecaz).hand.some(c => c.id === karama.id) && pairedE3NexusClean(s), actions, quietPairedE3NexusChoice); const purchase = { before: purchaseBefore, step: bid, after: pairedE3NexusReload(game) }; game = advancePairedE3Nexus(game, s => s.turn === 2 && s.phase === 1 && pairedE3NexusClean(s), actions, quietPairedE3NexusChoice); orderE3NexusEcazSpice(game, true); game = advancePairedE3Nexus(game, s => !!s.nexus && !s.spiceWindow && !s.spiceResolution && pairedE3NexusClean(s), actions, quietPairedE3NexusChoice); const beforeAlliance = pairedE3NexusReload(game); for (const [actor, target] of [[entrant, guild], [guild, entrant]]) game = stepPairedE3Nexus(game, { actor, action: { type: 'alliance', target } }, actions); const alliance = pairedE3NexusReload(game); const native = quietPlan(game); const token = native.token, destination = native.destination; const sector = territory(destination).sectors.find(s => s !== game.storm)!; assert.ok(Number.isInteger(sector)); game = advancePairedE3Nexus(game, s => !s.response && !s.phaseOpening && s.decision?.kind === 'ecazPlacement', actions, quietPairedE3NexusChoice); const placementBefore = pairedE3NexusReload(game); const place: PairedE3NexusStep = { actor: ecaz, action: { type: 'decision', token, territory: destination } }; game = stepPairedE3Nexus(game, place, actions); game = advancePairedE3Nexus(game, s => !s.response && s.ecazAmbassadors!.tokens.find(t => t.id === token)!.zone === 'placed', actions, quietPairedE3NexusChoice); const placement = { before: placementBefore, step: place, after: pairedE3NexusReload(game) }; game = advancePairedE3Nexus(game, s => s.phase === 5 && s.active === entrant && pairedE3NexusClean(s), actions, quietPairedE3NexusChoice); const source = Object.entries(pairedE3NexusPlayer(game, entrant).forces) .find(([key, n]) => n > 0 && Number(key.split(':')[1]) !== game.storm && key.split(':')[0] !== destination)?.[0]; assert.ok(source, 'The native Moritani setup must retain an original on-board source'); assert.equal(pairedE3NexusPlayer(game, entrant).ally, guild); assert.equal(pairedE3NexusPlayer(game, guild).ally, entrant); assert.equal(pairedE3NexusPlayer(game, ecaz).ally, null); assert.equal(game.ecazAmbassadors!.tokens.find(t => t.id === token)!.effect, 'ecaz'); assert.ok(pairedE3NexusPlayer(game, ecaz).hand.some(c => c.id === karama.id)); pairedE3NexusInventory(game); const action: Action = { type: 'guildShip', from: source, amount: 1, elite: 0, territory: destination, sector, allyPayment: 0 }; return { ...setup, ecaz, entrant, guild, source, destination, sector, target: `${destination}:${sector}`, token, karama: karama.id, purchase, beforeAlliance, alliance, placement, beforeAction: pairedE3NexusReload(game), action, actions, provenance: [ 'Shared original undealt setup entropy; no played physical state assigned.', 'Conserved unplayed Treachery deck: first original Karama auctioned and paid by Ecaz.', 'Conserved unplayed Spice deck: turn-one land blows, turn-two native worm/Nexus.', ] }; } /** Retain the actual unpaid native permission declaration as one engine transition. */ export function declareGuildAmbassadorIncomeArrival(fixture: { beforeAction: Game; entrant: string; action: Action; }): PairedE3NexusTransition { const before = pairedE3NexusReload(fixture.beforeAction); const step = { actor: fixture.entrant, action: fixture.action }; const after = stepPairedE3Nexus(before, step); assert.equal(after.response?.kind, 'guildTransportPermission'); assert.ok(!after.pendingArrivalReaction); assert.ok(!after.pendingAmbassador); return { before, step, after }; } /** Pass only the actual native permission window; rate/income choices stay separate. */ export function passNativeGuildTransportPermission(state: Game) { let game = pairedE3NexusReload(state); const actions: PairedE3NexusStep[] = []; for (let n = 0; game.response?.kind === 'guildTransportPermission' && n < 20; n++) { const step = nextPairedE3NexusStep(game); assert.ok(step); assert.equal(step.action.type, 'passResponse'); game = stepPairedE3Nexus(game, step, actions); } assert.notEqual(game.response?.kind, 'guildTransportPermission'); return { game, actions }; } /** Pass the actual persisted income queue; do not synthesize or clear controls. */ export function allowGuildAmbassadorIncomeArrival(state: Game) { let game = pairedE3NexusReload(state); const actions: PairedE3NexusStep[] = []; for (let n = 0; game.response && n < 20; n++) { assert.equal(game.response.kind, 'guildIncome'); const step = nextPairedE3NexusStep(game); assert.ok(step); assert.equal(step.action.type, 'passResponse'); game = stepPairedE3Nexus(game, step, actions); } assert.equal(game.response, null); assert.equal(game.decision?.kind, 'ecazAmbassador'); assert.equal(game.pendingAmbassador?.stage, 'offer'); return { game, actions }; } /** Spend the actual auctioned card through the original income cancellation. */ export function cancelGuildAmbassadorIncomeArrival(state: Game, actor: string, card: string) { const before = pairedE3NexusReload(state); assert.equal(before.response?.kind, 'guildIncome'); const controls = viewGame(before, actor).responseControls; assert.ok(controls && !controls.hasPassed && controls.cancelCards.includes(card)); const step: PairedE3NexusStep = { actor, action: { type: 'card', mode: 'cancel', card } }; const after = stepPairedE3Nexus(before, step); assert.equal(after.response, null); assert.equal(after.decision?.kind, 'ecazAmbassador'); assert.equal(after.pendingAmbassador?.stage, 'offer'); return { before, step, after }; } export function guildAmbassadorIncomeChoice(game: Game, trigger = true): PairedE3NexusStep { assert.equal(game.decision?.kind, 'ecazAmbassador'); const entry = game.pendingAmbassador!; return { actor: game.decision.player, action: { type: 'decision', event: entry.event, ...(trigger ? { trigger: true, beneficiary: game.decision.player, choice: 'duke' } : { decline: true }) } }; } /** A single original offer completion, then the original entrant's legal next action. */ export function completeGuildAmbassadorIncomeArrival(state: Game, trigger = true) { const before = pairedE3NexusReload(state), step = guildAmbassadorIncomeChoice(before, trigger); const after = stepPairedE3Nexus(before, step); assert.ok(pairedE3NexusClean(after)); const continuation: PairedE3NexusStep = { actor: after.active!, action: { type: 'endMovement' } }; return { before, step, after, continuation }; }