Files
Dune/tests/ecaz-entry-bots.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

263 lines
9.0 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,
viewGame,
type Game,
type GameView,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createAmbassadors,
validateAmbassadors,
type AmbassadorEffect,
} from '../game/ecaz-ambassadors';
function fixture(effect: AmbassadorEffect = 'emperor') {
const g = createGame('ECAZBOTS', newPlayer('ecaz', 'Ecaz', 'ecaz'));
g.players.push(
newPlayer(
'entrant',
'Entrant',
effect === 'atreides' ? 'emperor' : 'atreides',
),
newPlayer('ally', 'Ally', 'harkonnen'),
);
g.status = 'playing';
g.phase = 5;
g.turn = 2;
g.storm = 18;
g.order = ['entrant', 'ecaz', 'ally'];
g.active = 'entrant';
g.movementRemaining = [...g.order];
g.deck = baseDeck();
for (const p of g.players) {
p.hand = [];
p.forces = {};
p.reserves = 20;
p.spice = 10;
p.traitors = [p.leaders[0].id];
}
for (const p of g.players) {
for (const kind of p.id === 'entrant'
? ['projectile']
: ['worthless', 'shield']) {
const index = g.deck.findIndex((c) => c.kind === kind);
p.hand.push(g.deck.splice(index, 1)[0]);
}
}
const state = createAmbassadors(() => 0);
const cohortEffects =
effect === 'beneGesserit'
? ['beneGesserit', 'fremen', 'richese', 'guild', 'tleilaxu']
: [
effect,
...state.tokens
.map((t) => t.effect)
.filter((e) => e !== effect && e !== 'ecaz'),
].slice(0, 5);
state.cohort = state.tokens
.filter((t) => cohortEffects.includes(t.effect))
.map((t) => t.id);
for (const token of state.tokens) {
token.zone =
token.effect === 'ecaz' || state.cohort.includes(token.id)
? 'supply'
: 'pool';
token.location = null;
}
const token = state.tokens.find((t) => t.effect === effect)!;
token.zone = 'placed';
token.location = 'arrakeen';
validateAmbassadors(state);
g.ecazAmbassadors = state;
return g;
}
function enter(g: Game) {
return applyAction(g, 'entrant', {
type: 'ship',
amount: 1,
territory: 'arrakeen',
sector: 10,
});
}
function botView(
g: Game,
id: string,
difficulty: (typeof DIFFICULTIES)[number],
) {
const view = viewGame(g, id);
view.players.find((p) => p.id === id)!.bot = difficulty;
return view;
}
function choose(g: Game, difficulty: (typeof DIFFICULTIES)[number]) {
assert.equal(g.decision?.kind, 'ecazAmbassador');
const id = g.decision!.player;
const before = structuredClone(g);
const actions = botActions(botView(g, id, difficulty));
assert.equal(actions.length, 1, difficulty);
assert.equal(actions[0].event, g.pendingAmbassador!.event);
assert.deepEqual(g, before, 'policy must not mutate authoritative state');
return { action: actions[0], next: applyAction(g, id, actions[0]) };
}
function finish(g: Game, difficulty: (typeof DIFFICULTIES)[number]) {
for (let steps = 0; g.pendingAmbassador && steps < 4; steps++)
g = choose(JSON.parse(JSON.stringify(g)) as Game, difficulty).next;
assert.equal(
g.pendingAmbassador,
null,
`${difficulty}: bounded entry completion`,
);
return g;
}
void test('all four profiles legally resolve each supported ordinary effect from only the decision owner’s view', () => {
for (const difficulty of DIFFICULTIES)
for (const effect of [
'emperor',
'atreides',
'harkonnen',
'choam',
'ixians',
'tleilaxu',
] as const) {
const initial = enter(fixture(effect));
for (const id of ['entrant', 'ally'])
assert.deepEqual(botActions(botView(initial, id, difficulty)), []);
const done = finish(initial, difficulty);
assert.equal(done.players[1].reserves, 19);
assert.equal(done.players[1].spice, 9);
assert.equal(done.players[1].forces['arrakeen:10'], 1);
assert.equal(
done.ecazAmbassadors!.tokens.find((t) => t.effect === effect)!.zone,
'used',
);
if (effect === 'emperor') assert.equal(done.players[0].spice, 15);
if (effect === 'choam') {
assert.equal(done.players[0].spice, 13);
assert.equal(done.players[0].hand.length, 1);
}
if (effect === 'ixians') assert.equal(done.players[0].hand.length, 2);
if (effect === 'atreides' || effect === 'harkonnen') {
assert.equal(viewGame(done, 'ecaz').ambassadorInsights.length, 1);
assert.deepEqual(viewGame(done, 'entrant').ambassadorInsights, []);
assert.deepEqual(viewGame(done, 'ally').ambassadorInsights, []);
if (effect === 'atreides')
assert.deepEqual(
viewGame(done, 'ecaz').ambassadorInsights[0].cards,
initial.players[1].hand,
);
else
assert.equal(
viewGame(done, 'ecaz').ambassadorInsights[0].traitor,
initial.players[1].traitors[0],
);
}
for (const player of done.players)
assert.equal(
player.reserves +
player.tanks +
Object.values(player.forces).reduce((a, b) => a + b, 0),
20,
);
}
});
void test('the Tleilaxu force return is offered and declined when the beneficiary has nothing in the Tanks', () => {
for (const difficulty of DIFFICULTIES) {
const entered = enter(fixture('tleilaxu'));
const offered = choose(entered, difficulty).next;
assert.equal(offered.pendingAmbassador?.stage, 'revival');
assert.equal(offered.decision?.kind, 'ecazAmbassador');
const { action, next } = choose(offered, difficulty);
assert.equal(action.decline, true);
assert.equal(next.pendingAmbassador, null);
assert.equal(
next.ecazAmbassadors!.tokens.find((t) => t.effect === 'tleilaxu')!.zone,
'used',
);
assert.equal(next.players[0].spice, 10);
assert.equal(next.players[0].tanks, 0);
}
});
void test('Bene Gesserit copies choose only supported projected effects and complete without another acknowledgement', () => {
for (const difficulty of DIFFICULTIES) {
const entered = enter(fixture('beneGesserit'));
const copying = choose(entered, difficulty).next;
assert.equal(copying.pendingAmbassador?.stage, 'copy');
const view = botView(copying, 'ecaz', difficulty);
const action = botActions(view)[0];
assert.ok(
view.ambassadorEntry!.copies.some(
(option) => !option.blocked && option.effect === action.effect,
),
);
const done = finish(copying, difficulty);
assert.equal(
done.ecazAmbassadors!.tokens.find((t) => t.effect === 'beneGesserit')!
.zone,
'removed',
);
}
});
void test('an unavailable own Ixian effect can be assigned to an eligible ally whose own bot selects its private card', () => {
for (const difficulty of DIFFICULTIES) {
const g = fixture('ixians');
g.players[0].ally = 'ally';
g.players[2].ally = 'ecaz';
g.deck.push(...g.players[0].hand);
g.players[0].hand = [];
const entered = enter(g);
const { action, next } = choose(entered, difficulty);
assert.equal(action.beneficiary, 'ally');
assert.equal(next.decision?.player, 'ally');
assert.deepEqual(viewGame(next, 'ecaz').ambassadorEntry!.cards, []);
assert.deepEqual(botActions(botView(next, 'ecaz', difficulty)), []);
const done = finish(next, difficulty);
assert.equal(done.players[2].hand.length, 2);
assert.equal(done.players[0].hand.length, 0);
}
});
void test('card policies respect projected blocked IDs, CHOAM permits zero cards, and Ix chooses exactly one', () => {
for (const difficulty of DIFFICULTIES)
for (const effect of ['choam', 'ixians'] as const) {
const cards = choose(enter(fixture(effect)), difficulty).next;
const view = botView(cards, 'ecaz', difficulty);
const worthless = view.players[0].hand!.find(
(c) => c.kind === 'worthless',
)!;
view.ambassadorEntry!.cards.find(
(option) => option.card === worthless.id,
)!.blocked = 'Reserved card';
const action = botActions(view)[0];
assert.ok(Array.isArray(action.cards));
assert.ok(!action.cards.includes(worthless.id));
assert.equal(action.cards.length, effect === 'choam' ? 0 : 1);
assert.doesNotThrow(() => applyAction(cards, 'ecaz', action));
}
});
void test('entry policy is independent of opponent hand identities and unprojected balances', () => {
for (const difficulty of DIFFICULTIES) {
const g = enter(fixture('choam'));
const first = botView(g, 'ecaz', difficulty);
const changed = structuredClone(g);
changed.players[1].spice = 999;
changed.players[1].hand = baseDeck().slice(-4);
const second = botView(changed, 'ecaz', difficulty);
assert.equal(first.players[1].spice, undefined);
assert.equal(second.players[1].hand, undefined);
assert.deepEqual(botActions(first), botActions(second));
const empty: GameView = structuredClone(first);
empty.ambassadorEntry = null;
assert.deepEqual(botActions(empty), []);
}
});