190 lines
8.8 KiB
TypeScript
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');
|
|
});
|