import assert from 'node:assert/strict'; import test from 'node:test'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors'; import { applyAction, viewGame, type Game } from '../game/engine'; import { createTerrorState, placeTerror } from '../game/moritani-terror'; import { addArrivalAmbassador, arrivalMove, arrivalPlayer, arrivalReload, holdArrivalCard, movementArrivalGame, nativeArrivalSource, } from './deferred-movement-arrival-fixture'; type Mover = 'tleilaxu' | 'ixians' | 'fremen'; type Reaction = 'ambassador' | 'terror'; function table(mover: Mover, first: Reaction = 'ambassador') { const g = movementArrivalGame(mover); if (mover !== 'tleilaxu') nativeArrivalSource(g); // A Fremen entrant does not trigger the Fremen Ambassador. if (mover === 'fremen') addArrivalAmbassador(g, 'atreides'); const order = g.order.filter(id => id !== 'ecaz' && id !== 'moritani'); g.order = first === 'ambassador' ? ['ecaz', 'moritani', ...order] : ['moritani', 'ecaz', ...order]; return g; } function declineReaction(g: Game, reaction: Reaction): Game { if (reaction === 'ambassador') { assert.ok(g.pendingAmbassador); return applyAction(arrivalReload(g), 'ecaz', { type: 'decision', event: g.pendingAmbassador.event, decline: true, }); } assert.ok(g.pendingTerrorEntry); return applyAction(arrivalReload(g), 'moritani', { type: 'decision', decline: true }); } function assertMovedOnce(g: Game, mover: Mover) { assert.deepEqual(arrivalPlayer(g, mover).forces, { 'carthag:11': 3 }); assert.equal(arrivalPlayer(g, mover).moved, 1); if (mover === 'ixians') assert.deepEqual(arrivalPlayer(g, mover).elites!.forces, { 'carthag:11': 1 }); } for (const mover of ['tleilaxu', 'ixians', 'fremen'] as const) for (const first of ['ambassador', 'terror'] as const) { void test(`${mover} movement through CHOAM decline resolves ${first} first once`, () => { let g = table(mover, first); const source = structuredClone(arrivalPlayer(g, mover).forces); const ambassadors = structuredClone(g.ecazAmbassadors); const terror = structuredClone(g.moritaniTerror); g = applyAction(g, mover, arrivalMove(g)); assert.equal(g.decision?.kind, 'choamMovement'); assert.equal(viewGame(g, 'choam').decision?.kind, 'choamMovement'); assert.deepEqual(arrivalPlayer(g, mover).forces, source); assert.equal(arrivalPlayer(g, mover).moved, 0); assert.deepEqual(g.ecazAmbassadors, ambassadors); assert.deepEqual(g.moritaniTerror, terror); g = applyAction(arrivalReload(g), 'choam', { type: 'decision', decline: true }); assert.equal(g.pendingChoamMove, null); assert.equal(g.pendingArrivalOverlap?.first, first); assert.equal(g.pendingArrivalOverlap?.stage, 'first'); assert.equal(viewGame(g, mover).arrivalOverlap?.first, first); assert.equal(g.decision?.kind, first === 'ambassador' ? 'ecazAmbassador' : 'moritaniTerror'); assertMovedOnce(g, mover); g = declineReaction(g, first); const second = first === 'ambassador' ? 'terror' : 'ambassador'; assert.equal(g.pendingArrivalOverlap?.stage, 'second'); assert.equal(g.decision?.kind, second === 'ambassador' ? 'ecazAmbassador' : 'moritaniTerror'); assertMovedOnce(g, mover); g = declineReaction(g, second); assert.equal(g.pendingArrivalOverlap, null); assert.equal(g.pendingAmbassador, null); assert.equal(g.pendingTerrorEntry, null); assert.equal(g.decision, null); assert.equal(g.active, mover); assertMovedOnce(g, mover); assert.deepEqual(g.ecazAmbassadors, ambassadors); assert.deepEqual(g.moritaniTerror, terror); assert.throws(() => applyAction(g, mover, arrivalMove(g))); }); } void test('Baliset prevents the overlapping move without spending movement or arrival tokens', () => { let g = table('tleilaxu'); const baliset = holdArrivalCard(g, 'choam', 'Baliset'); holdArrivalCard(g, 'tleilaxu', 'Karama'); const source = structuredClone(arrivalPlayer(g, 'tleilaxu').forces); const ambassadors = structuredClone(g.ecazAmbassadors); const terror = structuredClone(g.moritaniTerror); const remaining = structuredClone(g.movementRemaining); g = applyAction(g, 'tleilaxu', arrivalMove(g)); g = applyAction(g, 'choam', { type: 'card', card: baliset, mode: 'choam', target: 'tleilaxu', territory: 'carthag', }); assert.equal(g.response?.kind, 'choamWorthless'); assert.ok(viewGame(g, 'tleilaxu').responseControls?.cancelCards.length); g = applyAction(arrivalReload(g), 'tleilaxu', { type: 'passResponse' }); assert.equal(g.response, null); assert.equal(g.pendingChoamMove, null); assert.equal(g.pendingArrivalOverlap ?? null, null); assert.deepEqual(arrivalPlayer(g, 'tleilaxu').forces, source); assert.equal(arrivalPlayer(g, 'tleilaxu').moved, 0); assert.deepEqual(g.movementRemaining, remaining); assert.deepEqual(g.ecazAmbassadors, ambassadors); assert.deepEqual(g.moritaniTerror, terror); assert.equal(g.discard.filter(card => card.id === baliset).length, 1); }); void test('CHOAM bot profiles have a legal path through the overlapping movement offer', () => { const initial = table('tleilaxu'); const g = applyAction(initial, 'tleilaxu', arrivalMove(initial)); for (const difficulty of DIFFICULTIES) { const view = viewGame(g, 'choam'); view.players.find(p => p.id === 'choam')!.bot = difficulty; const action = botActions(view)[0]; assert.ok(action, difficulty); const next = applyAction(g, 'choam', action); assert.equal(next.pendingArrivalOverlap?.stage, 'first'); assertMovedOnce(next, 'tleilaxu'); } }); void test('Robbery and the Ecaz Ambassador resolve their effects after CHOAM allows movement', () => { let g = table('tleilaxu', 'terror'); const terror = createTerrorState(() => 0.2); const robbery = terror.tokens.find(token => token.kind === 'robbery')!; g.moritaniTerror = placeTerror(terror, robbery.id, 'carthag', 2); const ambassadors = createAmbassadors(() => 0.2); const ecaz = ambassadors.tokens.find(token => token.effect === 'ecaz')!; g.ecazAmbassadors = placeAmbassador(ambassadors, ecaz.id, { turn: 2, availableSpice: 20, destination: { id: 'carthag', stronghold: true, inStorm: false, allowed: true }, }).state; const moverSpice = arrivalPlayer(g, 'tleilaxu').spice; const moritaniSpice = arrivalPlayer(g, 'moritani').spice; const stolen = Math.ceil(moverSpice / 2); g = applyAction(g, 'tleilaxu', arrivalMove(g)); g = applyAction(g, 'choam', { type: 'decision', decline: true }); assert.equal(g.pendingArrivalOverlap?.first, 'terror'); assert.equal(g.pendingTerrorEntry?.stage, 'offer'); assertMovedOnce(g, 'tleilaxu'); g = applyAction(g, 'moritani', { type: 'decision', reveal: true }); assert.equal(g.pendingTerrorEntry?.stage, 'robbery'); g = applyAction(g, 'moritani', { type: 'decision', choice: 'spice' }); assert.equal(arrivalPlayer(g, 'tleilaxu').spice, moverSpice - stolen); assert.equal(arrivalPlayer(g, 'moritani').spice, moritaniSpice + stolen); assert.equal(g.moritaniTerror!.tokens.find(token => token.id === robbery.id)?.status, 'removed'); assert.equal(g.pendingArrivalOverlap?.stage, 'second'); assertMovedOnce(g, 'tleilaxu'); assert.ok(g.pendingAmbassador); g = applyAction(g, 'ecaz', { type: 'decision', event: g.pendingAmbassador.event, trigger: true, beneficiary: 'ecaz', choice: 'duke', }); assert.equal(g.dukeVidal?.controller, 'ecaz'); assert.equal(g.ecazAmbassadors!.tokens.find(token => token.id === ecaz.id)?.zone, 'supply'); assert.equal(g.pendingArrivalOverlap, null); assert.equal(arrivalPlayer(g, 'tleilaxu').spice, moverSpice - stolen); assert.equal(arrivalPlayer(g, 'moritani').spice, moritaniSpice + stolen); assertMovedOnce(g, 'tleilaxu'); }); void test('Karama cancels Baliset and resumes both reactions for the original move', () => { let g = table('tleilaxu'); const baliset = holdArrivalCard(g, 'choam', 'Baliset'); const karama = holdArrivalCard(g, 'tleilaxu', 'Karama'); g = applyAction(g, 'tleilaxu', arrivalMove(g)); g = applyAction(g, 'choam', { type: 'card', card: baliset, mode: 'choam', target: 'tleilaxu', territory: 'carthag', }); assert.equal(g.response?.kind, 'choamWorthless'); g = applyAction(arrivalReload(g), 'tleilaxu', { type: 'card', card: karama, mode: 'cancel' }); assert.equal(g.discard.filter(card => card.id === karama).length, 1); assert.ok(arrivalPlayer(g, 'choam').hand.some(card => card.id === baliset)); assert.equal(g.pendingArrivalOverlap?.first, 'ambassador'); assertMovedOnce(g, 'tleilaxu'); g = declineReaction(g, 'ambassador'); assert.equal(g.pendingArrivalOverlap?.stage, 'second'); g = declineReaction(g, 'terror'); assert.equal(g.pendingArrivalOverlap, null); assertMovedOnce(g, 'tleilaxu'); });