Files
Dune/tests/ecaz-entry.test.ts
T

461 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import {
createAmbassadors,
placeAmbassador,
type AmbassadorEffect,
} from '../game/ecaz-ambassadors';
import type { FactionId } from '../game/catalog';
export function ambassadorFixture(
effect: AmbassadorEffect = 'emperor',
entrant: FactionId = 'harkonnen',
): Game {
const g = createGame('AMBENTRY', newPlayer('ec', 'Ecaz', 'ecaz'));
g.players.push(
newPlayer('in', 'Entrant', entrant),
newPlayer('al', 'Ally', 'atreides'),
);
Object.assign(g, {
status: 'playing',
phase: 5,
turn: 2,
storm: 18,
active: 'in',
order: ['in', 'ec', 'al'],
movementRemaining: ['in', 'ec', 'al'],
deck: baseDeck(),
});
for (const p of g.players)
Object.assign(p, { forces: {}, reserves: 20, spice: 20, hand: [] });
g.players[1].traitors = ['guild-1', 'emperor-2'];
const inventory = createAmbassadors(() => 0);
const chosen = inventory.tokens.find((t) => t.effect === effect)!;
const five = [
chosen,
...inventory.tokens.filter(
(t) =>
t.effect !== 'ecaz' && t.effect !== effect && t.effect !== 'harkonnen',
),
]
.filter((t) => t.effect !== 'ecaz')
.slice(0, 5);
inventory.cohort = five.map((t) => t.id);
for (const t of inventory.tokens) {
t.zone =
t.effect === 'ecaz' || inventory.cohort.includes(t.id)
? 'supply'
: 'pool';
t.location = null;
}
g.ecazAmbassadors = placeAmbassador(inventory, chosen.id, {
turn: 1,
availableSpice: 20,
destination: {
id: 'arrakeen',
stronghold: true,
inStorm: false,
allowed: true,
},
}).state;
return g;
}
const ship: Action = {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 2,
};
const enter = (g = ambassadorFixture()) => applyAction(g, 'in', ship);
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
const decide = (g: Game, id: string, extra: Omit<Action, 'type'>) =>
applyAction(g, id, {
type: 'decision',
event: g.pendingAmbassador!.event,
...extra,
});
const trigger = (g: Game, beneficiary = 'ec') =>
decide(g, 'ec', { trigger: true, beneficiary });
function reject(g: Game, id: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, id, action));
assert.deepEqual(g, before);
}
void test('committed entry interrupts remaining movement; decline retains token and never replays shipment', () => {
const g = enter();
assert.deepEqual(g.decision, { kind: 'ecazAmbassador', player: 'ec' });
assert.equal(g.players[1].spice, 18);
assert.equal(g.players[1].reserves, 18);
assert.equal(g.players[1].forces['arrakeen:10'], 2);
reject(g, 'in', { type: 'endMovement' });
reject(g, 'in', {
type: 'decision',
event: g.pendingAmbassador!.event,
decline: true,
});
reject(g, 'ec', { type: 'decision', event: 'old', decline: true });
const done = decide(reload(g), 'ec', { decline: true });
assert.deepEqual(done.players, g.players);
assert.deepEqual(done.ecazAmbassadors, g.ecazAmbassadors);
assert.equal(done.pendingAmbassador, null);
assert.equal(done.decision, null);
assert.equal(done.active, 'in');
reject(done, 'in', ship);
});
void test('Emperor credits the chosen current ally exactly once without a response acknowledgement', () => {
const initial = ambassadorFixture();
initial.players[0].ally = 'al';
initial.players[2].ally = 'ec';
const g = enter(initial);
assert.deepEqual(
viewGame(g, 'ec').ambassadorEntry!.beneficiaries.map((b) => b.player),
['ec', 'al'],
);
assert.deepEqual(viewGame(g, 'in').ambassadorEntry!.beneficiaries, []);
const result = trigger(reload(g), 'al');
assert.equal(result.players[2].spice, 25);
assert.equal(result.players[0].spice, 20);
assert.equal(result.decision, null);
assert.equal(result.response, null);
assert.equal(result.pendingAmbassador, null);
assert.equal(
result.ecazAmbassadors!.tokens.find((t) => t.effect === 'emperor')!.zone,
'used',
);
assert.equal(
result.log.filter((l) => l.automatic?.name === 'Emperor Ambassador').length,
1,
);
assert.deepEqual(normalizeAutomaticGame(reload(result)), reload(result));
reject(result, 'ec', {
type: 'decision',
event: g.pendingAmbassador!.event,
trigger: true,
beneficiary: 'al',
});
reject(g, 'ec', {
type: 'decision',
event: g.pendingAmbassador!.event,
trigger: true,
beneficiary: 'in',
});
});
void test('Atreides snapshots only the beneficiary’s inspected hand, survives reload and never tracks later cards', () => {
const initial = ambassadorFixture('atreides');
initial.players[0].ally = 'al';
initial.players[2].ally = 'ec';
initial.players[1].hand = initial.deck.splice(0, 3);
const result = trigger(enter(initial), 'al');
assert.equal(result.decision, null);
assert.deepEqual(
viewGame(result, 'al').ambassadorInsights[0].cards,
initial.players[1].hand,
);
assert.deepEqual(viewGame(result, 'ec').ambassadorInsights, []);
assert.deepEqual(viewGame(result, 'in').ambassadorInsights, []);
assert.ok(
!JSON.stringify(result.log).includes(initial.players[1].hand[0].id),
);
result.players[1].hand = [];
assert.equal(
viewGame(reload(result), 'al').ambassadorInsights[0].cards.length,
3,
);
});
void test('Harkonnen samples a held Traitor only once, including the Tleilaxu physical Face Dancer pool', () => {
for (const target of ['emperor', 'tleilaxu'] as const) {
const initial = ambassadorFixture('harkonnen', target);
if (target === 'tleilaxu') {
initial.players[1].traitors = [];
initial.players[1].faceDancers = [
{ leader: 'guild-1', revealed: true },
{ leader: 'emperor-2', revealed: false },
];
}
const done = trigger(enter(initial));
const insight = viewGame(done, 'ec').ambassadorInsights[0];
assert.ok(['guild-1', 'emperor-2'].includes(insight.traitor!));
assert.deepEqual(insight.cards, []);
assert.deepEqual(
viewGame(normalizeAutomaticGame(reload(done)), 'ec')
.ambassadorInsights[0],
insight,
);
assert.deepEqual(viewGame(done, 'al').ambassadorInsights, []);
assert.deepEqual(viewGame(done, 'in').ambassadorInsights, []);
assert.ok(!JSON.stringify(done.log).includes(insight.traitor!));
}
});
void test('CHOAM beneficiary owns its card choice; duplicate or foreign IDs cannot discard or mint spice', () => {
const initial = ambassadorFixture('choam');
initial.players[0].ally = 'al';
initial.players[2].ally = 'ec';
initial.players[2].hand = initial.deck.splice(0, 3);
const g = trigger(enter(initial), 'al');
assert.equal(g.decision?.player, 'al');
assert.equal(g.pendingAmbassador?.stage, 'cards');
assert.deepEqual(viewGame(g, 'ec').ambassadorEntry!.cards, []);
const ids = initial.players[2].hand.slice(0, 2).map((c) => c.id);
for (const cards of [[ids[0], ids[0]], ['missing']])
reject(g, 'al', {
type: 'decision',
event: g.pendingAmbassador!.event,
cards,
});
reject(g, 'ec', {
type: 'decision',
event: g.pendingAmbassador!.event,
cards: ids,
});
const done = decide(reload(g), 'al', { cards: ids });
assert.equal(done.players[2].hand.length, 1);
assert.equal(done.players[2].spice, 26);
assert.deepEqual(
done.discard.map((c) => c.id),
ids,
);
assert.equal(done.decision, null);
const zero = decide(g, 'al', { cards: [] });
assert.equal(zero.players[2].spice, 20);
assert.equal(zero.players[2].hand.length, 3);
});
void test('Ixian discards before recycling an empty deck and never spends another player’s choice', () => {
const initial = ambassadorFixture('ixians');
initial.players[0].hand = [initial.deck[0]];
initial.deck = [];
initial.discard = [];
const g = trigger(enter(initial));
assert.equal(g.pendingAmbassador?.stage, 'cards');
reject(g, 'ec', {
type: 'decision',
event: g.pendingAmbassador!.event,
cards: [],
});
const done = decide(reload(g), 'ec', {
cards: [initial.players[0].hand[0].id],
});
assert.deepEqual(done.players[0].hand, initial.players[0].hand);
assert.deepEqual(done.discard, []);
assert.deepEqual(done.deck, []);
assert.equal(done.players[0].spice, 20);
assert.equal(done.decision, null);
const empty = enter(ambassadorFixture('ixians'));
assert.ok(viewGame(empty, 'ec').ambassadorEntry!.beneficiaries[0].blocked);
reject(empty, 'ec', {
type: 'decision',
event: empty.pendingAmbassador!.event,
trigger: true,
beneficiary: 'ec',
});
});
void test('BG copies outside original cohort, can inspect matching Harkonnen, and permanently removes only BG', () => {
const initial = ambassadorFixture('beneGesserit');
const beforeHark = structuredClone(
initial.ecazAmbassadors!.tokens.find((t) => t.effect === 'harkonnen'),
);
const g = trigger(enter(initial));
assert.equal(g.pendingAmbassador?.stage, 'copy');
assert.ok(
viewGame(g, 'ec').ambassadorEntry!.copies.some(
(c) => c.effect === 'harkonnen' && !c.blocked,
),
);
assert.deepEqual(viewGame(g, 'in').ambassadorEntry!.copies, []);
reject(g, 'ec', {
type: 'decision',
event: g.pendingAmbassador!.event,
effect: 'ecaz',
});
const done = decide(reload(g), 'ec', { effect: 'harkonnen' });
assert.equal(
done.ecazAmbassadors!.tokens.find((t) => t.effect === 'beneGesserit')!.zone,
'removed',
);
assert.deepEqual(
done.ecazAmbassadors!.tokens.find((t) => t.effect === 'harkonnen'),
beforeHark,
);
assert.ok(done.ambassadorInsights![0].traitor);
});
void test('fifth Ambassador recycles only after the beneficiary completes the card effect', () => {
const initial = ambassadorFixture('choam');
initial.players[0].hand = initial.deck.splice(0, 2);
for (const t of initial.ecazAmbassadors!.tokens)
if (initial.ecazAmbassadors!.cohort.includes(t.id) && t.effect !== 'choam')
t.zone = t.effect === 'beneGesserit' ? 'removed' : 'used';
const old = [...initial.ecazAmbassadors!.cohort];
const g = trigger(enter(initial));
assert.deepEqual(g.ecazAmbassadors!.cohort, old);
assert.equal(
g.ecazAmbassadors!.tokens.filter(
(t) => t.zone === 'supply' && t.effect !== 'ecaz',
).length,
0,
);
const done = decide(reload(g), 'ec', { cards: [] });
assert.equal(
done.ecazAmbassadors!.tokens.filter(
(t) => t.zone === 'supply' && t.effect !== 'ecaz',
).length,
5,
);
assert.equal(
done.log.filter((l) => l.text.startsWith('All five random Ambassadors'))
.length,
1,
);
});
void test('self, ally, advisors and matching physical faction entries do not open an Ambassador opportunity', () => {
for (const variant of ['self', 'ally', 'matching', 'advisors'] as const) {
const g = ambassadorFixture(
'emperor',
variant === 'advisors'
? 'beneGesserit'
: variant === 'matching'
? 'emperor'
: 'harkonnen',
);
if (variant === 'ally') {
g.players[0].ally = 'in';
g.players[1].ally = 'ec';
}
if (variant === 'advisors') {
g.advanced = true;
g.players[0].forces = { 'arrakeen:10': 1 };
g.players[0].reserves = 19;
g.players[1].forces = { 'arrakeen:10': 1 };
g.players[1].reserves = 19;
g.players[1].advisors = { arrakeen: {} };
}
if (variant === 'self') g.active = 'ec';
const done = applyAction(g, variant === 'self' ? 'ec' : 'in', {
...ship,
...(variant === 'advisors' ? { advisors: true } : {}),
});
assert.equal(done.pendingAmbassador ?? null, null);
assert.notEqual(done.decision?.kind, 'ecazAmbassador');
}
});
void test('the Tleilaxu effect is offered to an eligible beneficiary and returns forces without consuming its token early', () => {
for (const effect of ['tleilaxu'] as const) {
const g = enter(ambassadorFixture(effect));
assert.equal(
viewGame(g, 'ec').ambassadorEntry!.beneficiaries[0].blocked,
null,
);
const offered = decide(g, 'ec', {
trigger: true,
beneficiary: 'ec',
choice: 'effect',
});
assert.equal(offered.pendingAmbassador?.stage, 'revival');
assert.equal(offered.decision?.player, 'ec');
assert.equal(viewGame(offered, 'ec').ambassadorEntry!.revival?.maximum, 0);
const done = decide(offered, 'ec', { decline: true });
assert.equal(done.pendingAmbassador, null);
assert.equal(
done.ecazAmbassadors!.tokens.find((t) => t.effect === effect)!.zone,
'used',
);
}
});
void test('exposed city storm returns Ambassadors to supply without triggering or advancing their cohort', () => {
for (const exposed of [false, true]) {
let g = ambassadorFixture('beneGesserit');
g.phase = 0;
g.storm = 9;
g.stormPending = 2;
g.shieldWallDestroyed = exposed;
const cohort = [...g.ecazAmbassadors!.cohort];
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
const bg = g.ecazAmbassadors!.tokens.find(
(t) => t.effect === 'beneGesserit',
)!;
assert.equal(bg.zone, exposed ? 'supply' : 'placed');
assert.equal(bg.location, exposed ? null : 'arrakeen');
assert.deepEqual(g.ecazAmbassadors!.cohort, cohort);
assert.deepEqual(g.ambassadorInsights ?? [], []);
assert.equal(
g.log.filter((l) => l.text.includes('This was destruction')).length,
exposed ? 1 : 0,
);
}
});
void test('Lasgun–shield explosion returns an Ambassador, while overriding traitor victory leaves it placed', () => {
for (const traitor of [false, true]) {
let g = ambassadorFixture('emperor');
g.phase = 6;
g.active = 'ec';
g.players[0].forces = { 'arrakeen:10': 3 };
g.players[0].reserves = 17;
g.players[1].forces = { 'arrakeen:10': 3 };
g.players[1].reserves = 17;
const lasgun = g.deck.splice(
g.deck.findIndex((c) => c.kind === 'lasgun'),
1,
)[0];
const shield = g.deck.splice(
g.deck.findIndex((c) => c.kind === 'shield'),
1,
)[0];
g.players[0].hand = [lasgun];
g.players[1].hand = [shield];
g.players[0].traitors = traitor ? ['harkonnen-0'] : [];
g.players[1].traitors = [];
g.battle = {
territory: 'arrakeen',
attacker: 'ec',
defender: 'in',
prepared: true,
plans: {},
revealed: false,
traitorCalls: {},
};
g = applyAction(g, 'ec', {
type: 'battlePlan',
dial: 0,
leader: 'ecaz-0',
weapon: lasgun.id,
});
g = applyAction(g, 'in', {
type: 'battlePlan',
dial: 0,
leader: 'harkonnen-0',
defense: shield.id,
});
g = applyAction(g, 'ec', { type: 'traitorCall', call: traitor });
g = applyAction(g, 'in', { type: 'traitorCall', call: false });
const token = g.ecazAmbassadors!.tokens.find(
(t) => t.effect === 'emperor',
)!;
assert.equal(token.zone, traitor ? 'placed' : 'supply');
assert.equal(
g.log.filter((l) => l.text.includes('This was destruction')).length,
traitor ? 0 : 1,
);
}
});