884 lines
30 KiB
TypeScript
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);
|
|
}
|
|
});
|