import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game, } from '../game/engine'; import { addArrivalAmbassador, addArrivalTerror, arrivalArmy, arrivalMove, arrivalPlayer, arrivalReload, arrivalResources, holdArrivalCard, movementArrivalGame, nativeArrivalSource, } from './deferred-movement-arrival-fixture'; function reject(g: Game, player: string, action: Action, pattern?: RegExp) { const before = structuredClone(g); if (pattern) assert.throws(() => applyAction(g, player, action), pattern); else assert.throws(() => applyAction(g, player, action)); assert.deepEqual(g, before); } const overlap = /Ambassadors combined with another arrival reaction/; void test('Tleilaxu and native Ix/Fremen declarations open their prevention windows before a paired arrival commits', () => { for (const faction of ['tleilaxu', 'ixians', 'fremen'] as const) { const before = movementArrivalGame(faction); if (faction !== 'tleilaxu') nativeArrivalSource(before); if (faction === 'fremen') addArrivalAmbassador(before, 'atreides'); holdArrivalCard(before, 'choam', 'Karama'); const forces = structuredClone(arrivalPlayer(before, faction).forces); const elites = structuredClone(arrivalPlayer(before, faction).elites); const ambassadors = structuredClone(before.ecazAmbassadors); const terror = structuredClone(before.moritaniTerror); const g = applyAction(before, faction, arrivalMove(before)); assert.deepEqual(arrivalPlayer(g, faction).forces, forces); assert.deepEqual(arrivalPlayer(g, faction).elites, elites); assert.equal(arrivalPlayer(g, faction).moved, 0); assert.deepEqual(g.ecazAmbassadors, ambassadors); assert.deepEqual(g.moritaniTerror, terror); if (faction === 'tleilaxu') { assert.equal(g.decision?.kind, 'choamMovement'); assert.ok(g.pendingChoamMove); } else { assert.equal(g.response?.kind, faction === 'ixians' ? 'ixMovement' : 'fremenMovement'); assert.ok(faction === 'ixians' ? g.pendingIxMove : g.pendingFremenMove); } } }); void test('a paired arrival can put a newly played Ornithopter into escrow before the prevention choice', () => { const before = movementArrivalGame(), card = holdArrivalCard(before, 'tleilaxu', 'Ornithopter'); const g = applyAction( before, 'tleilaxu', { ...arrivalMove(before), movementCard: card, ornithopter: 'range3' }, ); assert.equal(g.decision?.kind, 'choamMovement'); assert.ok(g.ornithopter); assert.equal(arrivalPlayer(g, 'tleilaxu').hand.some((c) => c.id === card), false); assert.equal(g.discard.some((c) => c.id === card), false); assert.deepEqual(arrivalPlayer(g, 'tleilaxu').forces, { 'arrakeen:10': 3 }); assert.equal(arrivalPlayer(g, 'tleilaxu').moved, 0); }); for (const tokens of ['ambassador', 'terror'] as const) for (const cancellation of [false, true]) void test(`${tokens}-only entry stays legal through CHOAM ${cancellation ? 'Baliset cancellation' : 'decline'} and commits once`, () => { let g = movementArrivalGame('tleilaxu', tokens); const baliset = holdArrivalCard(g, 'choam', 'Baliset'), karama = holdArrivalCard(g, 'tleilaxu', 'Karama'); g = applyAction(g, 'tleilaxu', arrivalMove(g)); assert.equal(g.decision?.kind, 'choamMovement'); assert.equal(arrivalPlayer(g, 'tleilaxu').moved, 0); if (cancellation) { 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((c) => c.id === karama).length, 1); assert.ok(arrivalPlayer(g, 'choam').hand.some((c) => c.id === baliset)); } else g = applyAction(arrivalReload(g), 'choam', { type: 'decision', decline: true, }); assert.equal(g.pendingChoamMove, null); assert.equal( g.decision?.kind, tokens === 'ambassador' ? 'ecazAmbassador' : 'moritaniTerror', ); assert.deepEqual(arrivalPlayer(g, 'tleilaxu').forces, { 'carthag:11': 3, }); assert.equal(arrivalPlayer(g, 'tleilaxu').moved, 1); const event = tokens === 'ambassador' ? g.pendingAmbassador!.event : undefined; g = applyAction( arrivalReload(g), tokens === 'ambassador' ? 'ecaz' : 'moritani', { type: 'decision', event, decline: true }, ); assert.equal(g.decision, null); assert.equal(g.active, 'tleilaxu'); assert.equal(arrivalPlayer(g, 'tleilaxu').moved, 1); reject(g, 'choam', { type: 'decision', decline: true }); }); void test('a saved CHOAM decline releases an uncommitted unsupported arrival with its precise reason and allows another legal destination', () => { let g = movementArrivalGame('tleilaxu', 'ambassador'); const card = holdArrivalCard(g, 'tleilaxu', 'Ornithopter'); g = applyAction(g, 'tleilaxu', { ...arrivalMove(g), movementCard: card, ornithopter: 'range3', }); const flight = structuredClone(g.ornithopter); // Model a legacy saved CHOAM window where a still-unsupported Semuta // overlay and second token were already present after an unspent declaration. g.semutaPreview = true; addArrivalTerror(g); const before = structuredClone(g), resources = structuredClone(arrivalResources(g)); for (const p of g.players) assert.deepEqual(viewGame(arrivalReload(g), p.id), viewGame(g, p.id)); assert.deepEqual(normalizeAutomaticGame(arrivalReload(g)), arrivalReload(g)); reject(g, 'tleilaxu', { type: 'decision', decline: true }); reject(g, 'choam', { type: 'decision', decline: false }); const done = applyAction(arrivalReload(g), 'choam', { type: 'decision', decline: true, }); assert.deepEqual(g, before); assert.deepEqual(arrivalResources(done), resources); assert.equal(done.pendingChoamMove, null); assert.equal(done.decision, null); assert.deepEqual(done.ornithopter, flight); assert.equal( arrivalPlayer(done, 'tleilaxu').hand.some((c) => c.id === card), false, ); assert.equal( done.discard.some((c) => c.id === card), false, ); assert.match(done.log.at(-1)!.text, overlap); assert.match( done.log.at(-1)!.text, /No additional forces, spice, cards or movement allowance were spent/, ); assert.deepEqual( normalizeAutomaticGame(arrivalReload(done)), arrivalReload(done), ); reject(done, 'choam', { type: 'decision', decline: true }); reject(done, 'tleilaxu', arrivalMove(done), overlap); const retry = applyAction(done, 'tleilaxu', { type: 'move', forces: { 'arrakeen:10': 3 }, territory: 'imperial_basin', sector: 10, ornithopterEvent: done.ornithopter!.event, }); assert.equal(arrivalPlayer(retry, 'tleilaxu').moved, 1); assert.deepEqual(arrivalPlayer(retry, 'tleilaxu').forces, { 'imperial_basin:10': 3, }); assert.equal(retry.ornithopter, null); assert.equal(retry.discard.filter((c) => c.id === card).length, 1); }); void test('legacy invalid-order retry stays unspent while unexpected malformed arrival faults remain rejected', () => { let g = movementArrivalGame('tleilaxu', 'ambassador'); g = applyAction(g, 'tleilaxu', arrivalMove(g)); const invalid = arrivalReload(g); arrivalArmy(invalid, 'tleilaxu', 'arrakeen:10', 2); const retried = applyAction(invalid, 'choam', { type: 'decision', decline: true, }); assert.equal(retried.pendingChoamMove, null); assert.equal(arrivalPlayer(retried, 'tleilaxu').moved, 0); assert.deepEqual(arrivalPlayer(retried, 'tleilaxu').forces, { 'arrakeen:10': 2, }); const malformed = arrivalReload(g); malformed.ecazAmbassadors!.tokens = {} as NonNullable< Game['ecazAmbassadors'] >['tokens']; const before = structuredClone(malformed); assert.throws( () => applyAction(malformed, 'choam', { type: 'decision', decline: true }), TypeError, ); assert.deepEqual(malformed, before); });