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:
1 parent
07c20ae806
commit
0e1a442dca
11 files changed
+503
-27
No files matched your search
@@ -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 [
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user