213 lines
8.1 KiB
TypeScript
213 lines
8.1 KiB
TypeScript
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);
|
|
});
|