import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { validateAmbassadors, type AmbassadorEffect } from '../game/ecaz-ambassadors'; import { fremenAmbassadorMovement } from '../game/fremen-ambassador'; import { guildAmbassadorShipments } from '../game/guild-ambassador'; import { finishEcazOccupySetup } from './fixture-ecaz-occupy'; import { homeworldEcazClean, nextHomeworldEcazStep } from './fixture-homeworld-ecaz-occupy'; const seat = (g: Game, id: string) => g.players.find(p => p.id === id)!; const quiet = homeworldEcazClean; /** Native public setup: Ecaz and Moritani with a selected Ix deck and * Tleilaxu seat. Only the unopened Spice positions are chosen. */ function fixture(first: 'ambassador' | 'terror', effect: AmbassadorEffect = 'ecaz', entrant = 'guild') { let game = createGame('MIXEDARRIVAL', newPlayer('ecaz', 'Ecaz', 'ecaz'), false, ['ecaz', 'ix']); for (const faction of ['moritani', 'guild', 'tleilaxu'] as const) joinGame(game, newPlayer(faction, faction, faction)); const act = (actor: string, action: Action) => { game = applyAction(game, actor, action); }; if (first === 'ambassador') { act('ecaz', {type: 'seatPosition', position: 5}); act('moritani', {type: 'seatPosition', position: 1}); act('ecaz', {type: 'seatPosition', position: 2}); } act('ecaz', {type: 'moduleOption', module: 'ecazTreachery', enabled: true}); for (const player of game.players) act(player.id, {type: 'ready'}); act('ecaz', {type: 'start'}); assert.equal(game.status, 'setup'); assert.equal(game.ecazTreachery, true); assert.deepEqual(game.expansions, ['ecaz', 'ix']); game = finishEcazOccupySetup(game); const afterSetup = structuredClone(game); for (let index = 0; index < 3; index++) { const at = game.spiceDeck.findIndex((card, position) => position >= index && 'territory' in card && !card.discovery); assert.ok(at >= index); game.spiceDeck.splice(index, 0, game.spiceDeck.splice(at, 1)[0]); } const step = () => { if (game.phase === 5 && game.active && quiet(game)) act(game.active, {type: 'endMovement'}); else { const next = nextHomeworldEcazStep(game); assert.ok(next, `Native continuation ${game.turn}/${game.phase}/${game.decision?.kind}`); act(next.actor, next.action); } }; const reach = (predicate: (state: Game) => boolean) => { for (let count = 0; count < 900 && !predicate(game); count++) step(); assert.ok(predicate(game), 'The native turn must reach its original decision.'); }; reach(state => state.turn === 1 && !state.response && state.decision?.kind === 'ecazPlacement'); const ambassadors = game.ecazAmbassadors!; const chosen = ambassadors.tokens.find(token => token.effect === effect); assert.ok(chosen); // Child cases control only the five-token packet, preserving the original // physical inventory; both actual board placements are still native actions. if (chosen.zone === 'pool') { const replaced = ambassadors.tokens.find(token => token.zone === 'supply' && token.effect !== 'ecaz'); assert.ok(replaced); const index = ambassadors.cohort.indexOf(replaced.id); assert.ok(index >= 0); ambassadors.cohort[index] = chosen.id; replaced.zone = 'pool'; chosen.zone = 'supply'; validateAmbassadors(ambassadors); } assert.equal(chosen.zone, 'supply'); const ambassador = chosen.id; act('ecaz', {type: 'decision', token: ambassador, territory: 'arrakeen'}); reach(state => !state.response && state.ecazAmbassadors!.tokens.find(token => token.id === ambassador)!.zone === 'placed'); reach(state => state.turn === 1 && !state.response && state.decision?.kind === 'moritaniPlacement'); const terror = game.moritaniTerror!.tokens.find(token => token.kind === 'robbery' && token.status === 'available')!.id; act('moritani', {type: 'decision', token: terror, territory: 'arrakeen'}); reach(state => state.turn === 2 && state.phase === 5 && state.active === entrant && quiet(state)); const before = structuredClone(game); const action: Action = {type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0}; act(entrant, action); reach(state => !!state.pendingArrivalOverlap); assert.equal(game.pendingArrivalOverlap!.first, first); return {game, before, action, ambassador, terror, afterSetup, entrant}; } for (const first of ['ambassador', 'terror'] as const) void test(`mixed Basic independent cards preserve ${first}-first native arrival and its one payment`, () => { const f = fixture(first), seen: string[] = []; let game = f.game; assert.equal(seat(game, 'guild').forces['arrakeen:10'], 1); for (let count = 0; count < 50 && (game.pendingArrivalOverlap || game.pendingAmbassador || game.pendingTerrorEntry || !quiet(game)); count++) { let actor: string, action: Action; if (game.response) { const next = nextHomeworldEcazStep(game); assert.ok(next); actor = next.actor; action = next.action; } else if (game.decision?.kind === 'ecazAmbassador') { seen.push('ambassador'); actor = 'ecaz'; action = {type: 'decision', event: game.pendingAmbassador!.event, trigger: true, choice: 'duke', beneficiary: 'ecaz'}; } else if (game.decision?.kind === 'moritaniTerror') { const entry = viewGame(game, 'moritani').terrorEntry!; assert.ok('stage' in entry); seen.push(`terror:${entry.stage}`); actor = 'moritani'; action = entry.stage === 'offer' ? {type: 'decision', reveal: true} : {type: 'decision', choice: 'spice'}; } else throw Error(`Unexpected native overlap continuation: ${game.decision?.kind}`); game = applyAction(game, actor, action); } assert.deepEqual(seen, first === 'ambassador' ? ['ambassador', 'terror:offer', 'terror:robbery'] : ['terror:offer', 'terror:robbery', 'ambassador']); const robbery = Math.ceil((seat(f.before, 'guild').spice - 1) / 2); assert.equal(seat(game, 'guild').spice, seat(f.before, 'guild').spice - 1 - robbery); assert.equal(seat(game, 'moritani').spice, seat(f.before, 'moritani').spice + robbery); assert.equal(seat(game, 'guild').reserves, seat(f.before, 'guild').reserves - 1); assert.equal(seat(game, 'guild').forces['arrakeen:10'], 1); assert.equal(game.dukeVidal!.controller, 'ecaz'); assert.equal(game.moritaniTerror!.tokens.find(token => token.id === f.terror)!.status, 'removed'); assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === f.ambassador)!.zone, 'supply'); assert.equal(game.active, 'guild'); assert.throws(() => applyAction(game, 'guild', f.action)); game = applyAction(game, 'guild', {type: 'endMovement'}); assert.equal(seat(game, 'guild').forces['arrakeen:10'], 1); }); void test('all four AI profiles can legally respond to a mixed independent-card arrival', () => { const f = fixture('terror'), actor = f.game.decision!.player; for (const difficulty of DIFFICULTIES) { const view = viewGame(f.game, actor); view.players.find(player => player.id === actor)!.bot = difficulty; const action = botActions(view)[0]; assert.ok(action); assert.doesNotThrow(() => applyAction(f.game, actor, action)); } }); for (const effect of ['fremen', 'guild'] as const) void test(`mixed Basic independent cards resume a real ${effect} Ambassador child before original Terror`, () => { // Native Guild entrant does not trigger the Guild Ambassador; Tleilaxu // supplies a separate genuine paid entry for that physical token. const entrant = effect === 'guild' ? 'tleilaxu' : 'guild'; const f = fixture('ambassador', effect, entrant); let game = f.game; const entry = game.pendingAmbassador; assert.ok(entry); const before = {spice: seat(game, 'ecaz').spice, reserves: seat(game, 'ecaz').reserves}; game = applyAction(game, 'ecaz', {type: 'decision', event: entry.event, trigger: true, beneficiary: 'ecaz'}); if (effect === 'fremen') { assert.equal(game.pendingAmbassador?.stage, 'move'); const options = fremenAmbassadorMovement(game, 'ecaz').sources; const source = options.find(row => row.territory !== 'arrakeen' && row.sectors.some(s => s.forces > 0) && row.destinations.some(d => d.territory === 'arrakeen' && d.sector === 10)); assert.ok(source, 'An original Ecaz force can reach the triggering stronghold.'); const sector = source.sectors.find(row => row.forces > 0)!; game = applyAction(game, 'ecaz', {type: 'decision', event: entry.event, forces: {[sector.key]: 1}, territory: 'arrakeen', sector: 10}); } else { assert.equal(game.pendingAmbassador?.stage, 'ship'); assert.ok(guildAmbassadorShipments(game, 'ecaz').destinations.some(d => d.territory === 'arrakeen' && d.sector === 10)); game = applyAction(game, 'ecaz', {type: 'decision', event: entry.event, amount: 1, elite: 0, territory: 'arrakeen', sector: 10}); assert.equal(seat(game, 'ecaz').reserves, before.reserves - 1); } assert.equal(seat(game, 'ecaz').spice, before.spice); assert.equal(game.pendingTerrorEntry?.entrant, 'ecaz'); assert.equal(game.pendingTerrorEntry?.cause, 'ambassador'); assert.equal(game.pendingTerrorEntry?.token, f.terror); game = applyAction(game, 'moritani', {type: 'decision', decline: true}); assert.equal(game.pendingTerrorEntry?.entrant, entrant); game = applyAction(game, 'moritani', {type: 'decision', decline: true}); assert.equal(game.pendingArrivalOverlap, null); assert.equal(game.pendingAmbassador, null); assert.equal(game.pendingTerrorEntry, null); assert.equal(seat(game, entrant).forces['arrakeen:10'], 1); assert.equal(seat(game, 'ecaz').forces['arrakeen:10'], 1); });