746 lines
23 KiB
TypeScript
746 lines
23 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
quoteCompletedMovementArrival,
|
|
type CompletedMovementArrivalInput,
|
|
} from '../game/karama-movement-preflight';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
joinGame,
|
|
newPlayer,
|
|
normalizeAutomaticGame,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { baseDeck } from '../game/cards';
|
|
import { richeseCards } from '../game/richese-cards';
|
|
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
|
|
import { createTerrorState, placeTerror } from '../game/moritani-terror';
|
|
|
|
function fixture(): CompletedMovementArrivalInput {
|
|
return {
|
|
advanced: true,
|
|
players: [
|
|
{ id: 'p', faction: 'emperor', ally: null, forces: {} },
|
|
{ id: 'b', faction: 'beneGesserit', ally: null, forces: {} },
|
|
{ id: 'e', faction: 'ecaz', ally: null, forces: {} },
|
|
{ id: 'm', faction: 'moritani', ally: null, forces: {} },
|
|
],
|
|
order: {
|
|
player: 'p',
|
|
origin: 'red_chasm',
|
|
to: 'broken_land',
|
|
advisors: false,
|
|
wantsFighters: false,
|
|
},
|
|
ambassadors: [],
|
|
terror: [],
|
|
controls: {
|
|
response: false,
|
|
decision: false,
|
|
pendingTerror: false,
|
|
pendingAmbassador: false,
|
|
paidBox: false,
|
|
},
|
|
flight: null,
|
|
};
|
|
}
|
|
const ambassador = {
|
|
zone: 'placed' as const,
|
|
location: 'broken_land',
|
|
effect: 'atreides' as const,
|
|
};
|
|
const terror = { status: 'placed' as const, location: 'broken_land' };
|
|
|
|
void test('a plain movement has no arrival reaction, and an eligible token opens only its own opportunity', () => {
|
|
const input = fixture();
|
|
assert.deepEqual(quoteCompletedMovementArrival(input), {
|
|
intrusion: false,
|
|
reaction: null,
|
|
retiresOrnithopter: false,
|
|
});
|
|
assert.equal(
|
|
quoteCompletedMovementArrival({ ...input, ambassadors: [ambassador] })
|
|
.reaction,
|
|
'ambassador',
|
|
);
|
|
assert.equal(
|
|
quoteCompletedMovementArrival({ ...input, terror: [terror] }).reaction,
|
|
'terror',
|
|
);
|
|
});
|
|
|
|
void test('supported simultaneous Ambassador and Terror arrival quotes one bounded overlap without changing the original entry', () => {
|
|
const input = {
|
|
...fixture(),
|
|
overlapSupported: true,
|
|
ambassadors: [ambassador],
|
|
terror: [terror],
|
|
};
|
|
const before = structuredClone(input);
|
|
assert.deepEqual(quoteCompletedMovementArrival(input), {
|
|
intrusion: false,
|
|
reaction: 'overlap',
|
|
retiresOrnithopter: false,
|
|
});
|
|
assert.deepEqual(quoteCompletedMovementArrival(input), {
|
|
intrusion: false,
|
|
reaction: 'overlap',
|
|
retiresOrnithopter: false,
|
|
});
|
|
assert.deepEqual(input, before);
|
|
assert.throws(() => quoteCompletedMovementArrival({ ...input, overlapSupported: false }));
|
|
assert.throws(() => quoteCompletedMovementArrival({ ...input, overlapSupported: undefined }));
|
|
assert.equal(
|
|
quoteCompletedMovementArrival({ ...input, terror: [] }).reaction,
|
|
'ambassador',
|
|
);
|
|
assert.equal(
|
|
quoteCompletedMovementArrival({ ...input, ambassadors: [] }).reaction,
|
|
'terror',
|
|
);
|
|
});
|
|
|
|
void test('overlap support does not bypass actual competing controls or the pre-entry fighter response', () => {
|
|
const input = {
|
|
...fixture(),
|
|
overlapSupported: true,
|
|
ambassadors: [ambassador],
|
|
terror: [terror],
|
|
};
|
|
for (const flag of [
|
|
'response',
|
|
'decision',
|
|
'pendingTerror',
|
|
'pendingAmbassador',
|
|
] as const)
|
|
assert.throws(() =>
|
|
quoteCompletedMovementArrival({
|
|
...input,
|
|
controls: { ...input.controls, [flag]: true },
|
|
}), flag);
|
|
assert.throws(() =>
|
|
quoteCompletedMovementArrival({
|
|
...input,
|
|
order: { ...input.order, wantsFighters: true },
|
|
}));
|
|
});
|
|
|
|
void test('overlap support never turns same-territory movement or shared ownership into an entry', () => {
|
|
const input = {
|
|
...fixture(),
|
|
overlapSupported: true,
|
|
ambassadors: [ambassador],
|
|
terror: [terror],
|
|
};
|
|
assert.equal(
|
|
quoteCompletedMovementArrival({
|
|
...input,
|
|
order: { ...input.order, origin: 'broken_land' },
|
|
}).reaction,
|
|
null,
|
|
);
|
|
assert.equal(
|
|
quoteCompletedMovementArrival({
|
|
...input,
|
|
order: {
|
|
...input.order,
|
|
origins: ['broken_land', 'broken_land'],
|
|
},
|
|
}).reaction,
|
|
null,
|
|
);
|
|
assert.throws(() =>
|
|
quoteCompletedMovementArrival({
|
|
...input,
|
|
players: input.players.map((p) =>
|
|
p.id === 'm' ? { ...p, id: 'e' } : p,
|
|
),
|
|
}));
|
|
});
|
|
|
|
for (const exclusion of [
|
|
'owner',
|
|
'ally',
|
|
'matchingFaction',
|
|
'advisor',
|
|
] as const)
|
|
void test(`Ambassador ${exclusion} exclusion still permits the independent Terror opportunity`, () => {
|
|
const input = fixture();
|
|
const players = input.players.map((p) => ({ ...p }));
|
|
let order = { ...input.order };
|
|
if (exclusion === 'owner') order.player = 'e';
|
|
if (exclusion === 'ally') players.find((p) => p.id === 'e')!.ally = 'p';
|
|
if (exclusion === 'matchingFaction')
|
|
players.find((p) => p.id === 'p')!.faction = 'atreides';
|
|
if (exclusion === 'advisor') {
|
|
order = { ...order, player: 'b', advisors: true };
|
|
}
|
|
assert.equal(
|
|
quoteCompletedMovementArrival({
|
|
...input,
|
|
players,
|
|
order,
|
|
ambassadors: [ambassador],
|
|
terror: [terror],
|
|
}).reaction,
|
|
'terror',
|
|
);
|
|
});
|
|
|
|
for (const entrant of ['m', 'p'])
|
|
void test(`Terror exempts its ${entrant === 'm' ? 'owner' : 'ally'} while independent Ambassador entry stays eligible`, () => {
|
|
const input = fixture();
|
|
const players = input.players.map((p) =>
|
|
p.id === 'm' ? { ...p, ally: 'p' } : p,
|
|
);
|
|
assert.equal(
|
|
quoteCompletedMovementArrival({
|
|
...input,
|
|
players,
|
|
order: { ...input.order, player: entrant },
|
|
ambassadors: [ambassador],
|
|
terror: [terror],
|
|
}).reaction,
|
|
'ambassador',
|
|
);
|
|
});
|
|
|
|
void test('BG intrusion uses current opposing fighters, requires Advanced rules and excludes the mover itself', () => {
|
|
const input = fixture();
|
|
const players = input.players.map((p) =>
|
|
p.id === 'b' ? { ...p, forces: { 'broken_land:9': 2 } } : p,
|
|
);
|
|
assert.equal(
|
|
quoteCompletedMovementArrival({ ...input, players }).intrusion,
|
|
true,
|
|
);
|
|
assert.equal(
|
|
quoteCompletedMovementArrival({ ...input, players, advanced: false })
|
|
.intrusion,
|
|
false,
|
|
);
|
|
assert.equal(
|
|
quoteCompletedMovementArrival({
|
|
...input,
|
|
players,
|
|
order: { ...input.order, player: 'b' },
|
|
}).intrusion,
|
|
false,
|
|
);
|
|
const advisors = players.map((p) =>
|
|
p.id === 'b' ? { ...p, advisors: { broken_land: {} } } : p,
|
|
);
|
|
assert.equal(
|
|
quoteCompletedMovementArrival({ ...input, players: advisors }).intrusion,
|
|
false,
|
|
);
|
|
// A Terror entry behind the pending Intrusion is now committed and deferred.
|
|
assert.equal(
|
|
quoteCompletedMovementArrival({ ...input, players, terror: [terror] }).reaction,
|
|
'terror',
|
|
);
|
|
});
|
|
|
|
void test('mover advisor stance is the declared resulting stance, not its stale destination map', () => {
|
|
const input = fixture();
|
|
const players = input.players.map((p) =>
|
|
p.id === 'b' ? { ...p, advisors: { broken_land: {} } } : p,
|
|
);
|
|
const base = {
|
|
...input,
|
|
players,
|
|
ambassadors: [ambassador],
|
|
order: { ...input.order, player: 'b' },
|
|
};
|
|
assert.equal(quoteCompletedMovementArrival(base).reaction, 'ambassador');
|
|
assert.equal(
|
|
quoteCompletedMovementArrival({
|
|
...base,
|
|
order: { ...base.order, advisors: true },
|
|
}).reaction,
|
|
null,
|
|
);
|
|
// A pending fighter flip leaves the advisor stance in place until allowed.
|
|
assert.equal(
|
|
quoteCompletedMovementArrival({
|
|
...base,
|
|
order: { ...base.order, advisors: true, wantsFighters: true },
|
|
}).reaction,
|
|
null,
|
|
);
|
|
assert.equal(
|
|
quoteCompletedMovementArrival({
|
|
...base,
|
|
terror: [terror],
|
|
order: { ...base.order, advisors: true, wantsFighters: true },
|
|
}).reaction,
|
|
'terror',
|
|
);
|
|
});
|
|
|
|
void test('same-territory movement still reports intrusion but does not reopen Ambassador or Terror entry', () => {
|
|
const input = fixture();
|
|
const players = input.players.map((p) =>
|
|
p.id === 'b' ? { ...p, forces: { 'broken_land:9': 1 } } : p,
|
|
);
|
|
assert.deepEqual(
|
|
quoteCompletedMovementArrival({
|
|
...input,
|
|
players,
|
|
order: { ...input.order, origin: 'broken_land', wantsFighters: true },
|
|
ambassadors: [ambassador],
|
|
terror: [terror],
|
|
controls: {
|
|
...input.controls,
|
|
response: true,
|
|
decision: true,
|
|
pendingTerror: true,
|
|
pendingAmbassador: true,
|
|
},
|
|
}),
|
|
{ intrusion: true, reaction: null, retiresOrnithopter: false },
|
|
);
|
|
});
|
|
|
|
void test('current controls and the move-created fighter response are checked without inventing a global pending-parent ban', () => {
|
|
const input = fixture();
|
|
for (const flag of [
|
|
'response',
|
|
'decision',
|
|
'pendingTerror',
|
|
'pendingAmbassador',
|
|
] as const)
|
|
assert.throws(() =>
|
|
quoteCompletedMovementArrival({
|
|
...input,
|
|
ambassadors: [ambassador],
|
|
controls: { ...input.controls, [flag]: true },
|
|
}));
|
|
for (const flag of ['response', 'decision'] as const)
|
|
assert.equal(
|
|
quoteCompletedMovementArrival({
|
|
...input,
|
|
terror: [terror],
|
|
controls: { ...input.controls, [flag]: true },
|
|
}).reaction,
|
|
'terror',
|
|
`A committed ${flag} arrival defers its Terror entry instead of rejecting it.`,
|
|
);
|
|
assert.throws(() =>
|
|
quoteCompletedMovementArrival({
|
|
...input,
|
|
terror: [terror],
|
|
controls: { ...input.controls, response: true, pendingTerror: true },
|
|
}));
|
|
assert.equal(
|
|
quoteCompletedMovementArrival({
|
|
...input,
|
|
terror: [terror],
|
|
controls: { ...input.controls, pendingAmbassador: true },
|
|
}).reaction,
|
|
'terror',
|
|
);
|
|
assert.throws(() =>
|
|
quoteCompletedMovementArrival({
|
|
...input,
|
|
ambassadors: [ambassador],
|
|
order: { ...input.order, wantsFighters: true },
|
|
}));
|
|
assert.equal(
|
|
quoteCompletedMovementArrival({
|
|
...input,
|
|
controls: {
|
|
...input.controls,
|
|
response: true,
|
|
decision: true,
|
|
pendingTerror: true,
|
|
pendingAmbassador: true,
|
|
},
|
|
}).reaction,
|
|
null,
|
|
);
|
|
});
|
|
|
|
void test('unplaced, removed, wrong-territory or unowned markers create no arrival opportunity', () => {
|
|
const input = fixture();
|
|
assert.equal(
|
|
quoteCompletedMovementArrival({
|
|
...input,
|
|
ambassadors: [
|
|
{ ...ambassador, zone: 'supply', location: null },
|
|
{ ...ambassador, location: 'red_chasm' },
|
|
],
|
|
terror: [
|
|
{ ...terror, status: 'removed' },
|
|
{ ...terror, location: 'red_chasm' },
|
|
],
|
|
}).reaction,
|
|
null,
|
|
);
|
|
assert.equal(
|
|
quoteCompletedMovementArrival({
|
|
...input,
|
|
players: input.players.filter((p) => p.id !== 'e' && p.id !== 'm'),
|
|
ambassadors: [ambassador],
|
|
terror: [terror],
|
|
}).reaction,
|
|
null,
|
|
);
|
|
});
|
|
|
|
void test('paid Box prevents only actual Ornithopter retirement: first two-groups move remains distinct from its final move', () => {
|
|
const input = fixture();
|
|
const controls = { ...input.controls, paidBox: true };
|
|
assert.equal(
|
|
quoteCompletedMovementArrival({ ...input, controls }).retiresOrnithopter,
|
|
false,
|
|
);
|
|
assert.equal(
|
|
quoteCompletedMovementArrival({
|
|
...input,
|
|
controls,
|
|
flight: { player: 'p', mode: 'twoGroups', completed: 0 },
|
|
}).retiresOrnithopter,
|
|
false,
|
|
);
|
|
assert.throws(() =>
|
|
quoteCompletedMovementArrival({
|
|
...input,
|
|
controls,
|
|
flight: { player: 'p', mode: 'twoGroups', completed: 1 },
|
|
}));
|
|
assert.throws(() =>
|
|
quoteCompletedMovementArrival({
|
|
...input,
|
|
controls,
|
|
flight: { player: 'p', mode: 'range3', completed: 0 },
|
|
}));
|
|
assert.equal(
|
|
quoteCompletedMovementArrival({
|
|
...input,
|
|
controls,
|
|
flight: { player: 'b', mode: 'range3', completed: 0 },
|
|
}).retiresOrnithopter,
|
|
false,
|
|
);
|
|
assert.equal(
|
|
quoteCompletedMovementArrival({
|
|
...input,
|
|
flight: { player: 'p', mode: 'twoGroups', completed: 1 },
|
|
}).retiresOrnithopter,
|
|
true,
|
|
);
|
|
});
|
|
|
|
void test('arrival quotes are repeatable and immutable without RNG, events, or reading private cards and marker value', (t) => {
|
|
const input = fixture();
|
|
const before = structuredClone(input);
|
|
for (const p of input.players)
|
|
Object.defineProperty(p, 'hand', {
|
|
get() {
|
|
throw Error('Private hand read.');
|
|
},
|
|
});
|
|
Object.defineProperty(input.players[0], 'noField', {
|
|
get() {
|
|
throw Error(
|
|
'The mover marker value is irrelevant to arrival eligibility.',
|
|
);
|
|
},
|
|
});
|
|
const random = t.mock.method(crypto, 'getRandomValues', () => {
|
|
throw Error('Randomness in pure quote.');
|
|
});
|
|
const uuid = t.mock.method(crypto, 'randomUUID', () => {
|
|
throw Error('Event creation in pure quote.');
|
|
});
|
|
const first = quoteCompletedMovementArrival(input);
|
|
assert.deepEqual(quoteCompletedMovementArrival(input), first);
|
|
assert.deepEqual(JSON.parse(JSON.stringify(input)), before);
|
|
assert.equal(random.mock.callCount(), 0);
|
|
assert.equal(uuid.mock.callCount(), 0);
|
|
});
|
|
|
|
const seat = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
|
|
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
|
const source = 'imperial_basin:10';
|
|
|
|
function holdCard(g: Game, id: string, name: string) {
|
|
const pile = g.richeseCache?.some((c) => c.name === name)
|
|
? g.richeseCache
|
|
: g.deck;
|
|
const index = pile!.findIndex((c) => c.name === name);
|
|
assert.ok(index >= 0, name);
|
|
const card = pile!.splice(index, 1)[0];
|
|
seat(g, id).hand.push(card);
|
|
return card.id;
|
|
}
|
|
function physical(g: Game) {
|
|
const ids = [
|
|
...g.deck,
|
|
...g.discard,
|
|
...g.players.flatMap((p) => p.hand),
|
|
...(g.richeseCache ?? []),
|
|
...(g.ornithopter ? [g.ornithopter.card] : []),
|
|
]
|
|
.map((c) => c.id)
|
|
.sort();
|
|
assert.equal(new Set(ids).size, ids.length);
|
|
for (const p of g.players)
|
|
assert.equal(
|
|
p.reserves + p.tanks + Object.values(p.forces).reduce((a, n) => a + n, 0),
|
|
20,
|
|
);
|
|
return ids;
|
|
}
|
|
|
|
/** Only initial board/card supply is staged. Movement card selection, the
|
|
* CHOAM opportunity, and Baliset declaration are all actual public actions. */
|
|
function balisetFlight(mode: 'range3' | 'twoGroups', conflict = false) {
|
|
let g = createGame('KARAMAARRIVAL', newPlayer('c', 'CHOAM', 'choam'), true, [
|
|
'choam',
|
|
'ecaz',
|
|
]);
|
|
joinGame(g, newPlayer('p', 'Emperor', 'emperor'));
|
|
joinGame(g, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
|
|
joinGame(g, newPlayer('e', 'Ecaz', 'ecaz'));
|
|
joinGame(g, newPlayer('m', 'Moritani', 'moritani'));
|
|
Object.assign(g, {
|
|
status: 'playing',
|
|
phase: 5,
|
|
turn: 2,
|
|
storm: 18,
|
|
order: ['p', 'c', 'b', 'e', 'm'],
|
|
active: 'p',
|
|
movementRemaining: ['p', 'c', 'b', 'e', 'm'],
|
|
deck: baseDeck(),
|
|
richeseCache: richeseCards(),
|
|
});
|
|
for (const p of g.players) {
|
|
p.hand = [];
|
|
p.spice = 10;
|
|
p.forces = {};
|
|
p.reserves = 20;
|
|
}
|
|
seat(g, 'p').forces[source] = 3;
|
|
seat(g, 'p').reserves = 17;
|
|
const territory = mode === 'range3' ? 'arrakeen' : 'carthag';
|
|
const sector = mode === 'range3' ? 10 : 11;
|
|
const destination = `${territory}:${sector}`;
|
|
seat(g, 'c').forces[destination] = 1;
|
|
seat(g, 'c').reserves = 19;
|
|
const orni = holdCard(g, 'p', 'Ornithopter');
|
|
const baliset = holdCard(g, 'c', 'Baliset');
|
|
const printed = holdCard(g, 'p', 'Karama');
|
|
const worthless = holdCard(g, 'b', 'Jubba Cloak');
|
|
const addLegacyConflict = () => {
|
|
g.ecazAmbassadors = createAmbassadors(() => 0);
|
|
g.ecazAmbassadors = placeAmbassador(
|
|
g.ecazAmbassadors,
|
|
g.ecazAmbassadors.tokens.find((t) => t.effect === 'ecaz')!.id,
|
|
{
|
|
turn: 1,
|
|
availableSpice: 10,
|
|
destination: {
|
|
id: territory,
|
|
stronghold: true,
|
|
allowed: true,
|
|
inStorm: false,
|
|
},
|
|
},
|
|
).state;
|
|
g.moritaniTerror = createTerrorState(() => 0);
|
|
g.moritaniTerror = placeTerror(
|
|
g.moritaniTerror,
|
|
g.moritaniTerror.tokens.find((t) => t.kind === 'sabotage')!.id,
|
|
territory,
|
|
1,
|
|
);
|
|
};
|
|
const inventory = physical(g);
|
|
if (mode === 'twoGroups') {
|
|
g = applyAction(g, 'p', {
|
|
type: 'move',
|
|
movementCard: orni,
|
|
ornithopter: mode,
|
|
forces: { [source]: 1 },
|
|
territory: 'arrakeen',
|
|
sector: 10,
|
|
});
|
|
assert.equal(g.ornithopter?.completed, 1);
|
|
assert.equal(g.decision, null);
|
|
g = applyAction(g, 'p', {
|
|
type: 'move',
|
|
ornithopterEvent: g.ornithopter!.event,
|
|
forces: { [source]: 1 },
|
|
territory,
|
|
sector,
|
|
});
|
|
} else {
|
|
g = applyAction(g, 'p', {
|
|
type: 'move',
|
|
movementCard: orni,
|
|
ornithopter: mode,
|
|
forces: { [source]: 1 },
|
|
territory,
|
|
sector,
|
|
});
|
|
}
|
|
assert.equal(g.decision?.kind, 'choamMovement');
|
|
assert.equal(seat(g, 'p').moved, mode === 'twoGroups' ? 1 : 0);
|
|
g = applyAction(g, 'c', {
|
|
type: 'card',
|
|
card: baliset,
|
|
mode: 'choam',
|
|
target: 'p',
|
|
territory,
|
|
});
|
|
assert.equal(g.response?.kind, 'choamWorthless');
|
|
assert.equal(g.pendingChoamWorthless?.movement, true);
|
|
// Model a saved prevention window whose destination gained the paired
|
|
// arrival tokens after the original uncommitted declaration.
|
|
if (conflict) addLegacyConflict();
|
|
assert.deepEqual(physical(g), inventory);
|
|
return { g, orni, baliset, printed, worthless, destination };
|
|
}
|
|
|
|
function allowConversion(initial: Game) {
|
|
let g = initial;
|
|
while (g.response?.kind === 'worthlessKarama') {
|
|
const p = g.players.find((p) => !g.response!.passed.includes(p.id));
|
|
assert.ok(p);
|
|
g = applyAction(g, p.id, { type: 'passResponse' });
|
|
}
|
|
return g;
|
|
}
|
|
|
|
for (const form of ['printed', 'worthless'] as const)
|
|
for (const mode of ['range3', 'twoGroups'] as const)
|
|
void test(`${form} cancellation of real Baliset commits one ${mode} flight and serializes both arrival reactions`, () => {
|
|
const { g, printed, worthless, orni, baliset, destination } = balisetFlight(
|
|
mode,
|
|
true,
|
|
);
|
|
const before = structuredClone(g);
|
|
const inventory = physical(g);
|
|
const spent = form === 'printed' ? printed : worthless;
|
|
let done = applyAction(g, form === 'printed' ? 'p' : 'b', {
|
|
type: 'card',
|
|
mode: 'cancel',
|
|
card: spent,
|
|
});
|
|
assert.deepEqual(g, before);
|
|
if (form === 'worthless') {
|
|
assert.equal(done.response?.kind, 'worthlessKarama');
|
|
assert.equal(seat(done, 'p').forces[destination] ?? 0, 0);
|
|
done = allowConversion(reload(done));
|
|
}
|
|
assert.equal(seat(done, 'p').forces[destination], 1);
|
|
assert.equal(seat(done, 'p').forces[source], mode === 'twoGroups' ? 1 : 2);
|
|
assert.equal(seat(done, 'p').moved, mode === 'twoGroups' ? 2 : 1);
|
|
assert.equal(done.ornithopter, null);
|
|
assert.equal(done.pendingChoamMove, null);
|
|
assert.equal(done.discard.filter((c) => c.id === orni).length, 1);
|
|
assert.equal(done.discard.filter((c) => c.id === spent).length, 1);
|
|
assert.ok(seat(done, 'c').hand.some((c) => c.id === baliset));
|
|
assert.deepEqual(physical(done), inventory);
|
|
assert.equal(done.decision?.kind, 'ecazAmbassador');
|
|
done = applyAction(reload(done), 'e', {
|
|
type: 'decision',
|
|
event: done.pendingAmbassador!.event,
|
|
decline: true,
|
|
});
|
|
assert.equal(done.decision?.kind, 'moritaniTerror');
|
|
done = applyAction(reload(done), 'm', { type: 'decision', decline: true });
|
|
assert.equal(done.decision, null);
|
|
assert.equal(seat(done, 'p').forces[destination], 1);
|
|
assert.equal(seat(done, 'p').moved, mode === 'twoGroups' ? 2 : 1);
|
|
assert.deepEqual(physical(done), inventory);
|
|
});
|
|
|
|
for (const form of ['printed', 'worthless'] as const)
|
|
for (const mode of ['range3', 'twoGroups'] as const)
|
|
void test(`${form} cancellation of real Baliset completes the exact final ${mode} flight and retires escrow once`, () => {
|
|
const { g, printed, worthless, orni, baliset, destination } =
|
|
balisetFlight(mode);
|
|
const before = structuredClone(g);
|
|
const inventory = physical(g);
|
|
const spent = form === 'printed' ? printed : worthless;
|
|
let done = applyAction(g, form === 'printed' ? 'p' : 'b', {
|
|
type: 'card',
|
|
mode: 'cancel',
|
|
card: spent,
|
|
});
|
|
assert.deepEqual(g, before);
|
|
if (form === 'worthless') {
|
|
assert.equal(done.response?.kind, 'worthlessKarama');
|
|
assert.equal(done.ornithopter?.completed, mode === 'twoGroups' ? 1 : 0);
|
|
assert.equal(seat(done, 'p').forces[destination] ?? 0, 0);
|
|
done = allowConversion(reload(done));
|
|
}
|
|
assert.equal(seat(done, 'p').forces[destination], 1);
|
|
assert.equal(
|
|
seat(done, 'p').forces[source],
|
|
mode === 'twoGroups' ? 1 : 2,
|
|
);
|
|
assert.equal(seat(done, 'p').moved, mode === 'twoGroups' ? 2 : 1);
|
|
assert.equal(done.ornithopter, null);
|
|
assert.equal(done.pendingChoamMove, null);
|
|
assert.equal(done.pendingTreacheryDiscard, null);
|
|
assert.equal(done.discard.filter((c) => c.id === orni).length, 1);
|
|
assert.equal(done.discard.filter((c) => c.id === spent).length, 1);
|
|
assert.ok(seat(done, 'c').hand.some((c) => c.id === baliset));
|
|
assert.deepEqual(physical(done), inventory);
|
|
assert.deepEqual(
|
|
reload(normalizeAutomaticGame(reload(done))),
|
|
reload(done),
|
|
);
|
|
});
|
|
|
|
for (const form of ['printed', 'worthless'] as const)
|
|
void test(`${form} cancellation still accepts a real Baliset order that became illegal, without moving or retiring the unused flight`, () => {
|
|
const {
|
|
g: original,
|
|
printed,
|
|
worthless,
|
|
orni,
|
|
baliset,
|
|
destination,
|
|
} = balisetFlight('range3');
|
|
const g = reload(original);
|
|
// Saved-state perturbation, not a claimed legal action during the response:
|
|
// source units have returned to reserves. The current flight remains valid,
|
|
// but its declared group is unavailable and must take the existing fizzle.
|
|
seat(g, 'p').reserves += seat(g, 'p').forces[source];
|
|
delete seat(g, 'p').forces[source];
|
|
const before = structuredClone(g);
|
|
const inventory = physical(g);
|
|
const spent = form === 'printed' ? printed : worthless;
|
|
let done = applyAction(g, form === 'printed' ? 'p' : 'b', {
|
|
type: 'card',
|
|
mode: 'cancel',
|
|
card: spent,
|
|
});
|
|
assert.deepEqual(g, before);
|
|
if (form === 'worthless') done = allowConversion(reload(done));
|
|
assert.equal(seat(done, 'p').moved, 0);
|
|
assert.equal(seat(done, 'p').forces[destination] ?? 0, 0);
|
|
assert.equal(seat(done, 'p').reserves, 20);
|
|
assert.equal(done.ornithopter?.completed, 0);
|
|
assert.equal(done.ornithopter?.card.id, orni);
|
|
assert.equal(done.pendingChoamMove, null);
|
|
assert.equal(done.pendingChoamWorthless, null);
|
|
assert.equal(done.discard.filter((c) => c.id === spent).length, 1);
|
|
assert.equal(
|
|
done.discard.some((c) => c.id === orni),
|
|
false,
|
|
);
|
|
assert.ok(seat(done, 'c').hand.some((c) => c.id === baliset));
|
|
assert.deepEqual(physical(done), inventory);
|
|
assert.deepEqual(
|
|
reload(normalizeAutomaticGame(reload(done))),
|
|
reload(done),
|
|
);
|
|
});
|