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.
This commit is contained in:
Quintine committed 2026-10-07 18:28:57 +11:00
1 parent 07c20ae806
commit 0e1a442dca
11 files changed
+503 -27

No files matched your search

+13
View File
@@ -1911,6 +1911,19 @@ function policyActions(g: GameView): Action[] {
me.noField.deployed.location.sector !== g.storm)
);
if (entry.stage === 'allianceReply') return [{ ...action, accept: true }];
if (entry.stage === 'revival') {
const revival = entry.revival;
if (!revival || revival.blocked || revival.maximum < 1)
return [{ ...action, decline: true }];
// Minimal legal participation: return as many Tanks counters as the
// printed maximum allows, spending the single elite option first.
const elite = Math.min(
revival.eliteTanks,
Math.max(0, 1 - revival.eliteRevived),
);
const forces = Math.min(revival.maximum, revival.tanks);
return [{ ...action, forces, elite: Math.min(elite, forces) }];
}
if (entry.stage === 'offer') {
if (entry.effect === 'ecaz')
return [
+93
View File
@@ -14,6 +14,7 @@ import {
type BattleLeaderSkill,
} from './leader-skill-combat';
import { quoteSukPhysicalRescue, quoteSukRescue, SukPhysicalRescueError, sukReceiptSignature, sukRescueOptions, type SukForceGroup, type SukPhysicalRescueQuote, type SukPhysicalRescueReceipt, type SukReserveDestinations, type SukRescueOption, type SukRescueReceipt } from './suk-graduate';
import { quoteTleilaxuAmbassadorForces, TleilaxuAmbassadorForceError } from './tleilaxu-ambassador-forces';
import { leaderSkillStrongholdCount, sandmasterVictorySpice } from './leader-skill-battle-board';
import { beginRihani, chooseRihaniDraw, finishRihani, validateRihani, type RihaniReceipt, type RihaniSkill } from './rihani-decipherer';
import { planetologistMovementModeSupported, planetologistLeader, planetologistRange, selectedOriginElites, groundMovementRange, type PlanetologistMovement } from './planetologist-movement';
@@ -1601,6 +1602,7 @@ export type Game = {
| 'bonus'
| 'move'
| 'ship'
| 'revival'
| 'arrival';
relocation?: {
order: FremenAmbassadorMove;
@@ -14660,6 +14662,7 @@ const implementedAmbassadorEffects: readonly AmbassadorEffect[] = [
'richese',
'fremen',
'guild',
'tleilaxu',
];
function returnAmbassadorsIn(g: Game, to: string, reason: string) {
for (const token of g.ecazAmbassadors?.tokens.filter(
@@ -16319,6 +16322,20 @@ function resolveAmbassadorEffect(g: Game) {
g.decision = { kind: 'ecazAmbassador', player: p.id };
return;
}
if (effect === 'tleilaxu') {
// Printed grant: "Revive one of your leaders or up to 4 of your forces for
// free." Only the physical force alternative is source-supported here; the
// leader alternative, ordinary allowance accounting and Tleilaxu income for
// this distinct event stay gated pending the recorded interpretations.
entry.stage = 'revival';
g.decision = { kind: 'ecazAmbassador', player: p.id };
log(
g,
`${p.name} may return up to four physical forces from the Tanks for free through the Tleilaxu Ambassador. Ordinary revival allowances and income are unchanged.`,
{ faction: 'tleilaxu', name: 'Tleilaxu Ambassador' },
);
return;
}
if (effect === 'emperor') {
p.spice += 5;
log(
@@ -16452,6 +16469,65 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
finishAmbassador(g);
return;
}
if (entry.stage === 'revival') {
requireRule(
Object.keys(action).every((key) =>
['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,