Files
Dune/tests/ecaz-entry.test.ts
T
Quintine 0e1a442dca Wire the Tleilaxu Ambassador force return
The last unimplemented Ambassador effect is now reachable. A placed Tleilaxu
token gives its beneficiary — the Ecaz owner or its ally — a private
ecazAmbassador decision at the new revival stage: one to four physical counters
return from the Tanks to reserves at zero cost, quoted by the existing
game/tleilaxu-ambassador-forces.ts, which also enforces the shared
Fedaykin/Sardaukar one-per-turn counter and keeps Ixian cyborgs uncapped.

The player view projects the maximum, Tanks and elite inventory and current
elite usage to the beneficiary only; the entry control offers a per-count
button and an explicit "Return no forces" decline; all four AI profiles choose
a legal return from the decision owner's own view or decline when the Tanks are
empty. Player-facing coverage text now lists Tleilaxu.

Kept gated exactly as recorded: the printed leader alternative, ordinary
force/free/leader allowance accounting around this independent grant,
dead-twice eligibility, and Tleilaxu free-income repeat scope. No ordinary
revival allowance, price, phase or income path changes.

Evidence: tests/tleilaxu-ambassador-engine.test.ts drives a genuine arrival into
a placed token and asserts the beneficiary-only projection, the typed return
with unchanged spice, the shared elite limit, malformed/over-maximum rejection
with immutability, the ordinary-only return, the decline path and the rendered
control. The four-profile ecaz-entry-bots loop now covers tleilaxu, and the
former unsupported-effect and copy-list pins were updated to the supported
behaviour rather than re-pinned. Offline suite 7,797/7,797 with typecheck and
lint clean. No game, seat, saved state or deployment changes.
2026-10-07 18:28:57 +11:00

478 lines
16 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 { createTerrorState, placeTerror } from '../game/moritani-terror';
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('unresolved simultaneous Terror or Guild income rejects the complete original entry atomically', () => {
for (const reaction of ['terror', 'guild'] as const) {
const g = ambassadorFixture();
if (reaction === 'terror') {
g.players.push(newPlayer('m', 'Moritani', 'moritani'));
const terror = createTerrorState(() => 0);
g.moritaniTerror = placeTerror(
terror,
terror.tokens[0].id,
'arrakeen',
1,
);
} else g.players.push(newPlayer('guild', 'Guild', 'guild'));
reject(g, 'in', ship);
}
});
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,
);
}
});