import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { baseDeck } from '../game/cards'; import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors'; import { createTerrorState, placeTerror } from '../game/moritani-terror'; import { spiritualAdvisorTargetBlock } from '../game/spiritual-advisor'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { quoteGuildAmbassadorShipment, guildAmbassadorShipments } from '../game/guild-ambassador'; import { quoteGuildAmbassadorMarker, markerDestinations } from '../game/guild-ambassador-no-field'; import { createRicheseNoField } from '../game/richese-no-field'; const player = (game: Game, id: string) => { const found = game.players.find(seat => seat.id === id); assert.ok(found); return found; }; const act = (game: Game, id: string, action: Action) => applyAction(game, id, action); /** Stage a conserved Advanced position, then use the real entry, Guild * Ambassador shipment and physical Terror token. No pending receipt is staged. */ function atomicsDuringGuildAmbassador(): Game { const game = createGame('SPIRITUALATOMICS', newPlayer('ec', 'Ecaz', 'ecaz'), true, ['ecaz']); game.players.push(newPlayer('in', 'Emperor', 'emperor'), newPlayer('a', 'Atreides', 'atreides'), newPlayer('m', 'Moritani', 'moritani'), newPlayer('bg', 'Bene Gesserit', 'beneGesserit')); Object.assign(game, {status:'playing', phase:5, turn:2, storm:18, active:'in', deck:baseDeck(), discard:[], order:game.players.map(seat => seat.id), movementRemaining:game.players.map(seat => seat.id), response:null, decision:null, phaseOpening:null}); for (const seat of game.players) Object.assign(seat, {hand:[], forces:{}, reserves:20, tanks:0, spice:20, traitors:[], traitorChoices:[], shipped:false, moved:0}); player(game,'ec').ally='a'; player(game,'a').ally='ec'; player(game,'in').forces={'imperial_basin:10':1}; player(game,'in').reserves=19; player(game,'in').shipped=true; let ambassadors = createAmbassadors(() => 0.2); const guild = ambassadors.tokens.find(token => token.effect === 'guild'); assert.ok(guild); ambassadors.cohort = [guild.id, ...ambassadors.tokens.filter(token => token.effect !== 'ecaz' && token.id !== guild.id).slice(0,4).map(token => token.id)]; for (const token of ambassadors.tokens) { token.zone = token.effect === 'ecaz' || ambassadors.cohort.includes(token.id) ? 'supply' : 'pool'; token.location = null; } ambassadors = placeAmbassador(ambassadors, guild.id, {turn:1, availableSpice:20, destination:{id:'arrakeen', stronghold:true, inStorm:false, allowed:true}}).state; game.ecazAmbassadors = ambassadors; let terror = createTerrorState(() => 0); const atomics = terror.tokens.find(token => token.kind === 'atomics'); assert.ok(atomics); terror = placeTerror(terror, atomics.id, 'sietch_tabr', 1); game.moritaniTerror = terror; let next = act(game, 'in', {type:'move', forces:{'imperial_basin:10':1}, territory:'arrakeen', sector:10}); assert.equal(next.pendingAmbassador?.stage, 'offer'); next = act(next, 'ec', {type:'decision', event:next.pendingAmbassador!.event, trigger:true, beneficiary:'a'}); next = act(next, 'a', {type:'decision', event:next.pendingAmbassador!.event, amount:2, territory:'sietch_tabr', sector:14}); assert.equal(next.pendingTerrorEntry?.entrant, 'a'); next = act(next, 'm', {type:'decision', reveal:true}); assert.equal(next.moritaniAtomics?.territory, 'sietch_tabr'); assert.equal(player(next,'a').forces['sietch_tabr:14'] ?? 0, 0); assert.equal(player(next,'a').tanks, 2); assert.equal(next.decision?.kind, 'advisor'); assert.equal(next.decision?.player, 'bg'); return next; } void test('Atomics Aftermath prevents Spiritual shipments in either rules mode without blocking other destinations', () => { for (const advanced of [false,true]) { const ctx = {advanced, players:[newPlayer('bg','Bene Gesserit','beneGesserit')], moritaniAtomics:{territory:'sietch_tabr',turn:2,moritaniId:'m',allyAtActivation:null,alliancePolicy:'activation-targets' as const}}; assert.match(spiritualAdvisorTargetBlock(ctx,'bg','sietch_tabr')!, /Atomics Aftermath/); assert.equal(spiritualAdvisorTargetBlock(ctx,'bg','polar_sink'), null); assert.equal(spiritualAdvisorTargetBlock(ctx,'bg','arrakeen'), null); } }); void test('Guild Ambassador Atomics blocks its later BG accompaniment, retaining Polar Sink and original shipment', () => { let game = atomicsDuringGuildAmbassador(); const bg = player(game,'bg'), priorReserve = bg.reserves, priorTanks = player(game,'a').tanks; assert.throws(() => quoteGuildAmbassadorShipment(game,'a',{type:'decision',amount:1, territory:'sietch_tabr',sector:14}), /Atomics Aftermath/); assert.ok(!guildAmbassadorShipments(game,'a').destinations.some(choice => choice.territory === 'sietch_tabr')); assert.ok(guildAmbassadorShipments(game,'a').destinations.some(choice => choice.territory !== 'sietch_tabr')); const choices = viewGame(game,'bg').spiritualAdvisorChoices; assert.ok(choices); const local = choices.choices.filter(choice => choice.accompany && choice.territory === 'sietch_tabr'); assert.ok(local.length && local.every(choice => choice.blocked?.includes('Atomics Aftermath'))); assert.ok(choices.choices.some(choice => !choice.accompany && choice.territory === 'polar_sink' && !choice.blocked)); const initial = structuredClone(game); assert.throws(() => act(game,'bg',{type:'decision',accept:true,accompany:true,territory:'sietch_tabr',sector:14}), /Atomics Aftermath/); assert.deepEqual(game,initial); for (const difficulty of DIFFICULTIES) { const view = viewGame(game,'bg'); view.players.find(seat => seat.id === 'bg')!.bot = difficulty; const actions = botActions(view); assert.ok(actions.length); assert.ok(actions.every(action => !(action.type === 'decision' && action.accept === true && action.accompany === true))); } game = act(game,'bg',{type:'decision',accept:true,accompany:false,territory:'polar_sink',sector:0}); for (let count=0;game.response && count<50;count++) { const next = game.players.find(seat => !game.response!.passed.includes(seat.id)); assert.ok(next); game = act(game,next.id,{type:'passResponse'}); } assert.equal(player(game,'bg').reserves, priorReserve-1); assert.equal(player(game,'bg').forces['polar_sink:0'],1); assert.equal(player(game,'bg').forces['sietch_tabr:14'] ?? 0,0); assert.equal(player(game,'a').tanks, priorTanks); assert.equal(game.pendingAmbassador ?? null, null); }); void test('a pre-existing Aftermath also blocks native and allied Guild Ambassador No-Field markers', () => { for (const beneficiary of ['native','allied'] as const) { const game = createGame('ATOMICSNOFIELD', newPlayer('p','Recipient',beneficiary === 'native' ? 'richese' : 'ecaz'), false); game.players.push(newPlayer('q','Other','atreides'), newPlayer('r','Supplier',beneficiary === 'native' ? 'ecaz' : 'richese')); Object.assign(game,{status:'playing',turn:2,phase:5,storm:18,active:'q',order:['p','q','r'], moritaniAtomics:{territory:'sietch_tabr',turn:1,moritaniId:'m',allyAtActivation:null,alliancePolicy:'activation-targets'}}); for (const seat of game.players) Object.assign(seat,{forces:{},reserves:20,tanks:0,spice:5, elites:undefined,hand:[],shipped:true,moved:2}); const owner = game.players.find(seat => seat.faction === 'richese')!; owner.noField = createRicheseNoField(['token0','token3','token5']); owner.noFieldEvent = 'selection-event'; if (owner.id !== 'p') {owner.ally='p';player(game,'p').ally=owner.id;} const selection = {tokenId:'token3',tokenEvent:'selection-event',territory:'sietch_tabr',sector:14}; assert.throws(() => quoteGuildAmbassadorMarker(game,'p',selection), /Atomics Aftermath/); const destinations=markerDestinations(game,'p'); assert.ok(!destinations.some(choice => choice.territory === 'sietch_tabr')); assert.ok(destinations.some(choice => choice.territory !== 'sietch_tabr')); assert.throws(() => quoteGuildAmbassadorShipment(game,'p',{type:'decision',amount:1, territory:'sietch_tabr',sector:14}), /Atomics Aftermath/); } });