From 0e1a442dcab5852c1c9d940b313acc20c2bf7e42 Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Wed, 7 Oct 2026 18:28:57 +1100
Subject: [PATCH] Wire the Tleilaxu Ambassador force return
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
components/ecaz-ambassadors.tsx | 2 +-
components/ecaz-entry.tsx | 55 +++++-
docs/CURRENT_STATUS.md | 12 ++
docs/IMPLEMENTATION_STATUS.md | 29 +++
docs/TLEILAXU_AMBASSADOR_RULES.md | 38 +++-
game/bots.ts | 13 ++
game/engine.ts | 93 +++++++++
tests/ecaz-entry-bots.test.ts | 30 +--
tests/ecaz-entry-continuations.test.ts | 4 +-
tests/ecaz-entry.test.ts | 24 ++-
tests/tleilaxu-ambassador-engine.test.ts | 230 +++++++++++++++++++++++
11 files changed, 503 insertions(+), 27 deletions(-)
create mode 100644 tests/tleilaxu-ambassador-engine.test.ts
diff --git a/components/ecaz-ambassadors.tsx b/components/ecaz-ambassadors.tsx
index 21016452..cf4e8a8b 100644
--- a/components/ecaz-ambassadors.tsx
+++ b/components/ecaz-ambassadors.tsx
@@ -23,7 +23,7 @@ import { TERRITORIES } from '@/game/board';
import { faction } from '@/game/catalog';
export const AMBASSADOR_COVERAGE =
- 'Development coverage: Ambassador placement, Ecaz’s direct Duke acquisition and consensual alliance, Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen and Guild entry effects are supported, plus Bene Gesserit copies of those eight ordinary effects. Duke loan choices, other effects and some competing arrival reactions remain unfinished; full Ecaz starts remain disabled.';
+ 'Development coverage: Ambassador placement, Ecaz’s direct Duke acquisition and consensual alliance, Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen, Guild and Tleilaxu entry effects are supported, plus Bene Gesserit copies of those eight ordinary effects. The Tleilaxu effect returns physical forces only; its printed leader alternative and ordinary revival accounting remain unfinished. Duke loan choices, other effects and some competing arrival reactions remain unfinished; full Ecaz starts remain disabled.';
function tokenLocation(token: AmbassadorToken) {
if (token.zone === 'placed')
return `Placed in ${TERRITORIES.find((t) => t.id === token.location)?.name ?? 'a stronghold'}`;
diff --git a/components/ecaz-entry.tsx b/components/ecaz-entry.tsx
index 597aa1c7..d51f309a 100644
--- a/components/ecaz-entry.tsx
+++ b/components/ecaz-entry.tsx
@@ -32,7 +32,7 @@ export function EcazEntry({
!entry ||
game.decision?.kind !== 'ecazAmbassador' ||
game.decision.player !== game.me ||
- !['offer', 'allianceReply', 'copy', 'cards', 'move', 'ship'].includes(
+ !['offer', 'allianceReply', 'copy', 'cards', 'move', 'ship', 'revival'].includes(
entry.stage,
)
)
@@ -254,6 +254,59 @@ export function EcazEntry({
);
})}
>
+ ) : entry.stage === 'revival' ? (
+ <>
+
+ Return up to {entry.revival?.maximum ?? 0} physical{' '}
+ {(entry.revival?.maximum ?? 0) === 1 ? 'force' : 'forces'} from the
+ Tanks to your reserves for free. Ordinary revival allowances,
+ prices and income are unchanged. The printed leader alternative
+ stays unavailable pending its recorded interpretation.
+
+ {entry.revival?.blocked && (
+ {entry.revival.blocked}
+ )}
+ {!entry.revival?.blocked &&
+ (entry.revival?.maximum ?? 0) > 0 && (
+
+ Forces to return
+ {Array.from(
+ { length: entry.revival!.maximum },
+ (_, index) => index + 1,
+ ).map((count) => (
+
+ send({
+ forces: count,
+ elite: Math.min(
+ entry.revival!.eliteTanks,
+ Math.max(0, 1 - entry.revival!.eliteRevived),
+ count,
+ ),
+ })
+ }
+ >
+ Return {count} {count === 1 ? 'force' : 'forces'}
+
+ ))}
+
+ )}
+ send({ decline: true })}
+ >
+ Return no forces
+
+ >
) : entry.stage === 'ship' ? (
+ ['type', 'event', 'decline', 'forces', 'elite'].includes(key),
+ ),
+ 'Choose a physical force return or decline it.',
+ );
+ if (action.decline === true) {
+ log(
+ g,
+ `${p.name} returned no forces through the Tleilaxu Ambassador.`,
+ { faction: 'tleilaxu', name: 'Tleilaxu Ambassador' },
+ );
+ finishAmbassador(g);
+ return;
+ }
+ const forces = integer(action.forces, 1, 4, 'Forces to return');
+ const elite =
+ action.elite === undefined
+ ? undefined
+ : integer(action.elite, 0, 4, 'Special forces to return');
+ let quote;
+ try {
+ quote = quoteTleilaxuAmbassadorForces(
+ {
+ faction: p.faction,
+ reserves: p.reserves,
+ tanks: p.tanks,
+ elites: p.elites
+ ? {
+ reserves: p.elites.reserves,
+ tanks: p.elites.tanks,
+ revived: p.elites.revived,
+ }
+ : undefined,
+ },
+ forces,
+ elite,
+ );
+ } catch (error) {
+ if (error instanceof TleilaxuAmbassadorForceError)
+ throw new RuleError(error.message);
+ throw error;
+ }
+ p.reserves = quote.after.reserves;
+ p.tanks = quote.after.tanks;
+ if (p.elites) {
+ p.elites.reserves = quote.after.eliteReserves;
+ p.elites.tanks = quote.after.eliteTanks;
+ p.elites.revived = quote.eliteRevivedNext;
+ }
+ log(
+ g,
+ `${p.name} returned ${quote.amount} physical ${quote.amount === 1 ? 'force' : 'forces'} (${quote.elite} special) from the Tanks to its reserves for free through the Tleilaxu Ambassador. Ordinary revival allowances, prices and income are unchanged.`,
+ { faction: 'tleilaxu', name: 'Tleilaxu Ambassador' },
+ );
+ finishAmbassador(g);
+ return;
+ }
if (entry.stage === 'offer') {
const token = g.ecazAmbassadors?.tokens.find((t) => t.id === entry.token);
requireRule(
@@ -31964,6 +32040,23 @@ export function viewGame(state: Game, id: string) {
blocked: ambassadorDiscardBlock(g, me, card, entry.effect),
}))
: [],
+ revival:
+ entry.stage === 'revival' && id === entry.beneficiary
+ ? {
+ maximum: Math.min(4, me.tanks),
+ tanks: me.tanks,
+ eliteTanks: me.elites?.tanks ?? 0,
+ eliteRevived: me.elites?.revived ?? 0,
+ leaderAlternative: false,
+ blocked:
+ ambassadorEffectBlock(
+ g,
+ 'tleilaxu',
+ me,
+ entrant,
+ ) ?? null,
+ }
+ : null,
};
})()
: null,
diff --git a/tests/ecaz-entry-bots.test.ts b/tests/ecaz-entry-bots.test.ts
index ec29822b..b7368a8f 100644
--- a/tests/ecaz-entry-bots.test.ts
+++ b/tests/ecaz-entry-bots.test.ts
@@ -123,6 +123,7 @@ void test('all four profiles legally resolve each supported ordinary effect from
'harkonnen',
'choam',
'ixians',
+ 'tleilaxu',
] as const) {
const initial = enter(fixture(effect));
for (const id of ['entrant', 'ally'])
@@ -166,19 +167,22 @@ void test('all four profiles legally resolve each supported ordinary effect from
}
});
-void test('unsupported effects are explicitly declined without consuming the placed token at every difficulty', () => {
- for (const difficulty of DIFFICULTIES)
- for (const effect of ['tleilaxu'] as const) {
- const entered = enter(fixture(effect));
- const { action, next } = choose(entered, difficulty);
- assert.equal(action.decline, true);
- assert.equal(next.pendingAmbassador, null);
- assert.equal(
- next.ecazAmbassadors!.tokens.find((t) => t.effect === effect)!.zone,
- 'placed',
- );
- assert.equal(next.players[0].spice, 10);
- }
+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', () => {
diff --git a/tests/ecaz-entry-continuations.test.ts b/tests/ecaz-entry-continuations.test.ts
index 7a00257b..4e3353da 100644
--- a/tests/ecaz-entry-continuations.test.ts
+++ b/tests/ecaz-entry-continuations.test.ts
@@ -210,7 +210,7 @@ void test('unselected BG copy permits spending its final Ixian discard while a F
.map((choice) => choice.effect)
.sort();
assert.equal(g.pendingAmbassador?.stage, 'copy');
- assert.deepEqual(copies(g), ['fremen', 'guild', 'ixians']);
+ assert.deepEqual(copies(g), ['fremen', 'guild', 'ixians', 'tleilaxu']);
assert.equal(
g.ecazAmbassadors!.tokens.find(
(token) => token.effect === 'beneGesserit',
@@ -249,7 +249,7 @@ void test('unselected BG copy permits spending its final Ixian discard while a F
assert.equal(g.pendingAmbassador?.event, event);
assert.equal(g.pendingAmbassador?.stage, 'copy');
assert.deepEqual(g.players[1].hand, []);
- assert.deepEqual(copies(g), ['fremen', 'guild']);
+ assert.deepEqual(copies(g), ['fremen', 'guild', 'tleilaxu']);
assert.equal(g.discard.filter((held) => held.id === card).length, 1);
if (karama)
assert.equal(g.discard.filter((held) => held.id === karama).length, 1);
diff --git a/tests/ecaz-entry.test.ts b/tests/ecaz-entry.test.ts
index 3e6f86ff..ec08067b 100644
--- a/tests/ecaz-entry.test.ts
+++ b/tests/ecaz-entry.test.ts
@@ -374,21 +374,27 @@ void test('unresolved simultaneous Terror or Guild income rejects the complete o
}
});
-void test('incomplete effect remains visibly unavailable and can be declined without consuming its token', () => {
+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.match(
- viewGame(g, 'ec').ambassadorEntry!.beneficiaries[0].blocked!,
- /implemented/,
+ assert.equal(
+ viewGame(g, 'ec').ambassadorEntry!.beneficiaries[0].blocked,
+ null,
);
- reject(g, 'ec', {
- type: 'decision',
- event: g.pendingAmbassador!.event,
+ const offered = decide(g, 'ec', {
trigger: true,
beneficiary: 'ec',
+ choice: 'effect',
});
- const done = decide(g, 'ec', { decline: true });
- assert.deepEqual(done.ecazAmbassadors, g.ecazAmbassadors);
+ 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',
+ );
}
});
diff --git a/tests/tleilaxu-ambassador-engine.test.ts b/tests/tleilaxu-ambassador-engine.test.ts
new file mode 100644
index 00000000..2726142b
--- /dev/null
+++ b/tests/tleilaxu-ambassador-engine.test.ts
@@ -0,0 +1,230 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ applyAction,
+ createGame,
+ newPlayer,
+ viewGame,
+ type Game,
+} from '../game/engine';
+import { baseDeck } from '../game/cards';
+import { createAmbassadors, validateAmbassadors } from '../game/ecaz-ambassadors';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { registerHooks } from 'node:module';
+
+/** A genuine Ecaz arrival whose Tleilaxu token is assigned to an elite ally. */
+function fixture() {
+ const g = createGame('TLEIAMBA', newPlayer('ecaz', 'Ecaz', 'ecaz'));
+ g.players.push(
+ newPlayer('entrant', 'Entrant', 'atreides'),
+ newPlayer('ally', 'Ally', 'emperor'),
+ );
+ Object.assign(g, {
+ status: 'playing',
+ phase: 5,
+ turn: 2,
+ storm: 18,
+ order: ['entrant', 'ecaz', 'ally'],
+ 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];
+ }
+ const ecaz = g.players[0], ally = g.players[2];
+ ecaz.ally = ally.id;
+ ally.ally = ecaz.id;
+ ally.tanks = 3;
+ ally.reserves = 15;
+ ally.elites = { reserves: 2, tanks: 1, forces: {}, revived: 0 };
+ const state = createAmbassadors(() => 0);
+ const token = state.tokens.find((t) => t.effect === 'tleilaxu')!;
+ const cohortEffects = [
+ 'tleilaxu',
+ ...state.tokens
+ .map((t) => t.effect)
+ .filter((e) => e !== 'tleilaxu' && e !== 'ecaz'),
+ ].slice(0, 5);
+ state.cohort = state.tokens
+ .filter((t) => cohortEffects.includes(t.effect))
+ .map((t) => t.id);
+ for (const other of state.tokens) {
+ other.zone =
+ other.effect === 'ecaz' || state.cohort.includes(other.id)
+ ? 'supply'
+ : 'pool';
+ other.location = null;
+ }
+ token.zone = 'placed';
+ token.location = 'arrakeen';
+ validateAmbassadors(state);
+ g.ecazAmbassadors = state;
+ return g;
+}
+const ship = (g: Game) =>
+ applyAction(g, 'entrant', { type: 'ship', amount: 1, territory: 'arrakeen', sector: 10 });
+const trigger = (g: Game) => {
+ const event = g.pendingAmbassador!.event;
+ return applyAction(g, 'ecaz', {
+ type: 'decision',
+ event,
+ trigger: true,
+ beneficiary: 'ally',
+ choice: 'effect',
+ });
+};
+const counters = (g: Game, id: string) => {
+ const p = g.players.find((seat) => seat.id === id)!;
+ return {
+ reserves: p.reserves,
+ tanks: p.tanks,
+ eliteReserves: p.elites?.reserves ?? 0,
+ eliteTanks: p.elites?.tanks ?? 0,
+ eliteRevived: p.elites?.revived ?? 0,
+ spice: p.spice,
+ };
+};
+
+void test('the Tleilaxu Ambassador returns typed forces from the Tanks for free and spends the shared elite option', () => {
+ const offered = trigger(ship(fixture()));
+ assert.equal(offered.pendingAmbassador?.stage, 'revival');
+ assert.equal(offered.pendingAmbassador?.effect, 'tleilaxu');
+ assert.equal(offered.decision?.kind, 'ecazAmbassador');
+ assert.equal(offered.decision?.player, 'ally');
+ const view = viewGame(offered, 'ally').ambassadorEntry!;
+ assert.deepEqual(view.revival, {
+ maximum: 3,
+ tanks: 3,
+ eliteTanks: 1,
+ eliteRevived: 0,
+ leaderAlternative: false,
+ blocked: null,
+ });
+ assert.equal(viewGame(offered, 'ecaz').ambassadorEntry!.revival, null);
+ assert.equal(viewGame(offered, 'entrant').ambassadorEntry!.revival, null);
+ const before = counters(offered, 'ally');
+ const event = offered.pendingAmbassador!.event;
+ // A second elite return is rejected by the shared one-per-turn limit.
+ const spent = structuredClone(offered);
+ spent.players.find((p) => p.id === 'ally')!.elites!.revived = 1;
+ assert.throws(
+ () =>
+ applyAction(spent, 'ally', {
+ type: 'decision',
+ event,
+ forces: 1,
+ elite: 1,
+ }),
+ /Only one Fedaykin or Sardaukar may be revived per turn/,
+ );
+ assert.deepEqual(spent.players.find((p) => p.id === 'ally')!.tanks, 3);
+ for (const bad of [
+ { forces: 0 },
+ { forces: 5 },
+ { forces: 2, elite: 1.5 },
+ ]) {
+ const copy = structuredClone(offered);
+ assert.throws(() =>
+ applyAction(copy, 'ally', { type: 'decision', event, ...bad }),
+ );
+ assert.deepEqual(copy, offered);
+ }
+ const returned = applyAction(offered, 'ally', {
+ type: 'decision',
+ event,
+ forces: 2,
+ elite: 1,
+ });
+ assert.deepEqual(counters(returned, 'ally'), {
+ reserves: before.reserves + 2,
+ tanks: before.tanks - 2,
+ eliteReserves: before.eliteReserves + 1,
+ eliteTanks: before.eliteTanks - 1,
+ eliteRevived: 1,
+ spice: before.spice,
+ });
+ assert.equal(returned.pendingAmbassador, null);
+ assert.equal(
+ returned.ecazAmbassadors!.tokens.find((t) => t.effect === 'tleilaxu')!.zone,
+ 'used',
+ );
+ assert.equal(
+ returned.log.some((line) =>
+ line.text.includes('Tleilaxu Ambassador') &&
+ line.text.includes('Ordinary revival allowances'),
+ ),
+ true,
+ );
+});
+
+void test('the Tleilaxu Ambassador can return only ordinary forces and can return none at all', () => {
+ const offered = trigger(ship(fixture()));
+ const event = offered.pendingAmbassador!.event;
+ const ordinary = applyAction(offered, 'ally', {
+ type: 'decision',
+ event,
+ forces: 2,
+ elite: 0,
+ });
+ const ally = ordinary.players.find((p) => p.id === 'ally')!;
+ assert.equal(ally.tanks, 1);
+ assert.equal(ally.reserves, 17);
+ assert.equal(ally.elites!.tanks, 1);
+ assert.equal(ally.elites!.revived, 0);
+ assert.equal(ally.spice, 10);
+ const fresh = trigger(ship(fixture()));
+ const declined = applyAction(fresh, 'ally', {
+ type: 'decision',
+ event: fresh.pendingAmbassador!.event,
+ decline: true,
+ });
+ assert.equal(declined.pendingAmbassador, null);
+ const seat = declined.players.find((p) => p.id === 'ally')!;
+ assert.equal(seat.tanks, 3);
+ assert.equal(seat.reserves, 15);
+});
+
+void test('the entry control offers each legal count, the elite-aware return and an explicit decline', async () => {
+ const aliases = registerHooks({
+ resolve(specifier, context, next) {
+ return next(
+ specifier === 'next/image' ? 'vinext/shims/image' : specifier,
+ context,
+ );
+ },
+ });
+ const { EcazEntry } = await import('../components/ecaz-entry');
+ aliases.deregister();
+ const offered = trigger(ship(fixture()));
+ const view = viewGame(offered, 'ally') as unknown as Parameters[0]['game'];
+ const actions: unknown[] = [];
+ const markup = renderToStaticMarkup(
+ createElement(EcazEntry, {
+ game: view,
+ act: (action: unknown) => actions.push(action),
+ busy: false,
+ }),
+ );
+ for (const label of ['Return 1 force', 'Return 2 forces', 'Return 3 forces', 'Return no forces'])
+ assert.ok(markup.includes(label), label);
+ assert.ok(markup.includes('Tleilaxu Ambassador'));
+ assert.ok(markup.includes('leader alternative'));
+ const blocked = structuredClone(offered);
+ blocked.players.find((p) => p.id === 'ally')!.tanks = 0;
+ const blockedView = viewGame(blocked, 'ally') as unknown as Parameters[0]['game'];
+ const blockedMarkup = renderToStaticMarkup(
+ createElement(EcazEntry, {
+ game: blockedView,
+ act: (action: unknown) => actions.push(action),
+ busy: false,
+ }),
+ );
+ assert.equal(blockedMarkup.includes('Return 1 force'), false);
+ assert.ok(blockedMarkup.includes('Return no forces'));
+});