Files
Dune/tests/deferred-movement-choam-overlap-runtime.test.ts
T
Quintine eb68f28a3a
Verify and publish checkpoint container / container (push) Canceled after 0s
Enable advanced mixed-faction paired arrivals
2026-10-29 09:42:27 +11:00

190 lines
8.8 KiB
TypeScript

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');
});