Files
Dune/tests/fremen-ambassador-engine.test.ts

884 lines
30 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
normalizeAutomaticGame,
type Game,
type Action,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import type { FactionId } from '../game/catalog';
import {
createAmbassadors,
placeAmbassador,
validateAmbassadors,
} from '../game/ecaz-ambassadors';
import {
createRicheseNoField,
deployRicheseNoField,
} from '../game/richese-no-field';
import { createTerrorState, placeTerror } from '../game/moritani-terror';
import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board';
import { createLeaderSkills } from '../game/leader-skills';
const p = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
function fixture(
faction: FactionId = 'fremen',
extra: FactionId[] = [],
copy = false,
fifth = false,
entrantFaction: FactionId = 'emperor',
) {
const g = createGame('FAMBTEST', newPlayer('ec', 'Ecaz', 'ecaz'), true, [
'ecaz',
'choam',
'ix',
]);
g.players.push(
newPlayer('in', 'Entrant', entrantFaction),
newPlayer('a', 'Ally', faction),
...extra.map((f) => newPlayer(f, f, f)),
);
Object.assign(g, {
status: 'playing',
phase: 5,
turn: 2,
storm: 18,
active: 'in',
deck: baseDeck(),
discard: [],
order: g.players.map((p) => p.id),
movementRemaining: g.players.map((p) => p.id),
});
for (const player of g.players)
Object.assign(player, {
hand: [],
forces: {},
reserves: 20,
tanks: 0,
spice: 20,
traitors: [],
traitorChoices: [],
shipped: false,
moved: 0,
});
p(g, 'ec').ally = 'a';
p(g, 'a').ally = 'ec';
p(g, 'in').forces = { 'imperial_basin:10': 1 };
p(g, 'in').reserves = 19;
p(g, 'in').shipped = true;
let state = createAmbassadors(() => 0.2);
const token = state.tokens.find(
(t) => t.effect === (copy ? 'beneGesserit' : 'fremen'),
)!;
state.cohort = [
token.id,
...state.tokens
.filter(
(t) =>
t.effect !== 'ecaz' &&
t.id !== token.id &&
(!copy || t.effect !== 'fremen'),
)
.slice(0, 4)
.map((t) => t.id),
];
for (const t of state.tokens) {
t.zone =
t.effect === 'ecaz' || state.cohort.includes(t.id) ? 'supply' : 'pool';
t.location = null;
if (fifth && t.id !== token.id && state.cohort.includes(t.id))
t.zone = 'used';
}
state = placeAmbassador(state, token.id, {
turn: 1,
availableSpice: 20,
destination: {
id: 'arrakeen',
stronghold: true,
inStorm: false,
allowed: true,
},
}).state;
g.ecazAmbassadors = state;
return g;
}
function army(g: Game, id: string, forces: Record<string, number>) {
p(g, id).forces = forces;
p(g, id).reserves =
20 - p(g, id).tanks - Object.values(forces).reduce((a, b) => a + b, 0);
}
function hold(g: Game, id: string, name: string) {
const at = g.deck.findIndex((c) => c.name === name);
assert.ok(at >= 0, name);
const card = g.deck.splice(at, 1)[0];
p(g, id).hand.push(card);
return card.id;
}
function enter(g: Game) {
const entered = applyAction(g, 'in', {
type: 'move',
forces: { 'imperial_basin:10': 1 },
territory: 'arrakeen',
sector: 10,
});
assert.equal(entered.pendingAmbassador?.stage, 'offer');
return entered;
}
function trigger(g: Game, beneficiary = 'a', copy = false) {
let out = applyAction(g, 'ec', {
type: 'decision',
event: g.pendingAmbassador!.event,
trigger: true,
beneficiary,
});
if (copy)
out = applyAction(out, beneficiary, {
type: 'decision',
event: out.pendingAmbassador!.event,
effect: 'fremen',
});
return out;
}
function sandmasterFixture(advanced = true, extra: FactionId[] = []) {
const g = fixture('fremen', extra);
g.advanced = advanced;
g.expansions = ['ecaz'];
army(g, 'a', { 'red_chasm:7': 2 });
g.spice = { 'south_mesa:4': 4, 'south_mesa:5': 2 };
const skills = createLeaderSkills(() => 0.2);
skills.deck = skills.deck.filter(skill => skill !== 'sandmaster');
skills.assignments = [{ skill: 'sandmaster', owner: 'a', leader: p(g, 'a').leaders[0].id }];
g.leaderSkills = skills;
return g;
}
void test('physical Fremen Ambassador relocation collects optional Sandmaster spice without consuming ordinary allowances', () => {
for (const collect of [false, true]) {
const start = fixture();
start.expansions = ['ecaz'];
army(start, 'a', { 'red_chasm:7': 2 });
start.spice = { 'hagga_basin:12': 2 };
const skills = createLeaderSkills(() => 0.2);
skills.deck = skills.deck.filter(skill => skill !== 'sandmaster');
skills.assignments = [{ skill: 'sandmaster', owner: 'a', leader: p(start, 'a').leaders[0].id }];
start.leaderSkills = skills;
const pending = trigger(enter(start));
const done = move(pending, {
forces: { 'red_chasm:7': 2 }, territory: 'hagga_basin', sector: 12,
sandmasterCollect: collect,
});
assert.equal(p(done, 'a').forces['hagga_basin:12'], 2);
assert.equal(p(done, 'a').spice, collect ? 21 : 20);
assert.equal(done.spice['hagga_basin:12'], collect ? 1 : 2);
assert.equal(p(done, 'a').shipped, false);
assert.equal(p(done, 'a').moved, 0);
assert.equal(p(done, 'a').reserves, 18);
assert.equal(p(done, 'a').tanks, 0);
assert.equal(done.pendingAmbassador, null);
}
});
void test('Basic and Advanced Ambassador relocation debit exactly the chosen destination pile or decline both', () => {
for (const advanced of [false, true]) {
const pending = trigger(enter(sandmasterFixture(advanced)));
for (const selected of [undefined, 'south_mesa:4', 'south_mesa:5']) {
const action: Action = {
type: 'decision',
event: pending.pendingAmbassador!.event,
forces: { 'red_chasm:7': 2 },
territory: 'south_mesa',
sector: 4,
sandmasterCollect: selected !== undefined,
...(selected ? { sandmasterPile: selected } : {}),
};
const done = applyAction(reload(pending), 'a', action);
assert.equal(p(done, 'a').forces['south_mesa:4'], 2);
assert.equal(p(done, 'a').forces['red_chasm:7'] ?? 0, 0);
assert.equal(p(done, 'a').spice, selected ? 21 : 20);
assert.equal(done.spice['south_mesa:4'], selected === 'south_mesa:4' ? 3 : 4);
assert.equal(done.spice['south_mesa:5'], selected === 'south_mesa:5' ? 1 : 2);
assert.equal(p(done, 'a').moved, 0);
assert.equal(p(done, 'a').shipped, false);
assert.equal(p(done, 'a').reserves, 18);
assert.equal(done.pendingAmbassador, null);
}
}
});
void test('Ambassador collecting declarations reject missing, malformed, foreign and exhausted pile keys before mutation', () => {
const start = sandmasterFixture();
start.spice['red_chasm:7'] = 3;
const pending = trigger(enter(start));
const action: Action = {
type: 'decision',
event: pending.pendingAmbassador!.event,
forces: { 'red_chasm:7': 2 },
territory: 'south_mesa',
sector: 4,
sandmasterCollect: true,
};
for (const key of [undefined, '', 'south_mesa:999', 'red_chasm:7', 5, null, ['south_mesa:5']])
reject(pending, { ...action, sandmasterPile: key } as Action);
reject(pending, { ...action, sandmasterCollect: false, sandmasterPile: 'south_mesa:5' });
const exhausted = reload(pending);
exhausted.spice['south_mesa:5'] = 0;
reject(exhausted, { ...action, sandmasterPile: 'south_mesa:5' });
const dead = reload(pending);
p(dead, 'a').leaders[0].dead = true;
reject(dead, { ...action, sandmasterPile: 'south_mesa:5' });
const captured = reload(pending);
p(captured, 'a').leaders[0].capturedBy = 'in';
reject(captured, { ...action, sandmasterPile: 'south_mesa:5' });
});
void test('selected Ambassador collection commits before Intrusion and a restored arrival resumes without a second debit', () => {
const start = sandmasterFixture(true, ['beneGesserit']);
army(start, 'beneGesserit', { 'south_mesa:4': 1 });
let g = trigger(enter(start));
const event = g.pendingAmbassador!.event;
g = move(g, {
forces: { 'red_chasm:7': 2 },
territory: 'south_mesa',
sector: 4,
sandmasterCollect: true,
sandmasterPile: 'south_mesa:5',
});
assert.equal(g.decision?.kind, 'intrusion');
assert.equal(g.pendingAmbassador?.stage, 'arrival');
assert.equal(g.pendingAmbassador?.event, event);
assert.equal(p(g, 'a').forces['south_mesa:4'], 2);
assert.equal(p(g, 'a').spice, 21);
assert.equal(g.spice['south_mesa:4'], 4);
assert.equal(g.spice['south_mesa:5'], 1);
g = applyAction(reload(g), 'beneGesserit', { type: 'decision', accept: true });
g = allow(reload(g));
assert.equal(p(g, 'a').forces['south_mesa:4'], 2);
assert.equal(p(g, 'a').spice, 21);
assert.equal(g.spice['south_mesa:4'], 4);
assert.equal(g.spice['south_mesa:5'], 1);
assert.equal(g.log.filter(entry => entry.automatic?.name === 'Sandmaster collection').length, 1);
settled(g);
});
void test('Ambassador sector relocation within its source territory earns no Sandmaster spice', () => {
const start = sandmasterFixture();
army(start, 'a', { 'wind_pass:14': 2 });
start.spice = { 'wind_pass:14': 4, 'wind_pass:15': 2 };
const pending = trigger(enter(start));
const action: Action = {
type: 'decision',
event: pending.pendingAmbassador!.event,
forces: { 'wind_pass:14': 2 },
territory: 'wind_pass',
sector: 15,
};
reject(pending, { ...action, sandmasterCollect: true, sandmasterPile: 'wind_pass:15' });
const done = applyAction(reload(pending), 'a', action);
assert.equal(p(done, 'a').forces['wind_pass:15'], 2);
assert.equal(p(done, 'a').spice, 20);
assert.deepEqual(done.spice, start.spice);
});
function move(g: Game, extra: Omit<Action, 'type'>, actor = 'a') {
return applyAction(g, actor, {
type: 'decision',
event: g.pendingAmbassador!.event,
...extra,
});
}
function bookkeeping(g: Game) {
return {
turn: g.turn,
phase: g.phase,
active: g.active,
queue: g.movementRemaining,
players: g.players.map((p) => ({
id: p.id,
shipped: p.shipped,
moved: p.moved,
spice: p.spice,
reserves: p.reserves,
tanks: p.tanks,
})),
ornithopter: g.ornithopter,
};
}
function conserve(g: Game) {
validateAmbassadors(g.ecazAmbassadors!);
for (const player of g.players)
assert.equal(
player.reserves +
player.tanks +
Object.values(player.forces).reduce((a, b) => a + b, 0),
20,
player.id,
);
const ids = [
...g.deck,
...g.discard,
...(g.richeseCache ?? []),
...(g.ornithopter ? [g.ornithopter.card] : []),
...g.players.flatMap((p) => p.hand),
].map((c) => c.id);
assert.equal(new Set(ids).size, ids.length);
return ids.sort();
}
function reject(g: Game, action: Action, actor = 'a') {
const before = reload(g);
assert.throws(() => applyAction(g, actor, action));
assert.deepEqual(reload(g), before);
}
function allow(g: Game) {
for (let i = 0; g.response && i < 30; i++) {
const actor = g.players.find((p) => !g.response!.passed.includes(p.id));
assert.ok(actor);
g = applyAction(g, actor.id, { type: 'passResponse' });
}
assert.equal(g.response, null);
return g;
}
function settled(g: Game) {
assert.equal(g.pendingAmbassador, null);
assert.equal(g.decision, null);
assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
conserve(g);
}
void test('real entry grants a far, combined-sector typed group without ordinary movement consumption', () => {
const initial = fixture();
army(initial, 'a', { 'wind_pass:14': 3, 'wind_pass:15': 2 });
p(initial, 'a').elites = {
forces: { 'wind_pass:14': 1, 'wind_pass:15': 1 },
reserves: 1,
tanks: 0,
revived: 0,
};
p(initial, 'a').moved = 1;
p(initial, 'a').shipped = true;
const g = trigger(enter(initial)),
before = bookkeeping(g),
ids = conserve(g),
event = g.pendingAmbassador!.event;
assert.equal(g.decision?.player, 'a');
assert.ok(viewGame(g, 'a').ambassadorEntry?.movement?.sources.length);
for (const id of ['ec', 'in'])
assert.equal(viewGame(g, id).ambassadorEntry?.movement, null);
const action: Action = {
type: 'decision',
event,
forces: { 'wind_pass:14': 2, 'wind_pass:15': 1 },
eliteForces: { 'wind_pass:14': 1, 'wind_pass:15': 0 },
territory: 'sietch_tabr',
sector: 14,
};
reject(g, action, 'ec');
reject(g, { ...action, event: 'stale' });
reject(g, { ...action, eliteForces: { 'wind_pass:14': 3 } });
const done = applyAction(reload(g), 'a', action);
assert.deepEqual(p(done, 'a').forces, {
'wind_pass:14': 1,
'wind_pass:15': 1,
'sietch_tabr:14': 3,
});
assert.equal(p(done, 'a').elites!.forces['sietch_tabr:14'], 1);
assert.equal(p(done, 'a').elites!.forces['wind_pass:15'], 1);
assert.deepEqual(bookkeeping(done), before);
assert.deepEqual(conserve(done), ids);
settled(done);
reject(done, action);
});
void test('same-territory redistribution permits Ecaz allied occupancy and clear sectors without an entry reaction', () => {
const initial = fixture('atreides', ['beneGesserit']);
army(initial, 'a', { 'wind_pass:14': 2, 'wind_pass:15': 1 });
army(initial, 'ec', { 'wind_pass:14': 1 });
army(initial, 'beneGesserit', { 'wind_pass:14': 1 });
initial.storm = 15;
const g = trigger(enter(initial));
reject(g, {
type: 'decision',
event: g.pendingAmbassador!.event,
forces: { 'wind_pass:15': 1 },
territory: 'sietch_tabr',
sector: 14,
});
const done = move(g, {
forces: { 'wind_pass:14': 1 },
territory: 'wind_pass',
sector: 16,
});
assert.equal(p(done, 'a').forces['wind_pass:16'], 1);
assert.equal(p(done, 'a').forces['wind_pass:15'], 1);
settled(done);
});
void test('a fifth-cohort BG copy replenishes only after the selected relocation and empty armies finish without a new veto', () => {
const initial = fixture('atreides', [], true, true);
army(initial, 'a', { 'red_chasm:7': 2 });
const g = trigger(enter(initial), 'a', true);
assert.equal(g.pendingAmbassador?.effect, 'fremen');
const cohort = [...g.ecazAmbassadors!.cohort];
assert.ok(
g.ecazAmbassadors!.tokens.some(
(t) => t.effect === 'beneGesserit' && t.zone === 'removed',
),
);
const done = move(g, {
forces: { 'red_chasm:7': 1 },
territory: 'sietch_tabr',
sector: 14,
});
assert.notDeepEqual(done.ecazAmbassadors!.cohort, cohort);
settled(done);
const empty = trigger(enter(fixture('atreides')));
settled(empty);
assert.ok(
empty.ecazAmbassadors!.tokens.some(
(t) => t.effect === 'fremen' && t.zone === 'used',
),
);
});
void test('marker-only zero and nonzero No-Fields enter the real HMS interior without revealing or moving reserves', () => {
for (const value of [0, 3, 5] as const) {
const initial = fixture('richese', ['ixians']);
initial.mobileStronghold = { location: 'wind_pass:14' };
p(initial, 'a').noField = deployRicheseNoField(
createRicheseNoField(['nf0', 'nf3', 'nf5']),
{
tokenId: `nf${value}`,
controller: 'a',
location: { territory: 'wind_pass', sector: 15 },
},
);
p(initial, 'a').noFieldEvent = 'marker-event';
const g = trigger(enter(initial));
const old = reload(g).players.find((p) => p.id === 'a')!;
const noField = { tokenId: `nf${value}`, event: 'marker-event' };
reject(g, {
type: 'decision',
event: g.pendingAmbassador!.event,
forces: {},
noField: { ...noField, event: g.pendingAmbassador!.event },
territory: MOBILE_STRONGHOLD,
sector: 0,
});
const done = move(reload(g), {
forces: {},
noField,
territory: MOBILE_STRONGHOLD,
sector: 0,
});
assert.deepEqual(p(done, 'a').noField!.deployed!.location, {
territory: MOBILE_STRONGHOLD,
sector: 0,
});
assert.equal(p(done, 'a').reserves, old.reserves);
assert.deepEqual(p(done, 'a').forces, {});
assert.notEqual(p(done, 'a').noFieldEvent, old.noFieldEvent);
assert.deepEqual(done.mobileStronghold, g.mobileStronghold);
settled(done);
assert.equal(Object.keys(p(done, 'ixians').forces).length, 0);
assert.equal(MOBILE_LOCATION, `${MOBILE_STRONGHOLD}:0`);
}
});
void test('CHOAM may permit the independent move, or Baliset blocks it and leaves a legal re-selection', () => {
for (const block of [false, true]) {
const initial = fixture('atreides', ['choam']);
army(initial, 'a', { 'red_chasm:7': 3 });
army(initial, 'choam', { 'sietch_tabr:14': 1 });
const baliset = hold(initial, 'choam', 'Baliset');
hold(initial, 'a', 'Karama');
let g = trigger(enter(initial));
const before = bookkeeping(g);
g = move(g, {
forces: { 'red_chasm:7': 2 },
territory: 'sietch_tabr',
sector: 14,
});
assert.equal(g.decision?.kind, 'choamMovement');
assert.equal(g.pendingChoamMove?.source, 'ambassador');
assert.equal(p(g, 'a').forces['red_chasm:7'], 3);
if (block) {
g = applyAction(g, 'choam', {
type: 'card',
card: baliset,
mode: 'choam',
target: 'a',
territory: 'sietch_tabr',
});
g = allow(reload(g));
assert.equal(g.pendingAmbassador?.stage, 'move');
assert.equal(p(g, 'a').forces['red_chasm:7'], 3);
reject(g, {
type: 'decision',
event: g.pendingAmbassador!.event,
forces: { 'red_chasm:7': 2 },
territory: 'sietch_tabr',
sector: 14,
});
g = move(g, {
forces: { 'red_chasm:7': 2 },
territory: 'wind_pass',
sector: 14,
});
assert.equal(g.discard.filter((c) => c.id === baliset).length, 1);
} else {
g = applyAction(reload(g), 'choam', { type: 'decision', decline: true });
assert.equal(p(g, 'a').forces['sietch_tabr:14'], 2);
}
assert.deepEqual(bookkeeping(g), before);
settled(g);
}
});
void test('printed cancellation of Baliset restores the declared typed relocation exactly once', () => {
const initial = fixture('fremen', ['choam']);
army(initial, 'a', { 'red_chasm:7': 3 });
p(initial, 'a').elites = {
forces: { 'red_chasm:7': 2 },
reserves: 1,
tanks: 0,
revived: 0,
};
army(initial, 'choam', { 'sietch_tabr:14': 1 });
const baliset = hold(initial, 'choam', 'Baliset'),
karama = hold(initial, 'a', 'Karama');
let g = trigger(enter(initial));
g = move(g, {
forces: { 'red_chasm:7': 2 },
eliteForces: { 'red_chasm:7': 1 },
territory: 'sietch_tabr',
sector: 14,
});
g = applyAction(g, 'choam', {
type: 'card',
card: baliset,
mode: 'choam',
target: 'a',
territory: 'sietch_tabr',
});
g = applyAction(reload(g), 'a', {
type: 'card',
card: karama,
mode: 'cancel',
});
assert.equal(p(g, 'a').forces['sietch_tabr:14'], 2);
assert.equal(p(g, 'a').elites!.forces['sietch_tabr:14'], 1);
assert.equal(p(g, 'a').moved, 0);
assert.ok(p(g, 'choam').hand.some((c) => c.id === baliset));
assert.equal(g.discard.filter((c) => c.id === karama).length, 1);
settled(g);
});
void test('BG Intrusion is a durable child of the relocation and does not resume the entrant prematurely', () => {
const initial = fixture('atreides', ['beneGesserit']);
army(initial, 'a', { 'red_chasm:7': 2 });
army(initial, 'beneGesserit', { 'sietch_tabr:14': 1 });
hold(initial, 'a', 'Karama');
let g = trigger(enter(initial));
const before = bookkeeping(g);
g = move(g, {
forces: { 'red_chasm:7': 1 },
territory: 'sietch_tabr',
sector: 14,
});
assert.equal(g.decision?.kind, 'intrusion');
assert.equal(g.pendingAmbassador?.stage, 'arrival');
const event = g.pendingAmbassador!.event;
reject(g, { type: 'endMovement' }, 'in');
g = applyAction(reload(g), 'beneGesserit', {
type: 'decision',
accept: true,
});
assert.equal(g.response?.kind, 'advisorFlip');
assert.equal(g.pendingAmbassador?.event, event);
g = allow(reload(g));
assert.ok(p(g, 'beneGesserit').advisors?.sietch_tabr);
assert.deepEqual(bookkeeping(g), before);
settled(g);
});
void test('a separate Terror child can finish after relocation; simultaneous Intrusion plus Terror rejects before transfer', () => {
for (const simultaneous of [false, true]) {
const initial = fixture(
'atreides',
simultaneous ? ['moritani', 'beneGesserit'] : ['moritani'],
);
army(initial, 'a', { 'red_chasm:7': 2 });
if (simultaneous) army(initial, 'beneGesserit', { 'sietch_tabr:14': 1 });
initial.moritaniTerror = createTerrorState(() => 0);
const token = initial.moritaniTerror.tokens.find(
(t) => t.kind === 'sabotage',
)!;
initial.moritaniTerror = placeTerror(
initial.moritaniTerror,
token.id,
'sietch_tabr',
1,
);
const g = trigger(enter(initial));
const action: Action = {
type: 'decision',
event: g.pendingAmbassador!.event,
forces: { 'red_chasm:7': 1 },
territory: 'sietch_tabr',
sector: 14,
};
if (simultaneous) {
reject(g, action);
continue;
}
let child = applyAction(g, 'a', action);
assert.equal(child.pendingTerrorEntry?.cause, 'ambassador');
assert.equal(child.pendingTerrorEntry?.entrant, 'a');
assert.equal(child.pendingAmbassador?.stage, 'arrival');
assert.equal('kind' in viewGame(child, 'a').terrorEntry!, false);
assert.equal(viewGame(child, 'moritani').terrorEntry?.kind, 'sabotage');
child = applyAction(reload(child), 'moritani', {
type: 'decision',
decline: true,
});
assert.equal(child.pendingTerrorEntry, null);
assert.equal(p(child, 'a').forces['sietch_tabr:14'], 1);
settled(child);
}
});
void test('phase-one advisor relocation permits a native flip and resumes the next genuine worm ride only after the response', () => {
for (const cancel of [false, true]) {
const initial = fixture(
'beneGesserit',
['atreides'],
true,
false,
'fremen',
);
Object.assign(initial, {
phase: 1,
active: null,
nexus: false,
wormRides: ['hagga_basin'],
decision: { kind: 'wormRide', player: 'in', territory: 'imperial_basin' },
});
army(initial, 'in', { 'imperial_basin:10': 2, 'hagga_basin:12': 1 });
army(initial, 'a', { 'red_chasm:7': 2 });
army(initial, 'atreides', { 'red_chasm:7': 1, 'sietch_tabr:14': 1 });
p(initial, 'a').advisors = { red_chasm: {} };
const karama = hold(initial, 'ec', 'Karama');
let g = applyAction(initial, 'in', {
type: 'decision',
accept: true,
territory: 'arrakeen',
sector: 10,
forces: { 'imperial_basin:10': 1 },
});
if (g.response) g = allow(g);
assert.equal(g.pendingAmbassador?.resume, 'wormRide');
g = trigger(g, 'a', true);
const event = g.pendingAmbassador!.event;
g = move(g, {
forces: { 'red_chasm:7': 1 },
territory: 'sietch_tabr',
sector: 14,
fighters: true,
});
assert.equal(g.response?.kind, 'advisorFlip');
assert.equal(g.response?.advisorResume, 'ambassador');
assert.equal(g.pendingAmbassador?.event, event);
assert.ok(p(g, 'a').advisors?.sietch_tabr);
assert.deepEqual(g.wormRides, ['hagga_basin']);
g = cancel
? applyAction(reload(g), 'ec', {
type: 'card',
card: karama,
mode: 'cancel',
})
: allow(reload(g));
assert.equal(!!p(g, 'a').advisors?.sietch_tabr, cancel);
assert.equal(g.pendingAmbassador, null);
assert.deepEqual(g.decision, {
kind: 'wormRide',
player: 'in',
territory: 'hagga_basin',
});
assert.deepEqual(g.wormRides, []);
assert.equal(p(g, 'a').moved, 0);
assert.equal(p(g, 'in').forces['arrakeen:10'], 1);
conserve(g);
assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
}
});
void test('Sabotage revealed after relocation discards once and restores the Ambassador only after its optional gift', () => {
const initial = fixture('atreides', ['moritani']);
army(initial, 'a', { 'red_chasm:7': 2 });
initial.moritaniTerror = createTerrorState(() => 0);
const terror = initial.moritaniTerror.tokens.find(
(t) => t.kind === 'sabotage',
)!;
initial.moritaniTerror = placeTerror(
initial.moritaniTerror,
terror.id,
'sietch_tabr',
1,
);
const victim = hold(initial, 'a', 'Shield'),
gift = hold(initial, 'moritani', 'Snooper');
const ids = conserve(initial);
let g = trigger(enter(initial));
const event = g.pendingAmbassador!.event;
g = move(g, {
forces: { 'red_chasm:7': 1 },
territory: 'sietch_tabr',
sector: 14,
});
g = applyAction(g, 'moritani', { type: 'decision', reveal: true });
assert.equal(g.pendingTerrorEntry?.stage, 'gift');
assert.equal(g.pendingAmbassador?.event, event);
assert.equal(g.discard.filter((c) => c.id === victim).length, 1);
for (const viewer of ['ec', 'in'])
assert.ok(!JSON.stringify(viewGame(g, viewer).log).includes(victim));
const saved = reload(g);
reject(g, { type: 'decision', reveal: true }, 'moritani');
g = applyAction(saved, 'moritani', { type: 'decision', card: gift });
assert.ok(p(g, 'a').hand.some((c) => c.id === gift));
assert.equal(g.pendingTerrorEntry, null);
assert.equal(g.discard.filter((c) => c.id === victim).length, 1);
assert.equal(p(g, 'a').forces['sietch_tabr:14'], 1);
assert.deepEqual(conserve(g), ids);
settled(g);
});
void test('the original entrants played two-group Ornithopter retains exact unmoved cohort through an ally relocation', () => {
const initial = fixture('atreides');
army(initial, 'a', { 'red_chasm:7': 2 });
army(initial, 'in', { 'imperial_basin:10': 2 });
initial.richeseCache = richeseCards();
const index = initial.richeseCache.findIndex(
(c) => c.effect === 'ornithopter',
);
const card = initial.richeseCache.splice(index, 1)[0];
assert.ok(card);
p(initial, 'in').hand.push(card);
const ids = conserve(initial);
let g = applyAction(initial, 'in', {
type: 'move',
forces: { 'imperial_basin:10': 1 },
territory: 'arrakeen',
sector: 10,
movementCard: card.id,
ornithopter: 'twoGroups',
});
assert.equal(g.pendingAmbassador?.stage, 'offer');
assert.equal(g.ornithopter?.completed, 1);
const flight = reload(g).ornithopter!;
g = trigger(g);
g = move(reload(g), {
forces: { 'red_chasm:7': 1 },
territory: 'sietch_tabr',
sector: 14,
});
assert.deepEqual(g.ornithopter, flight);
assert.equal(g.pendingAmbassador, null);
assert.equal(p(g, 'in').moved, 1);
assert.equal(p(g, 'a').moved, 0);
g = applyAction(reload(g), 'in', {
type: 'move',
forces: { 'imperial_basin:10': 1 },
territory: 'arrakeen',
sector: 10,
ornithopterEvent: flight.event,
});
assert.equal(g.ornithopter, null);
assert.equal(p(g, 'in').moved, 2);
assert.equal(p(g, 'in').forces['arrakeen:10'], 2);
assert.equal(g.discard.filter((c) => c.id === card.id).length, 1);
assert.deepEqual(conserve(g), ids);
settled(g);
});
void test('a summoned worm removing the last legal group completes the already committed relocation and defers remaining rides to Nexus', () => {
const initial = fixture('atreides', [], true, false, 'fremen');
Object.assign(initial, {
phase: 1,
active: null,
nexus: false,
wormRides: ['hagga_basin'],
decision: { kind: 'wormRide', player: 'in', territory: 'imperial_basin' },
});
army(initial, 'in', { 'imperial_basin:10': 2, 'hagga_basin:12': 1 });
army(initial, 'a', { 'red_chasm:7': 2 });
const karama = hold(initial, 'in', 'Karama');
let g = applyAction(initial, 'in', {
type: 'decision',
accept: true,
territory: 'arrakeen',
sector: 10,
forces: { 'imperial_basin:10': 1 },
});
g = trigger(g, 'a', true);
assert.equal(g.pendingAmbassador?.stage, 'move');
assert.ok(viewGame(g, 'a').ambassadorEntry?.movement?.sources.length);
g = applyAction(reload(g), 'in', {
type: 'card',
card: karama,
mode: 'special',
territory: 'red_chasm',
});
assert.equal(g.summonedWorm, null);
assert.equal(p(g, 'a').tanks, 2);
assert.equal(p(g, 'a').forces['red_chasm:7'] ?? 0, 0);
assert.equal(p(g, 'a').moved, 0);
assert.equal(g.pendingAmbassador, null);
assert.equal(g.decision, null);
assert.equal(g.nexus, true);
assert.deepEqual(g.wormRides, ['hagga_basin']);
assert.equal(g.discard.filter((c) => c.id === karama).length, 1);
assert.equal(p(g, 'in').specialKaramaUsed, true);
conserve(g);
assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
});
void test('Ecaz Occupy permits its unlocked BG ally to choose fighters beside Ecaz while accompaniment locks remain enforced', () => {
for (const locked of [false, true]) {
const initial = fixture('beneGesserit', ['atreides']);
army(initial, 'a', { 'red_chasm:7': 2 });
army(initial, 'ec', { 'red_chasm:7': 1, 'sietch_tabr:14': 1 });
army(initial, 'atreides', { 'sietch_tabr:14': 1 });
p(initial, 'a').advisors = {
red_chasm: locked ? { lockedTurn: initial.turn } : {},
};
hold(initial, 'in', 'Karama');
const g = trigger(enter(initial));
const action: Action = {
type: 'decision',
event: g.pendingAmbassador!.event,
forces: { 'red_chasm:7': 1 },
territory: 'sietch_tabr',
sector: 14,
fighters: true,
};
if (locked) {
reject(g, action);
const advisors = move(g, { ...action, fighters: false });
assert.ok(p(advisors, 'a').advisors?.sietch_tabr);
assert.equal(
p(advisors, 'a').advisors!.sietch_tabr.lockedTurn,
initial.turn,
);
settled(advisors);
continue;
}
const unlockedAdvisors = move(g, { ...action, fighters: false });
assert.deepEqual(p(unlockedAdvisors, 'a').advisors?.sietch_tabr, {});
assert.deepEqual(viewGame(unlockedAdvisors, 'a'), viewGame(reload(unlockedAdvisors), 'a'));
let done = applyAction(reload(g), 'a', action);
assert.equal(done.response?.kind, 'advisorFlip');
done = allow(reload(done));
assert.equal(p(done, 'a').advisors?.sietch_tabr, undefined);
assert.equal(p(done, 'a').forces['sietch_tabr:14'], 1);
assert.equal(p(done, 'ec').forces['sietch_tabr:14'], 1);
assert.equal(p(done, 'atreides').forces['sietch_tabr:14'], 1);
settled(done);
}
});