diff --git a/components/ecaz-entry.tsx b/components/ecaz-entry.tsx
index 619f44e7..daa07090 100644
--- a/components/ecaz-entry.tsx
+++ b/components/ecaz-entry.tsx
@@ -285,7 +285,10 @@ export function EcazEntry({
Revive one eligible leader or return up to {entry.revival?.maximum ?? 0}
{' '}physical forces for free. This independent grant leaves ordinary
- allowances and income unchanged provisionally. Ordinary leaders and
+ allowances unchanged provisionally. When Tleilaxu is seated, a
+ positive return can earn it one bank spice, sharing the beneficiary’s
+ once-per-turn free-revival reward. Canceling income keeps the return.
+ This income interpretation is provisional. Ordinary leaders and
Atreides’ Kwisatz Haderach retain their first-death restriction.
Other revival interactions remain under development.
diff --git a/components/game-table.tsx b/components/game-table.tsx
index 212bce0e..56317bd7 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -2611,6 +2611,15 @@ export function GameTable({
response.
)}
+ {g.response.source === 'ambassadorRevival' && (
+
+ The free Ambassador revival is already complete. Canceling
+ Tleilaxu’s bank income leaves the revived pieces in play.
+ Provisional rule: one free-revival reward per beneficiary
+ per turn, shared with ordinary free revival. Ghola cards
+ retain their separate income.
+
+ )}
{g.response.source === 'ambassador' && (
The Richese Ambassador purchase is already paid and its card
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index fd7cb74a..81f1fd7e 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -24,16 +24,27 @@ polling and a lobby action; Continue left the room intact. Focused tests pass
The live site still reports older revision `716304b`; the fix is pushed but
awaits deployment. [Exact evidence and limits](READABLE_NOTICES.md#verification).
-**Queued next — Tleilaxu income from Ambassador revivals, 24 October.** Connect
-positive free force/leader returns to the existing per-beneficiary/turn free
-income ledger under the explicit provisional interpretation. Decline earns0;
-Ghola stays independent. A committed-return receipt and distinct revival marker
-bind the native income response so allow/cancel resumes the original Ambassador
-once without repeating the revival. Root owns engine, response-context, controls,
-regressions and docs/reference; proposed disjoint lanes own only a pure receipt
-helper/test and original-action source fixture. The source review is complete;
-implementation was deferred for the user's notice report. No new module or
-assurance campaign.
+**Tleilaxu Ambassador income — 24 October. Prototyped / Partial.** Positive
+free force/leader returns now share the actual beneficiary's once-per-turn free
+income ledger. This selected source composition remains explicitly provisional;
+Ghola stays independent and decline earns nothing. A committed-return receipt
+binds the distinct income response. Allow/cancel finishes the original Ambassador
+once; canceling the bank spice keeps the return and spent entitlement. Persistent
+controls explain both the provisional reward and the already-completed return.
+
+A disjoint receipt/fixture/Basic-consumer wave, bounded source/composition review
+and owner integration pass **8 files81/81**, types/lint/build, four original
+Basic native/BG-copy allow/cancel gameplay/rendering programmes and eight legal
+policy continuations. Sources158/159 retain original47/22 decks, actual paid
+placements, Duke acquisition/death, Guild entry and its original dealt Karama.
+The real Basic start exposed a conservative restriction; bare Ecaz+Ix with native
+Tleilaxu now uses existing coalition/Face Dance mechanics. Six labelled controlled
+Basic cases retain genuine setup, actual Nexus/alliance and funded shipments.
+Feedback corrected a Guild-ally tariff expectation and an idle-policy private
+driver; runtime alone reran on identical source. [Policy, exact proof and limits](TLEILAXU_AMBASSADOR_RULES.md#provisional-free-revival-income--24-october-2026).
+Wider combinations, generic ordinary repeat eligibility, complete modes and final
+assurance remain open. No optional-module opening, strategic calibration or
+saved-room/database/reset/restart/deployment operation in this batch.
**Ecaz's Ambassador Duke revival — 24 October. Prototyped / Partial.** The
original Tleilaxu grant now offers Ecaz the real shared dead Duke, including his
diff --git a/docs/ECAZ_OCCUPY_RULES.md b/docs/ECAZ_OCCUPY_RULES.md
index 77558d5f..8623383e 100644
--- a/docs/ECAZ_OCCUPY_RULES.md
+++ b/docs/ECAZ_OCCUPY_RULES.md
@@ -2,6 +2,25 @@
Historical primary-source audit, 2026-09-07, corrected after independent verification. The publisher/designer odd-force conflict remains unresolved as a publisher correction. Under the 8 October Basic-first prototype instruction, Basic now provisionally uses the main p8 paragraph: ceiling contribution/loss and floor survivors, with a visible control warning. This is not a numerical ruling by the user or designer. Advanced retains its separately authorized source. The earlier false attribution to Jack Reda remains withdrawn.
+## Bare Basic native Tleilaxu — 24 October 2026
+
+The Basic profile now admits native Tleilaxu with exactly the selected `ecaz`
+and `ix` decks, classic seats and optional Moritani. This removes the old
+conservative roster restriction through the existing composition predicate;
+the genuine start and battle assertions remain. It adds no native Ixian,
+CHOAM/Richese or independent-card/optional-module composition. Existing broader
+registered profiles keep their own boundaries.
+
+Basic Tleilaxu supplies ordinary counters and uses the existing fixed Ecaz
+contribution, selected lead, zero support and separate physical losses. The
+winning co-side cannot Face Dance itself; external Face Dance returns both
+winning armies and counts their combined survivors. These existing native
+handlers have no Advanced-only dependency. The Basic odd-count interpretation
+above remains provisional. This is the setup dependency for [Ambassador bank
+income](TLEILAXU_AMBASSADOR_RULES.md#provisional-free-revival-income--24-october-2026),
+not complete-mode certification. Bounded consumer proof and exact frozen results
+are recorded with that checkpoint.
+
## Registered mixed Skills — 18 October 2026
Actual Eca and registered selected E1/E2/classic seats now use all14 without
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 6c6fff60..a2374fbd 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,29 @@
# Dune implementation status
+## 24 October 2026 — Tleilaxu income from committed Ambassador returns
+
+Positive free force/native leader/KH/Duke returns now share the existing
+beneficiary/turn free-income ledger under an explicitly provisional source
+composition. Actual Ghola stays separate; decline earns nothing. Bind the
+already-completed return to a distinct income response. Allow pays one bank
+spice; cancellation keeps the revival and spent entitlement. Both finish the
+original Ambassador once. Persistent controls explain the policy and result.
+
+The original Basic source exposed an old conservative start restriction. Bare
+Ecaz+Ix with native Tleilaxu now admits existing coalition/Face Dance behavior,
+retaining the start/battle assertions and other faction/module boundaries.
+A disjoint receipt/fixture/consumer wave, bounded review and owner integration
+pass8-file81/81, types/lint/build, four original Basic native/copy allow/cancel
+gameplay/rendering programmes and eight legal policies. Sources158/159 retain
+original47/22 decks, actual placements/acquisition/poison death/paid Guild entry
+and its dealt Karama. Six labelled controlled Basic consumers retain original
+setup and actual Nexus/alliance/funded battle controls. Feedback repaired a
+Guild-ally price expectation and private idle-policy driver; runtime alone
+reran on identical source. [Policy, source and exact frozen evidence](TLEILAXU_AMBASSADOR_RULES.md#provisional-free-revival-income--24-october-2026).
+Full modes, ordinary repeated-leader eligibility, wider combinations, assurance
+and deployed acceptance remain open. No strategic calibration, optional-module
+opening or saved-room/database/reset/restart/deployment operation.
+
## 24 October 2026 — Ecaz's free Ambassador Duke return
Connect the missing shared-disc alternative to the original Tleilaxu grant for
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 254db65e..cf57f0cb 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -442,7 +442,7 @@ They supersede earlier software-absence claims only within their named scope.
| --- | --- | --- |
| Odd-force Ecaz Occupy | Main paragraph: ceiling contribution/loss, floor survivors; conflicting FAQ remains unresolved. | [Occupy](ECAZ_OCCUPY_RULES.md) |
| New-ally Duke loan | Optional existing-disc loan after Ambassador alliance consent; one battle and unused turn-end set-aside. | [Duke acquisition](ECAZ_DUKE_ACQUISITION.md) |
-| Tleilaxu Ambassador leader alternative | First-death own native leader, Atreides' first-killed KH, or Ecaz's independently revivable shared Duke returns free instead of forces. Duke reuses the canonical dead-disc permission and selected alive/set-aside result, preserves death history and any satisfied native cohort opening, and grants no automatic battle control. Only an Ecaz beneficiary may select Duke. Ordinary allowance/income independence remains provisional; generic native repeat and wider custody envelopes stay separate. | [Ambassador source and shared Duke route](TLEILAXU_AMBASSADOR_RULES.md#ecaz-shared-duke-alternative--24-october-2026) |
+| Tleilaxu Ambassador leader alternative | First-death own native leader, Atreides' first-killed KH, or Ecaz's independently revivable shared Duke returns free instead of forces. Duke reuses the canonical dead-disc permission and selected alive/set-aside result, preserves death history and any satisfied native cohort opening, and grants no automatic battle control. Only an Ecaz beneficiary may select Duke. Ordinary allowance independence remains provisional. A positive Ambassador return shares the actual beneficiary’s ordinary free-income entitlement once per turn; actual Ghola remains independently rewarded. Cancellation suppresses only the earned bank spice and retains the completed return and spent entitlement. This source composition is provisional; generic native repeat and wider custody envelopes stay separate. | [Ambassador income policy](TLEILAXU_AMBASSADOR_RULES.md#provisional-free-revival-income--24-october-2026), [shared Duke route](TLEILAXU_AMBASSADOR_RULES.md#ecaz-shared-duke-alternative--24-october-2026) |
| Ordinary allied shipment transit | Depart during the shipper's turn; excess new physical visitors still present at ending go to Tanks, even when exit was prevented. Old co-occupation timing is separate. | [Allied transit](ALLIED_TRANSIT_RULES.md) |
| Ordinary mixed No-Field battle | Private pool is physical forces plus reserve-limited token materialization; both plans seal before one reveal. Advanced now shares this provisional policy; combined Occupy remains separate. | [No-Field](RICHESE_NO_FIELD_RULES.md) |
| Guild-recipient allied No-Field | Rounded one-force Guild fee: one spice to bank, even when Richese funds it; no Guild self-shipment income. | [Allied No-Field payment](RICHESE_ALLIED_SHIPMENT_AUDIT.md) |
diff --git a/docs/TLEILAXU_AMBASSADOR_RULES.md b/docs/TLEILAXU_AMBASSADOR_RULES.md
index 1db3581a..554d7c3a 100644
--- a/docs/TLEILAXU_AMBASSADOR_RULES.md
+++ b/docs/TLEILAXU_AMBASSADOR_RULES.md
@@ -2,6 +2,103 @@
Primary-source review, 2026-09-07, with later runtime checkpoints below. This document supplements the [Ambassador effects audit](ECAZ_AMBASSADOR_EFFECTS_AUDIT.md) and [runtime readiness review](TLEILAXU_AMBASSADOR_RUNTIME_READINESS.md). Recorded source questions remain distinct from the Basic-first implementation choices.
+## Provisional free revival income — 24 October 2026
+
+A successful positive Ambassador force, native leader, KH or Ecaz Duke return
+now uses Tleilaxu's existing bank-income mechanism. The [E1 rulebook, printed
+page7](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=7)
+awards income for each faction using free revival or Ghola. The [E3 rulebook,
+printed page8](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=8)
+makes this Ambassador return free. Applying that general trigger, including
+the leader alternative, is source composition. Neither passage explicitly
+settles cross-phase repetition. Under the standing choose-recommended-option
+instruction, use **one bank spice per actual beneficiary per turn**, shared
+with ordinary free revival; actual Ghola remains independently rewarded per
+use. This is a provisional interpretation, not a located publisher example.
+It supersedes only the earlier Ambassador income-independence boundary below.
+
+Complete the physical return first, then stamp the existing `revivalFreeIncome`
+ledger through `collectRevivalIncome`. The actual recipient may be Ecaz, its
+ally or Tleilaxu itself. A decline returns nothing, consumes no entitlement and
+opens no income response. An already-used ordinary free entitlement pays no
+second reward. Existing Tleilaxu Homeworld suppression remains in the shared
+calculation. No ordinary quota, price, paid request, Ghola or out-of-phase Axlotl
+payment is added.
+
+When a reward is due, keep the original Ambassador at `income` with a receipt
+binding its event, owner, entrant, token, site, turn/phase, beneficiary, Tleilaxu
+owner and exact committed return. Distinct `ambassadorRevival` response routing
+avoids Richese's purchase continuation. Allow pays the bank spice; cancel spends
+the actual eligible card and prevents only that spice. Both finish the original
+Ambassador once, without replaying the return or releasing the free entitlement.
+Returned pieces need not stay in their original location during an otherwise
+legal interruption. The income source also remains bound across BG conversion
+and its printed counter. Persistent guidance explains the provisional reward
+before selection and the already-completed return during the response.
+
+The original Basic source also exposed an old conservative Occupy whitelist.
+Bare selected `ecaz`+`ix` with native Tleilaxu now admits the existing ordinary
+Basic coalition and Face Dance paths, with classic seats and optional Moritani.
+The start and battle guards remain; this does not open native Ixian/CHOAM/
+Richese Basic compositions or add optional modules/independent card variants.
+See [the native force-owner contract](ECAZ_OCCUPY_RULES.md#native-e1e2-force-owner-integration--2-october-2026).
+The actual source roster for this income programme is Ecaz/Harkonnen/Tleilaxu/
+Guild; its original47-card Treachery deck and22-card Spice deck are retained.
+
+### Focused income proof and remaining scope
+
+Frozen owner integration passes **8 files,81/81 tests**, types/lint and production
+build. The six pure receipt cases bind exact source/return identity and fixed
+one-spice amount. Engine cases cover force/native leader/KH/Duke returns, actual
+beneficiary/self attribution, decline, prior ordinary free revival, actual Ghola
+before/after the grant, printed cancellation and an Advanced BG conversion's
+printed counter. The latter restores the original income without another revival.
+Existing ordinary usage, legacy BG contexts, Basic coalition and Duke paths pass
+in the affected union. No full recovery/privacy/custody suite was added.
+
+`tests/fixture-ambassador-revival-income.ts` supplies native and copied original
+public Basic sources using **158/159 accepted actions**. Five unopened deal
+positions give Guild its actual Karama, two unopened ordinary Spice positions
+and original packet/scalar lotteries retain all components. Real paid placements,
+Duke acquisition, a two-poison battle/death and next-turn paid Guild entry precede
+the free Duke return and separate bank-income response. Four allow/cancel
+programmes preserve the living strength6/deaths1 shared disc, the consumed free
+entitlement and original entrant movement. Only allowing credits the one spice;
+the original held Karama is physically spent only on cancellation.
+
+Actual GameTable/entry rendering checks enabled/busy Duke controls, preselection
+policy, income-owner amount and persistent cancellation guidance. Eight existing
+Easy/Medium/Hard/Brutal native/copy policies select the legal return and an eligible
+response, then finish the original entrant. No strategy refinement. The private
+driver initially selected an idle seat without a cancellation card; selecting
+an eligible responder repaired only that driver. Runtime alone reran with all
+application/test source identical to the already-passed tests/quick/build.
+Framework aliases/CSS stubs make this runtime rendering, not browser, visual or
+deployed acceptance.
+
+Six explicitly controlled Basic coalition consumers retain the actual public
+setup, original47-card inventory, physical forces and Traitor identities. Labelled
+conserved board/card/Face Dancer swaps precede real next-turn Nexus/alliance,
+funded city shipments and native choose/lead/plan/loss/cleanup controls. Both
+Tleilaxu co-side leads suppress self Face Dance; external Tleilaxu zero/three
+replacement returns both winning Ecaz/Guild armies and retains original rewards.
+Their initial cost expectation omitted the actual Guild alliance half-rate;
+only that expectation was corrected. These are controlled consumers, not six
+unmodified original full-game programmes or wider-combination certification.
+
+Frozen source: base `f9e204c301c6789718553c0cd10cd59ef315792c`, tree
+`9cd43e04cf7d4cada94daa2749da36bd335da9d3ff78a72062d47364fefc70e0`,
+2611 source files, including only the hash of the untracked authorized PDF.
+Private evidence:
+`/home/quintine/.local/share/dune/checkpoints/20261024-ambassador-revival-income/`.
+`frozen-integrated-final/report.json` explicitly combines the identical-source
+passed checks in `frozen-integrated-2` with `runtime-repair`; earlier failed
+feedback reports remain. Final checkpoint prose/reference is a reviewed
+documentation-only overlay. Full modes, generic repeated ordinary leader
+eligibility, wider modules/combinations and final assurance remain unfinished;
+no deployment, server restart or saved-room/database operation belongs to this
+batch.
+
## Ecaz shared Duke alternative — 24 October 2026
The original Tleilaxu Ambassador now offers Ecaz its dead shared Duke as the
diff --git a/game/engine.ts b/game/engine.ts
index 8a55da3f..71acd449 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -16,6 +16,8 @@ import {
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 { tleilaxuAmbassadorLeaderChoices, tleilaxuAmbassadorLeaderSelection } from './tleilaxu-ambassador-leaders';
+import { createAmbassadorRevivalReceipt, validateAmbassadorRevivalReceipt, AmbassadorRevivalIncomeError,
+ type AmbassadorRevivalReceipt, type AmbassadorRevivalReturn } from './tleilaxu-ambassador-income';
import { leaderSkillStrongholdCount, sandmasterVictorySpice, sandmasterVictoryPiles, type SandmasterVictorySpiceOffer } from './leader-skill-battle-board';
import { beginRihani, chooseRihaniDraw, finishRihani, validateRihani, type RihaniReceipt, type RihaniSkill } from './rihani-decipherer';
import { planetologistMovementModeSupported, planetologistLeader, planetologistRange, selectedOriginElites, groundMovementRange, planetologistGatherAdvisorEntry, advisorFlipMovementBlocked, type PlanetologistMovement } from './planetologist-movement';
@@ -1059,7 +1061,7 @@ export type ResponseWindow = {
occupiedPayment?: { event: string; contributions: { payer: string; amount: number }[] };
/** Bank-paid portion of a frozen Homeworld charity claim. */
charityHomeworld?: number;
- source?: 'ambassador';
+ source?: 'ambassador' | 'ambassadorRevival';
intent?: string;
advisors?: boolean;
/** Saved exact ordinary Guild-rate response; not the independent Karama rate. */
@@ -1770,6 +1772,8 @@ export type Game = {
stage: 'income' | 'bonus';
nexusEvent?: string;
};
+ /** The free return is committed; only its bank income remains answerable. */
+ revivalReceipt?: AmbassadorRevivalReceipt;
beneficiary?: string;
effect?: AmbassadorEffect;
copyChoices: AmbassadorEffect[];
@@ -10754,6 +10758,15 @@ export function initializeFactionExpansionsGameForAudit(state: Game): Game {
return initialized;
}
function ecazOccupyCompositionSupported(g: Game): boolean {
+ // Native Tleilaxu's ordinary Basic armies and Face Dance already use the
+ // same coalition outcome. Admit their bare selected decks without opening
+ // unrelated native factions or optional-module compositions.
+ const basicTleilaxu = g.advanced === false && Array.isArray(g.expansions) && g.expansions.length === 2 &&
+ g.expansions.includes('ecaz') && g.expansions.includes('ix') &&
+ !!byFaction(g, 'tleilaxu') &&
+ g.players.every(p => p.faction === 'ecaz' || p.faction === 'moritani' ||
+ p.faction === 'tleilaxu' || CLASSIC_FACTIONS[p.faction]) &&
+ !g.ecazTreachery && !g.techTokens && !g.strongholdCards;
if ((independentNexusProfile(g) || independentDiscoveryProfile(g)) && byFaction(g, 'ecaz')) return true;
if (g.leaderSkills && nativeEcazLeaderSkillsProfile(g)) return true;
if (g.ecazOccupyPreview === true && (mixedE3NexusProfile(g) || standaloneE3NexusProfile(g) || pairedE3NexusProfile(g)) &&
@@ -10779,7 +10792,7 @@ function ecazOccupyCompositionSupported(g: Game): boolean {
g.expansions.includes('ecaz') &&
g.expansions.every(id => ['ecaz', 'ix', 'choam'].includes(id)) &&
new Set(g.expansions).size === g.expansions.length &&
- (g.advanced || (g.expansions.length === 1 && g.expansions[0] === 'ecaz' &&
+ (g.advanced || basicTleilaxu || (g.expansions.length === 1 && g.expansions[0] === 'ecaz' &&
g.players.every(p => p.faction === 'ecaz' ||
p.faction === 'moritani' || CLASSIC_FACTIONS[p.faction]))) &&
g.players.length >= 2 && g.players.length <= 6 &&
@@ -10797,7 +10810,7 @@ function ecazOccupyCompositionSupported(g: Game): boolean {
/** Fresh native Occupy profile; Basic odd counts use the main printed rule provisionally. */
export function initializeEcazOccupyGameForAudit(state: Game): Game {
requireRule(ecazOccupyCompositionSupported(state),
- 'Occupy requires supported native factions and their selected decks; Basic uses Ecaz/classic/optional Moritani and may include the independent cards.');
+ 'Occupy requires supported native factions and their selected decks; Basic additionally admits native Tleilaxu with the bare Ecaz and Ix decks.');
requireFreshBaseRuntime(state);
requireFreshFactionInventory(state);
const g = initializeSetupGameForAudit(state, false, false, false, false, false, false, true, state.ecazTreachery === true);
@@ -18451,6 +18464,59 @@ function ecazAllianceIntegrity(g: Game) {
getPlayer(g, entry.entrant).ally === entry.owner,
'The Duke loan lost its accepted Ecaz alliance.');
}
+/** The Ambassador return has happened; the existing free-income ledger decides
+ * whether its independent bank reward still needs a response. */
+function finishAmbassadorRevival(g: Game, beneficiary: Player, returned: AmbassadorRevivalReturn) {
+ const entry = g.pendingAmbassador!;
+ collectRevivalIncome(g, beneficiary, 0, true);
+ if (!g.response) { finishAmbassador(g); return; }
+ const income = g.response;
+ requireRule(income.kind === 'revivalIncome' && income.amount === 1,
+ 'The free Ambassador revival lost its bank income.');
+ entry.revivalReceipt = createAmbassadorRevivalReceipt({
+ event: entry.event, owner: entry.owner, entrant: entry.entrant, token: entry.token,
+ territory: entry.territory, sector: entry.sector, turn: entry.turn, phase: entry.phase,
+ beneficiary: beneficiary.id, incomeOwner: income.owner,
+ }, returned);
+ entry.stage = 'income';
+ income.source = 'ambassadorRevival';
+ income.intent = entry.event;
+ log(g, 'Provisional Ambassador revival income: Tleilaxu may collect one bank spice for this beneficiary this turn. The return is already complete; canceling income does not undo it.',
+ { faction: 'tleilaxu', name: 'Ambassador revival income' });
+}
+/** Bind the income to the original committed return, not its pieces' later
+ * location after an otherwise legal card interruption. */
+function validateAmbassadorRevivalIncome(g: Game, response: ResponseWindow) {
+ const entry = g.pendingAmbassador, receipt = entry?.revivalReceipt;
+ const token = g.ecazAmbassadors?.tokens.find(t => t.id === entry?.token);
+ requireRule(g.status === 'playing' && entry && receipt && entry.stage === 'income' &&
+ entry.effect === 'tleilaxu' && entry.turn === g.turn && entry.phase === g.phase &&
+ ambassadorPhaseAllowed(g) && response.kind === 'revivalIncome' &&
+ response.source === 'ambassadorRevival' && response.intent === entry.event && response.amount === 1 &&
+ response.owner === byFaction(g, 'tleilaxu')?.id && response.recipient === entry.beneficiary &&
+ byFaction(g, 'ecaz')?.id === entry.owner && g.players.some(p => p.id === entry.entrant) &&
+ g.players.some(p => p.id === entry.beneficiary) && g.revivalFreeIncome?.[entry.beneficiary!] === g.turn &&
+ token && ((token.effect === 'tleilaxu' && token.zone === 'used') ||
+ (token.effect === 'beneGesserit' && token.zone === 'removed' && entry.copyChoices.includes('tleilaxu'))),
+ 'This Ambassador revival income has lost its committed return.');
+ try {
+ validateAmbassadorRevivalReceipt({ event: entry.event, owner: entry.owner, entrant: entry.entrant,
+ token: entry.token, territory: entry.territory, sector: entry.sector, turn: entry.turn, phase: entry.phase,
+ beneficiary: entry.beneficiary!, incomeOwner: response.owner }, receipt);
+ } catch (error) {
+ if (error instanceof AmbassadorRevivalIncomeError) throw new RuleError(error.message);
+ throw error;
+ }
+}
+function ambassadorRevivalIncomeIntegrity(g: Game) {
+ const responses = [...new Set([...savedNoFieldResponses(g), ...savedKaramaContexts(g).flatMap(({ pending }) =>
+ pending.use.kind === 'cancel' ? [pending.use.response] : [])])]
+ .filter(response => response.source === 'ambassadorRevival');
+ const entry = g.pendingAmbassador;
+ if (!entry?.revivalReceipt && !(entry?.effect === 'tleilaxu' && entry.stage === 'income') && !responses.length) return;
+ requireRule(responses.length === 1, 'The committed Ambassador revival needs its original income response.');
+ validateAmbassadorRevivalIncome(g, responses[0]);
+}
function decideAmbassador(g: Game, p: Player, action: Action) {
const entry = g.pendingAmbassador;
requireRule(
@@ -18541,12 +18607,13 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
'Choose a leader or forces, not both.');
const selected = tleilaxuAmbassadorLeaderSelection(g, p, action.leader);
requireRule(selected, 'Choose an eligible own leader, Ecaz Duke or Kwisatz Haderach from the Tanks.');
- let name: string;
+ let name: string, returned: AmbassadorRevivalReturn;
if (selected === 'kwisatz') {
const kwisatz = p.kwisatz!;
kwisatz.dead = false;
delete kwisatz.usedAt;
kwisatz.revivalCycle = Math.max(p.revivalCycle, kwisatz.revivalCycle ?? 1) + 1;
+ returned = { kind: 'kwisatz', cycle: kwisatz.revivalCycle };
name = 'Kwisatz Haderach';
} else if (selected === DUKE_VIDAL_ID) {
const duke = g.dukeVidal!.leader;
@@ -18555,16 +18622,18 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
delete duke.usedAt;
delete duke.concealed;
name = duke.name;
+ returned = { kind: 'leader', leader: duke.id, deaths: duke.deaths };
g.dukeVidal = consumeDuke(g.dukeVidal!);
} else {
recordEcazLeaderReturn(g, selected);
selected.dead = false;
delete selected.usedAt;
name = selected.name;
+ returned = { kind: 'leader', leader: selected.id, deaths: selected.deaths };
}
log(g, `${p.name} revived ${name} for free through the Tleilaxu Ambassador.${selected === DUKE_VIDAL_ID ? ' The living shared disc is set aside, unclaimed.' : ''} The ordinary leader allowance is unchanged.`,
{ faction: 'tleilaxu', name: 'Tleilaxu Ambassador' });
- finishAmbassador(g);
+ finishAmbassadorRevival(g, p, returned);
return;
}
const forces = integer(action.forces, 1, 4, 'Forces to return');
@@ -18604,10 +18673,10 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
}
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.`,
+ `${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 and prices are unchanged.`,
{ faction: 'tleilaxu', name: 'Tleilaxu Ambassador' },
);
- finishAmbassador(g);
+ finishAmbassadorRevival(g, p, { kind: 'forces', amount: quote.amount, elite: quote.elite });
return;
}
if (entry.stage === 'offer') {
@@ -26142,6 +26211,8 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
: null;
if (response.source === 'ambassador')
validateAmbassadorPurchaseResponse(g, response);
+ if (response.source === 'ambassadorRevival')
+ validateAmbassadorRevivalIncome(g, response);
const placementCancellation = canceled
? currentPlacementCancellationQuote(g, response)
: null;
@@ -26831,6 +26902,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
awardBankerIncome(g, response.spiceBankerIncomePayment, canceled);
if (g.pendingChoamMarketGhola && g.pendingChoamMarketGhola.event === response.intent)
g.pendingChoamMarketGhola.stage = 'complete';
+ if (response.source === 'ambassadorRevival') finishAmbassador(g);
} else if (response.kind === 'faceDancerReplacement') {
const owner = getPlayer(g, response.owner);
const index = owner.faceDancers!.findIndex(
@@ -30511,6 +30583,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
shipmentPromiseIntegrity(state);
saphoMovementIntegrity(state);
ambassadorRelocationIntegrity(state);
+ ambassadorRevivalIncomeIntegrity(state);
ecazCollectionIntegrity(state);
ecazAllianceIntegrity(state);
// Seat control is independent of gameplay locks and must preserve their exact continuation.
@@ -30917,6 +30990,7 @@ export function normalizeAutomaticGame(state: Game): Game {
shipmentPromiseIntegrity(state);
saphoMovementIntegrity(state);
ambassadorRelocationIntegrity(state);
+ ambassadorRevivalIncomeIntegrity(state);
ecazCollectionIntegrity(state);
ecazAllianceIntegrity(state);
const g = structuredClone(state);
@@ -35129,6 +35203,7 @@ export function viewGame(state: Game, id: string) {
shipmentPromiseIntegrity(state);
saphoMovementIntegrity(state);
ambassadorRelocationIntegrity(state);
+ ambassadorRevivalIncomeIntegrity(state);
ecazCollectionIntegrity(state);
ecazAllianceIntegrity(state);
battleSpecialWeaponIntegrity(state);
diff --git a/game/karama-context.ts b/game/karama-context.ts
index 20bb936f..71a3c514 100644
--- a/game/karama-context.ts
+++ b/game/karama-context.ts
@@ -224,7 +224,8 @@ const cancellationSources = {
revivalLimit: (g) => g.pendingRevival ?? null,
revivalDiscount: (g) => g.pendingRevival ?? null,
earlyRevival: (g) => g.pendingRevival ?? null,
- revivalIncome: () => null,
+ revivalIncome: (g, response) => response.source === 'ambassadorRevival'
+ ? g.pendingAmbassador?.revivalReceipt ?? null : null,
foreignGhola: (g) => g.pendingRevival ?? null,
ixSubstitution: (g) => ({
...aftermathSource(g),
diff --git a/game/reference.ts b/game/reference.ts
index 0f758299..cbdb7ceb 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -1453,7 +1453,7 @@ export const RULE_TOPICS: RuleTopic[] = [
summary: 'Ecaz chooses who leads its combined army. Basic provisionally follows the main printed paragraph for odd as well as even counts: ceiling contribution/loss and floor survivors. Advanced retains its user-authorized revision. Usable controls and legal participation are connected; interaction refinement remains.',
steps: [
'When reciprocal Ecaz allies coexist with fighting forces, Ecaz chooses Ecaz or its ally to lead. Every participating whole-territory army must be clear of storm and connected; no component-only battle or casualties are created. The selected lead uses its own leaders, Treachery cards and spice; the original battle-order chooser is not replaced. Advisors are not fighters but retain their native voluntary fighter-conversion opportunity.',
- 'Basic combined battles use the native Ecaz deck and existing roster prerequisites. Ecaz contributes the ceiling of half its forces at full strength and loses that many on an ordinary win; survivors round down. This follows the main printed paragraph provisionally because its odd-count FAQ example conflicts. A visible control warning distinguishes the prototype from a settled correction. Basic has no Emperor/Fremen elite inventory and its supplied support normalizes to zero.',
+ 'Basic combined battles use the native Ecaz deck and existing roster prerequisites. Bare Ecaz+Ix with native Tleilaxu now supports ordinary Tleilaxu coalition and Face Dance behavior, with classic seats and optional Moritani; it adds no other Basic native faction or optional-module combination. Ecaz contributes the ceiling of half its forces at full strength and loses that many on an ordinary win; survivors round down. This follows the main printed paragraph provisionally because its odd-count FAQ example conflicts. A visible control warning distinguishes the prototype from a settled correction. Basic has no Emperor/Fremen elite inventory and its supplied support normalizes to zero.',
'With Advanced Occupy active, the total dial includes the ceiling of half the Ecaz fighters as mandatory full-strength contribution without spice cost, plus the chosen allied army strength. The variable army retains its native Fremen/Fedaykin or Emperor/Sardaukar properties even when Ecaz forms the plan.',
'An ordinary victory leaves the floor of half the Ecaz fighters alive and separately applies the variable army’s dial losses. A sole successful Traitor-call winner preserves both armies; an ordinary defeat, mutual Traitor result or resolved explosion removes the actual combined side. Ecaz and its ally share the printed same-faction Traitor/Face Dancer relation.',
'Eligible Karama can prevent Occupy after the lead choice and before powers/plans. The chosen lead remains lead and dials its own army; the other ally contributes zero. The plan editor then uses the new force pool and no fixed Ecaz strength.',
@@ -1470,7 +1470,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Player controls', status: 'Partial', detail: 'Original total-wheel, lead and Karama controls now support native mixed Richese as ally or opponent. Selected Ecaz sees the private mixed variable notice, not its own maximum; prevention returns to selected-only forces. Four new390px journeys include capped mixed Ecaz lead, funded H/R Richese lead, opposing zero with ordinary counters and actual Karama redial. Earlier native/basic controls remain historical evidence.', evidence: ['components/ecaz-occupy.tsx', 'components/face-dance-decision.tsx', 'components/game-table.tsx', 'game/ecaz-occupy-options.ts'] },
{ area: 'AI', status: 'Partial', detail: 'All four native policies actually seal legal total plans for either lead, active or canceled, using native leaders/cards and physical support. Strategy remains deferred.', evidence: ['game/ecaz-occupy-options.ts', 'game/bots.ts', 'tests/ecaz-occupy-options.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'Authorized Advanced arithmetic and provisional main-paragraph Basic arithmetic are distinguished from the conflicting FAQ, specific Duke ruling and deferred interaction refinement.', evidence: ['docs/ECAZ_OCCUPY_RULES.md', 'docs/RULE_DECISIONS.md', 'game/reference.ts'] },
- { area: 'Verification', status: 'Partial', detail: 'Current mixed regressions cover both physical roles/leads, zero/caps, prevention, original support/losses, real Traitors and H/R funding/disposal. Four actual mobile journeys and16 native programs/80 policy actions reach original Collection. Earlier363/26-program and Basic full-game evidence remains historical, not a fresh broad/recovery/deployed certification. Complete modes, overlays and final assurance remain open.', evidence: ['tests/ecaz-occupy-mixed-no-field.test.ts', 'tests/fixture-ecaz-occupy-mixed-no-field.ts', 'tests/ecaz-occupy-mixed-no-field-opponent.test.ts', 'tests/ecaz-occupy-battle.test.ts', 'tests/basic-ecaz-occupy-runtime.test.ts', 'tests/ecaz-occupy-richese.test.ts', 'tests/ecaz-occupy-stone.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'tools/faction-games.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
+ { area: 'Verification', status: 'Partial', detail: 'Current mixed regressions cover both physical roles/leads, zero/caps, prevention, original support/losses, real Traitors and H/R funding/disposal. Four actual mobile journeys and16 native programs/80 policy actions reach original Collection. Earlier363/26-program and Basic full-game evidence remains historical, not a fresh broad/recovery/deployed certification. Complete modes, overlays and final assurance remain open.', evidence: ['tests/ecaz-occupy-mixed-no-field.test.ts', 'tests/fixture-ecaz-occupy-mixed-no-field.ts', 'tests/ecaz-occupy-mixed-no-field-opponent.test.ts', 'tests/ecaz-occupy-battle.test.ts', 'tests/basic-ecaz-occupy-runtime.test.ts', 'tests/basic-ecaz-tleilaxu-occupy.test.ts', 'tests/ecaz-occupy-richese.test.ts', 'tests/ecaz-occupy-stone.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'tools/faction-games.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
],
related: ['faction-ecaz', 'advanced-ecaz', 'ecaz-ambassadors', 'battle', 'victory', 'implementation-checklist'],
},
@@ -1522,30 +1522,30 @@ export const RULE_TOPICS: RuleTopic[] = [
'The Guild Ambassador grants an immediate free shipment of zero through four physical reserve forces for Ecaz or its ally, including typed elites. Choose a clear territory and sector; this does not spend ordinary shipment or movement, ally aid or a retained rate card. Fremen may use destinations outside their ordinary reinforcement radius, but their southern reserves stay on-planet and this effect never permits storm entry. Only Ixians may ship directly into their placed mobile stronghold.',
'During Shipment and Movement, Guild can use its special Karama to stop another eligible off-planet Ambassador shipment before forces arrive; it does not stop this grant during a phase-one worm sequence. A successful off-planet shipment may invite Bene Gesserit accompaniment and accrues applicable phase-five Heighliner income once. BG can accompany Ixians into the mobile stronghold. Projected accompaniment choices show blocked sectors and keep Polar Sink and decline available when legal. Intrusion, separate Terror and accompanying fighter-triggered Ambassadors finish before the original entrant or worm rider resumes.',
'No-Field substitution into the free four-force grant remains unavailable. In bounded classic plus Ecaz/Moritani games, a shared Ambassador/Terror entry gives both owners saved optional prompts in current storm order without repeating the committed entry; this is a provisional table order, not a printed publisher priority. A Terror entry now waits for the arrival’s own interaction — Guild income, a Bene Gesserit fighter Intrusion or advisor choice, or Fremen storm protection — and then opens without replaying payment or force transfer. Ambassador-created child arrivals retain their own contracts. The native-module extension now composes ordinary paid arrival order with Atomics, Homeworlds and Nexus; other combinations and complete rules remain unfinished.',
- 'The Tleilaxu Ambassador offers one eligible leader or up to four physical forces free. Ordinary native leaders retain their first-death limit; Atreides may choose its separate first-killed Kwisatz Haderach without adding a roster disc or resetting losses. An Ecaz beneficiary may instead return the original dead shared Duke through his independent permission, preserving death history and any native cohort opening. He becomes alive and set aside, unclaimed; a separate legal acquisition is needed before battle use. Ecaz’s ally cannot inherit that revival permission. Native and BG copied grants share private eligibility. Force/leader choices remain exclusive; ordinary allowance/income independence stays provisional and generic repeat/custody combinations remain separate.',
- 'All eleven effect guides are readable. Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen and Guild entry effects are integrated, together with Bene Gesserit copies of those eight effects and direct Duke acquisition or consensual alliance through the reusable Ecaz token. Advanced combined Occupy has a separate bounded Development Verified guide, and Basic even-force Occupy now composes the same shared controls. Remaining effects, exceptional Duke custody and complete Ecaz rules remain unfinished. Mobile stronghold placement is gated. Full Ecaz starts remain disabled.',
+ 'The Tleilaxu Ambassador offers one eligible leader or up to four physical forces free. Ordinary native leaders retain their first-death limit; Atreides may choose its separate first-killed Kwisatz Haderach without adding a roster disc or resetting losses. An Ecaz beneficiary may instead return the original dead shared Duke through his independent permission, preserving death history and any native cohort opening. He becomes alive and set aside, unclaimed; a separate legal acquisition is needed before battle use. Ecaz’s ally cannot inherit that revival permission. Native and BG copied grants share private eligibility. Force/leader choices remain exclusive. Under the provisional income interpretation, a positive return earns seated Tleilaxu one bank spice per beneficiary per turn, sharing ordinary free revival; actual Ghola stays separate. Canceling income keeps the return and spent entitlement. Ordinary allowance independence and generic repeat/custody combinations remain provisional.',
+ 'All eleven effect guides are readable. Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen and Guild entry effects are integrated, together with Bene Gesserit copies of those eight effects and direct Duke acquisition or consensual alliance through the reusable Ecaz token. Advanced combined Occupy has a separate bounded Development Verified guide, and Basic even-force Occupy now composes the same shared controls. Remaining effects, exceptional Duke custody and complete Ecaz rules remain unfinished. Mobile stronghold placement is gated. Normal public Ecaz starts remain development previews; complete rules and unsupported combinations stay gated.',
],
checklist: [
{
area: 'Implementation',
status: 'Partial',
detail:
- 'Original effects, copies, Duke acquisition, consent and token returns remain integrated. Ecaz’s Tleilaxu grant now returns the canonical shared dead Duke alive and set aside, preserving his history and satisfied cohort opening without a paid request, ordinary quota or extra disc. Native shipment/Guild income and BG child/original arrival settlement remain; wider combinations stay partial.',
- evidence: ['game/engine.ts', 'game/tleilaxu-ambassador-leaders.ts', 'game/ecaz-duke-revival.ts', 'game/bot-arrival.ts'],
+ 'Original effects, copies, Duke acquisition, consent and token returns remain integrated. Ecaz’s Tleilaxu grant now returns the canonical shared dead Duke alive and set aside, preserving his history and satisfied cohort opening without a paid request, ordinary quota or extra disc. Positive Tleilaxu Ambassador returns now use the shared free-income ledger and a bound committed-return response; allow/cancel finishes the original arrival once. Bare Basic Ecaz+Ix with native Tleilaxu admits existing coalition/Face Dance mechanics; other combinations stay partial.',
+ evidence: ['game/engine.ts', 'game/tleilaxu-ambassador-leaders.ts', 'game/tleilaxu-ambassador-income.ts', 'game/karama-context.ts', 'game/ecaz-duke-revival.ts', 'game/bot-arrival.ts'],
},
{
area: 'Player controls',
status: 'Partial',
detail:
- 'Existing placement, effect, consent and movement controls remain. An entitled Duke revival choice explains the alive/unclaimed shared destination before selection. Earlier four actual390px native transport/income/BG journeys remain separate evidence; original entrant movement and once-only payment are retained.',
+ 'Existing placement, effect, consent and movement controls remain. An entitled Duke choice explains the alive/unclaimed destination; free-return and income controls explain the provisional shared reward and cancellation retaining the completed return. Earlier four actual390px native transport/income/BG journeys remain separate evidence; original entrant movement and once-only payment are retained.',
evidence: ['components/ecaz-entry.tsx', 'components/game-table.tsx'],
},
{
area: 'AI',
status: 'Partial',
detail:
- 'All four existing profiles legally select the sole free Duke alternative in both original Basic native/BG-copy positions and finish the entrant turn: eight policy continuations. Earlier legal placement, consent, native income/acquisition and BG arrival paths remain separate evidence. No strategy refinement or calibration.',
- evidence: ['game/bots.ts', 'tests/duke-ambassador-engine.test.ts'],
+ 'All four existing profiles legally select the original free Duke return and an eligible revival-income response, then finish the entrant turn: eight native/BG-copy policy continuations with Tleilaxu seated. Earlier placement, consent, income/acquisition and BG arrival evidence remains separate. No strategy refinement or calibration.',
+ evidence: ['game/bots.ts', 'tests/ambassador-revival-income-engine.test.ts', 'tests/duke-ambassador-engine.test.ts', 'docs/TLEILAXU_AMBASSADOR_RULES.md'],
},
{
area: 'Documentation',
@@ -1558,8 +1558,12 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Verification',
status: 'Partial',
detail:
- 'Current Duke-grant5-file42/42, types/lint/build, two original Basic gameplay/rendering programmes and eight legal policies pass. Actual acquisition/death/paid Guild entry precede the free original-disc return and movement completion. Controlled Advanced/repeat/cohort/ownership/reacquisition probes and earlier native arrival/browser evidence remain separate; full mode, combination, browser/deployed and final assurance stay open.',
+ 'Current income8-file81/81, types/lint/build, four original Basic native/copy allow/cancel gameplay/rendering programmes and eight legal policies pass. Original47/22 components, actual acquisition/death/paid Guild entry and dealt Karama precede the return and bound bank income. Six labelled Basic Tleilaxu coalition consumers retain actual setup/alliance/funded battles. Earlier Duke-only42 and arrival/browser evidence remain separate; full modes, wider combinations and deployed/final assurance stay open.',
evidence: [
+ 'tests/fixture-ambassador-revival-income.ts',
+ 'tests/ambassador-revival-income-engine.test.ts',
+ 'tests/tleilaxu-ambassador-income.test.ts',
+ 'tests/basic-ecaz-tleilaxu-occupy.test.ts',
'tests/fixture-duke-ambassador.ts',
'tests/duke-ambassador-engine.test.ts',
'tests/tleilaxu-ambassador-leaders.test.ts',
diff --git a/game/tleilaxu-ambassador-income.ts b/game/tleilaxu-ambassador-income.ts
new file mode 100644
index 00000000..543812a2
--- /dev/null
+++ b/game/tleilaxu-ambassador-income.ts
@@ -0,0 +1,101 @@
+export type AmbassadorRevivalSource = {
+ event: string;
+ owner: string;
+ entrant: string;
+ token: string;
+ territory: string;
+ sector: number;
+ turn: number;
+ phase: number;
+ beneficiary: string;
+ incomeOwner: string;
+};
+export type AmbassadorRevivalReturn =
+ | { kind: 'forces'; amount: number; elite: number }
+ | { kind: 'leader'; leader: string; deaths: number }
+ | { kind: 'kwisatz'; cycle: number };
+export type AmbassadorRevivalReceipt = {
+ version: 1;
+ source: AmbassadorRevivalSource;
+ returned: AmbassadorRevivalReturn;
+ amount: 1;
+ signature: string;
+};
+export class AmbassadorRevivalIncomeError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'AmbassadorRevivalIncomeError';
+ }
+}
+function requireIncome(value: unknown, message: string): asserts value {
+ if (!value) throw new AmbassadorRevivalIncomeError(message);
+}
+const text = (value: unknown): value is string =>
+ typeof value === 'string' && value.trim().length > 0;
+const count = (value: unknown): value is number =>
+ typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
+const exact = (value: unknown, keys: string): value is Record =>
+ !!value && typeof value === 'object' && !Array.isArray(value) &&
+ [Object.prototype, null].includes(Object.getPrototypeOf(value)) &&
+ Object.keys(value).sort().join(',') === keys;
+function validSource(source: unknown): source is AmbassadorRevivalSource {
+ return exact(source, 'beneficiary,entrant,event,incomeOwner,owner,phase,sector,territory,token,turn') &&
+ text(source.event) && text(source.owner) && text(source.entrant) && text(source.token) &&
+ text(source.territory) && text(source.beneficiary) && text(source.incomeOwner) &&
+ count(source.sector) && source.sector >= 1 && source.sector <= 18 &&
+ count(source.turn) && source.turn >= 1 && count(source.phase);
+}
+function validReturn(returned: unknown): returned is AmbassadorRevivalReturn {
+ if (exact(returned, 'amount,elite,kind') && returned.kind === 'forces')
+ return count(returned.amount) && returned.amount >= 1 && returned.amount <= 4 &&
+ count(returned.elite) && returned.elite <= returned.amount;
+ if (exact(returned, 'deaths,kind,leader') && returned.kind === 'leader')
+ return text(returned.leader) && count(returned.deaths) && returned.deaths >= 1;
+ return exact(returned, 'cycle,kind') && returned.kind === 'kwisatz' &&
+ count(returned.cycle) && returned.cycle >= 1;
+}
+const sourceValues = (source: AmbassadorRevivalSource) => [
+ source.event, source.owner, source.entrant, source.token, source.territory,
+ source.sector, source.turn, source.phase, source.beneficiary, source.incomeOwner,
+];
+function signature(receipt: AmbassadorRevivalReceipt): string {
+ const returned = receipt.returned;
+ return JSON.stringify([
+ 'ambassadorRevivalReceipt', receipt.version, sourceValues(receipt.source),
+ returned.kind === 'forces' ? ['forces', returned.amount, returned.elite] :
+ returned.kind === 'leader' ? ['leader', returned.leader, returned.deaths] : ['kwisatz', returned.cycle],
+ receipt.amount,
+ ]);
+}
+
+/** Bind an already-committed positive return, not permission to revive or pay.
+ * The native parent validates actual pieces/custody and owns the existing free
+ * income ledger. Canceling the queued income cannot undo this return. */
+export function createAmbassadorRevivalReceipt(
+ source: AmbassadorRevivalSource,
+ returned: AmbassadorRevivalReturn,
+): AmbassadorRevivalReceipt {
+ requireIncome(validSource(source), 'Ambassador revival income needs its exact original source.');
+ requireIncome(validReturn(returned), 'Ambassador revival income needs one positive physical return.');
+ const receipt: AmbassadorRevivalReceipt = {
+ version: 1, source: { ...source }, returned: { ...returned }, amount: 1, signature: '',
+ };
+ receipt.signature = signature(receipt);
+ return receipt;
+}
+export function validateAmbassadorRevivalReceipt(
+ source: AmbassadorRevivalSource,
+ receipt: AmbassadorRevivalReceipt,
+): void {
+ requireIncome(validSource(source), 'Ambassador revival income needs its exact original source.');
+ requireIncome(
+ exact(receipt, 'amount,returned,signature,source,version') && receipt.version === 1 && receipt.amount === 1,
+ 'The Ambassador revival income receipt has changed its fixed reward or shape.',
+ );
+ requireIncome(
+ validSource(receipt.source) && JSON.stringify(sourceValues(source)) === JSON.stringify(sourceValues(receipt.source)),
+ 'The Ambassador revival income receipt no longer matches its original source.',
+ );
+ requireIncome(validReturn(receipt.returned), 'Ambassador revival income needs one positive physical return.');
+ requireIncome(receipt.signature === signature(receipt), 'The Ambassador revival income receipt has changed its committed return.');
+}
diff --git a/tests/ambassador-revival-income-engine.test.ts b/tests/ambassador-revival-income-engine.test.ts
new file mode 100644
index 00000000..282234fa
--- /dev/null
+++ b/tests/ambassador-revival-income-engine.test.ts
@@ -0,0 +1,189 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
+import { baseDeck } from '../game/cards';
+import { createAmbassadors, validateAmbassadors } from '../game/ecaz-ambassadors';
+import { createDukeVidal, DUKE_VIDAL_ID } from '../game/duke-vidal';
+import { placeFixtureHand } from './fixture-hand';
+import { createAmbassadorRevivalIncomeFixture, actAmbassadorRevivalIncome } from './fixture-ambassador-revival-income';
+
+const player = (g: Game, id = 'ecaz') => g.players.find(p => p.id === id)!;
+function allow(g: Game) {
+ for (let n = 0; g.response && n < 40; n++)
+ g = applyAction(g, g.players.find(p => !g.response!.passed.includes(p.id))!.id, { type: 'passResponse' });
+ assert.equal(g.response, null); return g;
+}
+/** Controlled consumer probes; the original-deal programme is separate. */
+function fixture(copied = false, kwisatz = false) {
+ const g = createGame('AMBINCOMEPROBE', newPlayer('ecaz', 'Ecaz', 'ecaz'), false, ['ecaz', 'ix']);
+ g.players.push(newPlayer('entrant', 'Entrant', kwisatz ? 'harkonnen' : 'atreides'), newPlayer('t', 'Tleilaxu', 'tleilaxu'),
+ newPlayer('ally', 'Ally', kwisatz ? 'atreides' : 'emperor'));
+ Object.assign(g, { status: 'playing', phase: 5, turn: 2, storm: 18, active: 'entrant',
+ order: g.players.map(p => p.id), movementRemaining: g.players.map(p => p.id), deck: baseDeck() });
+ for (const p of g.players) { p.forces = {}; p.tanks = 3; p.reserves = 17; p.spice = 12; p.hand = []; }
+ placeFixtureHand(g, 1, [g.deck.find(c => c.effect === 'karama')!]);
+ const state = createAmbassadors(() => 0), effect = copied ? 'beneGesserit' : 'tleilaxu';
+ const cohort = [effect, ...state.tokens.map(t => t.effect).filter(e => e !== effect && e !== 'ecaz')].slice(0, 5);
+ state.cohort = state.tokens.filter(t => cohort.includes(t.effect)).map(t => t.id);
+ for (const t of state.tokens) { t.zone = t.effect === 'ecaz' || state.cohort.includes(t.id) ? 'supply' : 'pool'; t.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 offer(g: Game, beneficiary = 'ecaz', copied = false) {
+ if (beneficiary !== 'ecaz') { player(g).ally = beneficiary; player(g, beneficiary).ally = 'ecaz'; }
+ g = allow(applyAction(g, 'entrant', { type: 'ship', amount: 1, territory: 'arrakeen', sector: 10 }));
+ for (let n = 0; g.decision && ['advisor', 'intrusion'].includes(g.decision.kind) && n < 5; n++)
+ g = allow(applyAction(g, g.decision.player, { type: 'decision', accept: false }));
+ assert.ok(g.pendingAmbassador, JSON.stringify(g.decision));
+ const event = g.pendingAmbassador!.event;
+ g = applyAction(g, 'ecaz', { type: 'decision', event, trigger: true, beneficiary, choice: 'effect' });
+ if (copied) g = applyAction(g, beneficiary, { type: 'decision', event, effect: 'tleilaxu' });
+ assert.equal(g.pendingAmbassador?.stage, 'revival'); return g;
+}
+const revive = (g: Game, choice: Action = { type: 'decision', forces: 2 }) => applyAction(g,
+ g.pendingAmbassador!.beneficiary!, { ...choice, type: 'decision', event: g.pendingAmbassador!.event });
+const usage = (g: Game, id = 'ecaz') => {
+ const p = player(g, id); return { revived: p.revived, free: p.freeForcesRevived, leader: p.leaderRevived };
+};
+
+void test('a positive Ambassador return commits before the one-spice income response and resumes once', () => {
+ const offered = offer(fixture()), before = structuredClone(offered), g = revive(offered);
+ assert.equal(g.response?.kind, 'revivalIncome'); assert.equal(g.response?.source, 'ambassadorRevival');
+ assert.equal(g.response?.owner, 't'); assert.equal(g.response?.recipient, 'ecaz'); assert.equal(g.response?.amount, 1);
+ assert.equal(g.pendingAmbassador?.stage, 'income'); assert.equal(g.decision, null);
+ assert.equal(player(g).tanks, 1); assert.equal(player(g).reserves, 19);
+ assert.equal(player(g).spice, player(before).spice); assert.deepEqual(usage(g), usage(before));
+ assert.equal(g.revivalFreeIncome?.ecaz, g.turn); assert.equal(player(g, 't').spice, player(before, 't').spice);
+ const done = allow(g);
+ assert.equal(player(done, 't').spice, player(before, 't').spice + 1);
+ assert.equal(done.pendingAmbassador, null); assert.equal(done.active, 'entrant');
+ assert.ok(done.movementRemaining!.includes('entrant')); assert.equal(player(done).reserves, 19);
+ assert.throws(() => revive(done));
+ const ended = applyAction(done, 'entrant', { type: 'endMovement' });
+ assert.equal(ended.movementRemaining!.includes('entrant'), false);
+});
+
+void test('canceling Ambassador income consumes Karama but keeps the committed return and spent free entitlement', () => {
+ const source = fixture(), card = player(source, 'entrant').hand.find(c => c.effect === 'karama')!;
+ const g = revive(offer(source)), balance = player(g, 't').spice;
+ const done = applyAction(g, 'entrant', { type: 'card', card: card.id, mode: 'cancel' });
+ assert.equal(done.response, null); assert.equal(done.pendingAmbassador, null);
+ assert.equal(player(done, 't').spice, balance); assert.equal(player(done).reserves, 19);
+ assert.equal(done.revivalFreeIncome?.ecaz, done.turn); assert.equal(done.discard.filter(c => c.id === card.id).length, 1);
+ assert.equal(done.active, 'entrant'); assert.ok(done.movementRemaining!.includes('entrant'));
+});
+
+void test('countering a BG conversion restores the original income response without repeating revival', () => {
+ const source = fixture(); source.advanced = true;
+ source.players.push(newPlayer('bg', 'Bene Gesserit', 'beneGesserit'));
+ source.order.push('bg'); source.movementRemaining!.push('bg');
+ placeFixtureHand(source, 1, [...player(source, 'entrant').hand, source.deck.find(c => c.effect === 'karama')!]);
+ const worthless = source.deck.find(c => c.kind === 'worthless')!;
+ placeFixtureHand(source, 4, [worthless]);
+ const g = revive(offer(source)), balance = player(g, 't').spice;
+ const converted = applyAction(g, 'bg', { type: 'card', card: worthless.id, mode: 'cancel' });
+ assert.equal(converted.response?.kind, 'worthlessKarama'); assert.equal(converted.pendingAmbassador?.stage, 'income');
+ const counter = player(converted, 'entrant').hand.find(c => c.effect === 'karama')!;
+ const restored = applyAction(converted, 'entrant', { type: 'card', card: counter.id, mode: 'cancel' });
+ assert.equal(restored.response?.source, 'ambassadorRevival');
+ assert.deepEqual(restored.pendingAmbassador?.revivalReceipt, g.pendingAmbassador?.revivalReceipt);
+ assert.equal(player(restored).reserves, 19);
+ const done = allow(restored);
+ assert.equal(player(done, 't').spice, balance + 1); assert.equal(player(done).reserves, 19);
+ assert.equal(done.pendingAmbassador, null); assert.equal(done.pendingKarama, null);
+});
+
+void test('prior ordinary free revival shares the turn entitlement while decline leaves it unused', () => {
+ let ordinary = fixture(); ordinary.phase = 4;
+ ordinary = allow(applyAction(ordinary, 'ecaz', { type: 'revive', amount: 1 }));
+ const income = player(ordinary, 't').spice;
+ ordinary.phase = 5;
+ const done = revive(offer(ordinary));
+ assert.equal(done.response, null); assert.equal(done.pendingAmbassador, null);
+ assert.equal(player(done, 't').spice, income); assert.equal(player(done).reserves, 20);
+ const declined = revive(offer(fixture()), { type: 'decision', decline: true });
+ assert.equal(declined.response, null); assert.equal(declined.pendingAmbassador, null);
+ assert.equal(declined.revivalFreeIncome?.ecaz, undefined); assert.equal(player(declined).tanks, 3);
+});
+
+for (const kind of ['native', 'duke', 'kwisatz'] as const) void test(`${kind} leader return earns one bank spice for the actual beneficiary`, () => {
+ const source = fixture(false, kind === 'kwisatz'); let beneficiary = 'ecaz', leader: string;
+ if (kind === 'duke') {
+ source.dukeVidal = createDukeVidal(); Object.assign(source.dukeVidal.leader, { dead: true, deaths: 1 }); leader = DUKE_VIDAL_ID;
+ } else if (kind === 'kwisatz') {
+ beneficiary = 'ally'; const p = player(source, beneficiary);
+ source.advanced = true; p.battleLosses = 7; p.kwisatz = { dead: true, revivalCycle: 1 }; leader = 'kwisatz';
+ } else {
+ beneficiary = 'ally'; const disc = player(source, beneficiary).leaders[0];
+ Object.assign(disc, { dead: true, deaths: 1 }); leader = disc.id;
+ }
+ const g = revive(offer(source, beneficiary), { type: 'decision', leader });
+ assert.equal(g.response?.recipient, beneficiary); assert.equal(g.revivalFreeIncome?.[beneficiary], g.turn);
+ assert.equal(g.revivalFreeIncome?.ecaz, beneficiary === 'ecaz' ? g.turn : undefined);
+ const income = player(g, 't').spice, done = allow(g);
+ assert.equal(player(done, 't').spice, income + 1); assert.equal(done.pendingAmbassador, null);
+});
+
+void test('BG copied grants and Tleilaxu self-revival use the same beneficiary ledger', () => {
+ const copied = revive(offer(fixture(true), 'ally', true));
+ assert.equal(copied.response?.recipient, 'ally'); assert.equal(copied.pendingAmbassador?.revivalReceipt?.amount, 1);
+ assert.equal(copied.ecazAmbassadors!.tokens.find(t => t.effect === 'beneGesserit')!.zone, 'removed');
+ assert.equal(allow(copied).pendingAmbassador, null);
+ const self = revive(offer(fixture(), 't')), balance = player(self, 't').spice;
+ assert.equal(self.response?.owner, 't'); assert.equal(self.response?.recipient, 't');
+ assert.equal(player(allow(self), 't').spice, balance + 1);
+});
+
+void test('an actual Ghola before or after the Ambassador retains its separate per-use bank reward', () => {
+ for (const gholaFirst of [false, true]) {
+ let g = fixture(); const card = g.deck.find(c => c.effect === 'ghola')!;
+ placeFixtureHand(g, 0, [card]); const balance = player(g, 't').spice;
+ if (gholaFirst) {
+ g = allow(applyAction(g, 'ecaz', { type: 'card', card: card.id, amount: 1 }));
+ assert.equal(g.revivalFreeIncome?.ecaz, undefined);
+ }
+ g = allow(revive(offer(g)));
+ if (!gholaFirst) g = allow(applyAction(g, 'ecaz', { type: 'card', card: card.id, amount: 1 }));
+ assert.equal(player(g, 't').spice, balance + 2); assert.equal(g.revivalFreeIncome?.ecaz, g.turn);
+ assert.equal(player(g).tanks, 0); assert.equal(g.discard.filter(c => c.id === card.id).length, 1);
+ }
+});
+
+void test('income continuation rejects a mismatched parent or amount without replaying physical revival', () => {
+ const g = revive(offer(fixture()));
+ for (const change of [(copy: Game) => { copy.response!.amount = 2; },
+ (copy: Game) => { copy.pendingAmbassador!.event += ':stale'; },
+ (copy: Game) => { copy.pendingAmbassador!.revivalReceipt!.returned = { kind: 'forces', amount: 4, elite: 0 }; }]) {
+ const bad = structuredClone(g); change(bad); const before = structuredClone(bad);
+ assert.throws(() => viewGame(bad, 'ecaz')); assert.throws(() => applyAction(bad, 'entrant', { type: 'passResponse' }));
+ assert.deepEqual(bad, before);
+ }
+});
+
+for (const copied of [false, true]) for (const canceled of [false, true])
+ void test(`original Basic ${copied ? 'copied' : 'native'} grant ${canceled ? 'cancels' : 'allows'} bank income after the real Duke return`, () => {
+ const f = createAmbassadorRevivalIncomeFixture({ copied }), source = f.game;
+ assert.equal(source.advanced, false); assert.deepEqual(source.expansions, ['ecaz', 'ix']);
+ assert.equal(f.originalTreachery.length, 47); assert.equal(f.originalSpice.length, 22);
+ assert.equal(f.afterDeath.dukeVidal!.leader.dead, true); assert.equal(f.afterDeath.dukeVidal!.leader.deaths, 1);
+ assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
+ assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 5).map(c => c.id), f.deal);
+ assert.ok(player(source, f.cancelActor).hand.some(c => c.id === f.cancelCard));
+ const balance = player(source, f.incomeOwner).spice, originalUsage = usage(source, f.owner);
+ const returned = actAmbassadorRevivalIncome(source, f.owner, { type: 'decision', event: f.event, leader: f.duke });
+ assert.equal(returned.dukeVidal!.leader.dead, false); assert.equal(returned.dukeVidal!.controller, null);
+ assert.equal(returned.pendingAmbassador?.stage, 'income'); assert.equal(returned.response?.amount, 1);
+ assert.equal(returned.response?.source, 'ambassadorRevival'); assert.equal(player(returned, f.incomeOwner).spice, balance);
+ const done = canceled ? actAmbassadorRevivalIncome(returned, f.cancelActor,
+ { type: 'card', card: f.cancelCard, mode: 'cancel' }) : allow(returned);
+ assert.equal(done.response, null); assert.equal(done.pendingAmbassador, null);
+ assert.equal(player(done, f.incomeOwner).spice, balance + (canceled ? 0 : 1));
+ assert.deepEqual(usage(done, f.owner), originalUsage); assert.equal(done.revivalFreeIncome?.[f.owner], source.turn);
+ assert.equal(done.dukeVidal!.leader.dead, false); assert.equal(done.dukeVidal!.leader.deaths, 1);
+ assert.equal(done.dukeVidal!.controller, null); assert.equal(player(done, f.owner).leaders.length, 5);
+ assert.equal(done.active, f.entrant); assert.ok(done.movementRemaining!.includes(f.entrant));
+ assert.equal(done.discard.some(c => c.id === f.cancelCard), canceled);
+ const ended = actAmbassadorRevivalIncome(done, f.entrantContinuation.actor, f.entrantContinuation.action);
+ assert.equal(ended.movementRemaining?.includes(f.entrant) ?? false, false);
+ });
diff --git a/tests/basic-ecaz-tleilaxu-occupy.test.ts b/tests/basic-ecaz-tleilaxu-occupy.test.ts
new file mode 100644
index 00000000..13ce5d27
--- /dev/null
+++ b/tests/basic-ecaz-tleilaxu-occupy.test.ts
@@ -0,0 +1,211 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { applyAction, viewGame, type Game, type Player } from '../game/engine';
+import { createAmbassadorRevivalIncomeFixture, actAmbassadorRevivalIncome,
+ advanceAmbassadorRevivalIncome, ambassadorRevivalIncomeClean,
+ type AmbassadorRevivalIncomeStep } from './fixture-ambassador-revival-income';
+import { chooseEcazOccupyLead, openEcazOccupyPlans, stageEcazOccupyCard,
+ ecazOccupyTerritoryForces, type EcazOccupyFixture } from './fixture-ecaz-occupy';
+
+const player = (game: Game, actor: string): Player => {
+ const result = game.players.find(candidate => candidate.id === actor); assert.ok(result); return result;
+};
+const cards = (game: Game) => [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)]
+ .map(card => card.id).sort();
+const traitors = (game: Game) => [...(game.traitorReserve ?? []), ...game.players.flatMap(p =>
+ [...p.traitors, ...p.traitorChoices, ...(p.faceDancers ?? []).map(card => card.leader)])].sort();
+const totalForces = (game: Game) => game.players.map(p => ({ id: p.id,
+ total: p.reserves + p.tanks + Object.values(p.forces).reduce((sum, count) => sum + count, 0) }));
+let originalSetup: Game | undefined;
+function publicBasicSetup(): Game {
+ // This is the actual admitted public Basic start, not the old Advanced
+ // Occupy factory or a started Advanced game whose rules flag was changed.
+ originalSetup ??= createAmbassadorRevivalIncomeFixture().afterSetup;
+ const game = structuredClone(originalSetup);
+ assert.equal(game.advanced, false); assert.equal(game.status, 'playing');
+ assert.equal(game.turn, 1); assert.equal(game.phase, 0);
+ assert.deepEqual(game.expansions, ['ecaz', 'ix']); assert.equal(cards(game).length, 47);
+ return game;
+}
+function originalBlow(game: Game, worm: boolean) {
+ const from = game.spiceDeck.findIndex(card => worm ? 'worm' in card && !card.greatMaker && !card.suppressed
+ : 'territory' in card && !card.discovery);
+ assert.ok(from >= 0); game.spiceDeck.unshift(game.spiceDeck.splice(from, 1)[0]);
+}
+
+/** Explicit CONTROLLED consumer setup after the real Basic public setup:
+ * printed armies return to their own physical reserves, and later Treachery
+ * and Face Dancer identities are conserved swaps. No wallet, phase, alliance,
+ * battle frame, plan, casualty or outcome is installed. All subsequent native
+ * alliance, paid shipment and battle controls execute through applyAction. */
+function controlledBasicCoalition(coside: boolean, lead: 'ecaz' | 'ally') {
+ let game = publicBasicSetup();
+ const initial = structuredClone(game), inventory = cards(game), physicalTraitors = traitors(game);
+ const physicalForces = totalForces(game), startingWallets = game.players.map(p => ({ id: p.id, spice: p.spice }));
+ for (const p of game.players) {
+ p.reserves += Object.values(p.forces).reduce((sum, count) => sum + count, 0); p.forces = {};
+ }
+ assert.deepEqual(totalForces(game), physicalForces);
+ assert.deepEqual(game.players.map(p => ({ id: p.id, spice: p.spice })), startingWallets);
+ const actions: AmbassadorRevivalIncomeStep[] = [];
+ const act = (actor: string, action: AmbassadorRevivalIncomeStep['action']) => {
+ game = actAmbassadorRevivalIncome(game, actor, action, actions);
+ };
+ const reach = (until: (state: Game) => boolean) => {
+ game = advanceAmbassadorRevivalIncome(game, until, actions);
+ };
+ originalBlow(game, false);
+ reach(state => state.turn === 2 && state.phase === 1);
+ assert.ok(!game.spiceSequence && !game.spiceResolution && !game.spiceWindow && !game.nexus);
+ originalBlow(game, true);
+ reach(state => state.phase === 1 && !!state.nexus && !state.spiceResolution &&
+ !state.spiceWindow && ambassadorRevivalIncomeClean(state));
+ const ecaz = 'ecaz', ally = coside ? 'tleilaxu' : 'guild', opponent = 'harkonnen';
+ act(ecaz, { type: 'alliance', target: ally }); act(ally, { type: 'alliance', target: ecaz });
+ assert.equal(player(game, ecaz).ally, ally); assert.equal(player(game, ally).ally, ecaz);
+ reach(state => state.phase === 5 && ambassadorRevivalIncomeClean(state));
+ const territory = 'arrakeen', location = 'arrakeen:10', paid: {
+ actor: string; amount: number; before: number; after: number;
+ }[] = [];
+ for (let count = 0; game.phase === 5 && count < 100; count++) {
+ if (!ambassadorRevivalIncomeClean(game)) {
+ reach(ambassadorRevivalIncomeClean); continue;
+ }
+ const actor = game.active!;
+ if (actor === ecaz || actor === ally || actor === opponent) {
+ const amount = actor === ecaz ? 3 : actor === ally ? 4 : 8;
+ const before = player(game, actor).spice;
+ act(actor, { type: 'ship', amount, territory, sector: 10 }); reach(ambassadorRevivalIncomeClean);
+ paid.push({ actor, amount, before, after: player(game, actor).spice });
+ assert.equal(player(game, actor).forces[location], amount);
+ }
+ act(actor, { type: 'endMovement' });
+ }
+ reach(state => state.phase === 6 && ambassadorRevivalIncomeClean(state));
+ assert.equal(paid.length, 3);
+ for (const shipment of paid)
+ assert.equal(shipment.before - shipment.after,
+ shipment.actor === 'guild' || ally === 'guild' && shipment.actor === ecaz
+ ? Math.ceil(shipment.amount / 2) : shipment.amount,
+ 'Original Basic stronghold rates fund each physical army without a staged wallet');
+ const actor = lead === 'ecaz' ? ecaz : ally;
+ const candidates = player(game, actor).leaders.filter(leader => !leader.dead && !leader.usedAt &&
+ physicalTraitors.includes(leader.id)).sort((a, b) => b.strength - a.strength);
+ const leader = candidates[0]; assert.ok(leader, 'Choose a native non-loyal leader with an actual physical Traitor Card');
+ const dancers = player(game, 'tleilaxu').faceDancers!;
+ if (!dancers.some(dancer => dancer.leader === leader.id && !dancer.revealed)) {
+ const returned = dancers[0].leader, reserve = game.traitorReserve!;
+ const index = reserve.indexOf(leader.id);
+ if (index >= 0) reserve[index] = returned;
+ else {
+ const donor = game.players.find(p => p.traitors.includes(leader.id));
+ assert.ok(donor, 'The selected physical target must retain reserve or native Traitor-hand custody');
+ donor.traitors[donor.traitors.indexOf(leader.id)] = returned;
+ }
+ // Labelled controlled Face Dancer custody exchange, not a natural deal.
+ dancers[0] = { leader: leader.id, revealed: false };
+ }
+ assert.deepEqual(traitors(game), physicalTraitors);
+ const weapon = stageEcazOccupyCard(game, actor, card => card.kind === 'projectile');
+ const shield = stageEcazOccupyCard(game, actor, card => card.kind === 'shield');
+ assert.deepEqual(cards(game), inventory);
+ assert.equal(game.advanced, false);
+ const choice = viewGame(game, game.active!).battleChoices.find(candidate => candidate.territory === territory);
+ assert.ok(choice); const chooser = choice.chooser;
+ act(chooser, { type: 'chooseBattle', territory,
+ target: choice.attacker === chooser ? choice.defender : choice.attacker });
+ assert.equal(game.decision?.kind, 'ecazBattleLead');
+ const fixture: EcazOccupyFixture = { game, initial, afterSetup: initial, ecaz, ally, opponent,
+ territory, location, ecazForces: 3 };
+ return { fixture, actor, leader: leader.id, weapon, shield, inventory, physicalTraitors, physicalForces, actions, paid,
+ staging: 'Controlled conserved board/card/Face Dancer positions after a genuine public Basic setup; original worm alliance, funded shipments and native battle controls.' };
+}
+
+function settle(state: Game): Game {
+ let game = state;
+ for (let count = 0; count < 100; count++) {
+ if (game.response) {
+ const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
+ game = applyAction(game, actor.id, { type: 'passResponse' });
+ } else if (game.decision?.kind === 'battleLosses')
+ game = applyAction(game, game.decision.player, { type: 'decision', choice: 0 });
+ else if (game.pendingTreacheryDiscard)
+ game = applyAction(game, game.host, { type: 'advanceBots' });
+ else return game;
+ }
+ throw Error('The native Basic coalition aftermath did not reach its next control');
+}
+function winControlledBattle(source: ReturnType, lead: 'ecaz' | 'ally') {
+ const { fixture, actor } = source;
+ let game = openEcazOccupyPlans(chooseEcazOccupyLead(fixture, lead));
+ const before = structuredClone(game), profile = viewGame(game, actor).battle!.ecazOccupy!.profile!;
+ assert.equal(profile.advanced, false); assert.equal(profile.fixedEcazDial, 2); assert.equal(profile.maxSupport, 0);
+ assert.equal(profile.planOwner, actor); assert.equal(profile.forceOwner, fixture.ally);
+ const loser = player(game, fixture.opponent).leaders.filter(leader => !leader.dead && !leader.usedAt)
+ .sort((a, b) => a.strength - b.strength)[0]; assert.ok(loser);
+ game = applyAction(game, actor, { type: 'battlePlan', dial: 4, support: 0,
+ leader: source.leader, weapon: source.weapon, defense: source.shield });
+ game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: 0, support: 0, leader: loser.id });
+ const voters = viewGame(game, actor).battle!.traitorVoters;
+ assert.ok(voters.includes(fixture.ecaz) && voters.includes(fixture.ally));
+ for (let count = 0; game.battle && count < 10; count++) {
+ const view = viewGame(game, actor).battle!;
+ const voter = view.traitorVoters.find(id => !view.traitorSubmitted.includes(id));
+ if (!voter) break;
+ game = applyAction(game, voter, { type: 'traitorCall', call: false });
+ }
+ game = settle(game); assert.equal(game.decision?.kind, 'battleCards');
+ assert.equal(game.decision.player, actor);
+ return { game, before, loser: loser.id, bounty: loser.strength };
+}
+function assertCompleted(game: Game, source: ReturnType) {
+ assert.equal(game.advanced, false); assert.equal(game.battle, null); assert.equal(game.decision, null);
+ assert.deepEqual(cards(game), source.inventory); assert.deepEqual(traitors(game), source.physicalTraitors);
+ assert.deepEqual(totalForces(game), source.physicalForces);
+ assert.ok(player(game, source.actor).hand.some(card => card.id === source.weapon));
+ assert.ok(game.discard.some(card => card.id === source.shield));
+ const collection = advanceAmbassadorRevivalIncome(game,
+ state => state.phase === 7 && ambassadorRevivalIncomeClean(state));
+ assert.equal(collection.phase, 7, 'Original battle readiness continues to native collection');
+}
+const army = (game: Game, source: ReturnType, actor: string) =>
+ ecazOccupyTerritoryForces(game, actor, source.fixture.territory);
+
+for (const lead of ['ecaz', 'ally'] as const)
+ void test(`Basic admitted Ecaz/Tleilaxu Occupy suppresses Face Dance for ${lead} lead`, () => {
+ const source = controlledBasicCoalition(true, lead), win = winControlledBattle(source, lead);
+ let game = applyAction(win.game, source.actor, { type: 'decision', discard: [source.shield] });
+ game = settle(game);
+ assert.equal(army(game, source, 'ecaz'), 1); assert.equal(army(game, source, 'tleilaxu'), 2);
+ assert.equal(player(game, source.actor).leaders.find(leader => leader.id === source.leader)!.dead, false);
+ assert.ok(player(game, 'tleilaxu').faceDancers!.some(dancer => dancer.leader === source.leader && !dancer.revealed));
+ assert.equal(player(game, source.actor).spice, player(win.before, source.actor).spice + win.bounty,
+ 'Basic support is zero and only the actual original leader bounty is credited');
+ assertCompleted(game, source);
+ });
+
+for (const lead of ['ecaz', 'ally'] as const) for (const count of [0, 3])
+ void test(`Basic admitted external Tleilaxu Face Dance replaces both ${lead}-led armies with ${count} forces`, () => {
+ const source = controlledBasicCoalition(false, lead), win = winControlledBattle(source, lead);
+ let game = applyAction(win.game, source.actor, { type: 'decision', discard: [source.shield] });
+ game = settle(game);
+ assert.ok(game.decision?.kind === 'faceDance');
+ assert.equal(game.decision.player, 'tleilaxu'); assert.equal(game.decision.winner, source.actor);
+ assert.equal(game.decision.leader, source.leader);
+ assert.equal(army(game, source, 'ecaz'), 1); assert.equal(army(game, source, 'guild'), 2);
+ const before = structuredClone(game), other = source.actor === 'ecaz' ? 'guild' : 'ecaz';
+ game = applyAction(game, 'tleilaxu', { type: 'decision', reveal: true, sources: { reserves: count }, sector: 10 });
+ game = settle(game);
+ assert.equal(army(game, source, 'ecaz'), 0); assert.equal(army(game, source, 'guild'), 0);
+ assert.equal(army(game, source, 'tleilaxu'), count);
+ assert.equal(player(game, 'ecaz').reserves, player(before, 'ecaz').reserves + 1);
+ assert.equal(player(game, 'guild').reserves, player(before, 'guild').reserves + 2);
+ assert.equal(player(game, 'tleilaxu').reserves, player(before, 'tleilaxu').reserves - count);
+ assert.equal(player(game, source.actor).leaders.find(leader => leader.id === source.leader)!.dead, true);
+ assert.equal(player(game, source.fixture.opponent).leaders.find(leader => leader.id === win.loser)!.dead, true);
+ assert.equal(player(game, 'tleilaxu').spice, player(before, 'tleilaxu').spice);
+ assert.equal(player(game, source.actor).spice, player(before, source.actor).spice, 'No second bounty after replacement');
+ assert.deepEqual(player(game, other).hand, player(before, other).hand);
+ assert.ok(player(game, 'tleilaxu').faceDancers!.some(dancer => dancer.leader === source.leader && dancer.revealed));
+ assertCompleted(game, source);
+ });
diff --git a/tests/fixture-ambassador-revival-income.ts b/tests/fixture-ambassador-revival-income.ts
new file mode 100644
index 00000000..c915d237
--- /dev/null
+++ b/tests/fixture-ambassador-revival-income.ts
@@ -0,0 +1,228 @@
+import assert from 'node:assert/strict';
+import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
+import { AMBASSADOR_EFFECTS, type AmbassadorEffect } from '../game/ecaz-ambassadors';
+import { DUKE_VIDAL_ID } from '../game/duke-vidal';
+import {
+ actDukeAmbassador, dukeAmbassadorClean, dukeAmbassadorPlayer, nextDukeAmbassadorStep,
+ type DukeAmbassadorStep, type DukeAmbassadorTransition,
+} from './fixture-duke-ambassador';
+
+export type AmbassadorRevivalIncomeStep = DukeAmbassadorStep;
+export type AmbassadorRevivalIncomeTransition = DukeAmbassadorTransition;
+export const ambassadorRevivalIncomePlayer = dukeAmbassadorPlayer;
+export const ambassadorRevivalIncomeClean = dukeAmbassadorClean;
+export const actAmbassadorRevivalIncome = actDukeAmbassador;
+
+/** The native Tleilaxu has its ordinary opportunity to decline a Face Dance.
+ * All other controls reuse the original Duke programme's no-spending driver. */
+export function nextAmbassadorRevivalIncomeStep(game: Game): AmbassadorRevivalIncomeStep | null {
+ if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard && game.decision?.kind === 'faceDance')
+ return { actor: game.decision.player, action: { type: 'decision', reveal: false } };
+ return nextDukeAmbassadorStep(game);
+}
+export function advanceAmbassadorRevivalIncome(game: Game, until: (state: Game) => boolean,
+ actions: AmbassadorRevivalIncomeStep[] = []): Game {
+ for (let count = 0; count < 1800; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished', 'The original Ambassador income source ended before its next control');
+ const step = nextAmbassadorRevivalIncomeStep(game);
+ assert.ok(step, 'Choose the actual Ambassador effect or sealed plan before advancing');
+ game = actAmbassadorRevivalIncome(game, step.actor, step.action, actions);
+ }
+ throw Error('The original Ambassador income source did not reach its finite boundary');
+}
+
+/** Select only the original packet's nine Fisher–Yates draws when the final
+ * native traitor choice initializes starting forces. No live token is moved. */
+function originalIncomePacket(copied: boolean) {
+ const pool = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz');
+ const supply: Exclude[] = copied
+ ? ['beneGesserit', 'atreides', 'choam', 'emperor', 'fremen']
+ : ['tleilaxu', 'atreides', 'choam', 'emperor', 'fremen'];
+ const desired = [...supply, ...pool.filter(effect => !supply.includes(effect))];
+ const current = [...pool], rolls: number[] = [];
+ for (let i = current.length - 1; i > 0; i--) {
+ const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
+ rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
+ [current[i], current[j]] = [current[j], current[i]];
+ }
+ assert.deepEqual(current, desired); return { supply, rolls };
+}
+function withOriginalIncomePacket(rolls: readonly number[], run: () => T): T {
+ const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
+ const original = crypto.getRandomValues.bind(crypto); let cursor = 0;
+ crypto.getRandomValues = (array: V): V => {
+ if (array instanceof Uint32Array && array.length === 1)
+ array[0] = cursor < rolls.length ? rolls[cursor++] : 0xffffffff;
+ else Reflect.apply(original, crypto, [array]);
+ return array;
+ };
+ try {
+ const result = run(); assert.equal(cursor, rolls.length); return result;
+ } finally {
+ if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
+ else Reflect.deleteProperty(crypto, 'getRandomValues');
+ }
+}
+
+/** Public Basic Ecaz/Harkonnen/Tleilaxu/Guild with the original selected E3+Ix
+ * decks. Original poison plans kill the acquired shared Duke in a real battle;
+ * the zero-dial attacker tie leaves Ecaz no force or native-leader alternative.
+ * Guild's original dealt Karama stays held for a later genuine income response.
+ * Stop at the free revival grant, before choosing a return or earning income. */
+export function createAmbassadorRevivalIncomeFixture(options: { copied?: boolean } = {}) {
+ const copied = options.copied ?? false, packet = originalIncomePacket(copied);
+ const owner = 'ecaz', opponent = 'harkonnen', tleilaxu = 'tleilaxu', entrant = 'guild';
+ let game = createGame('AMBASSADORREVIVALINCOME', newPlayer(owner, 'Ecaz', 'ecaz'), false, ['ecaz', 'ix']);
+ for (const faction of ['harkonnen', 'tleilaxu', 'guild'] as const)
+ joinGame(game, newPlayer(faction, faction, faction));
+ const actions: AmbassadorRevivalIncomeStep[] = [];
+ const act = (actor: string, action: Action) => { game = actAmbassadorRevivalIncome(game, actor, action, actions); };
+ const reach = (until: (state: Game) => boolean) => { game = advanceAmbassadorRevivalIncome(game, until, actions); };
+ const transition = (step: AmbassadorRevivalIncomeStep,
+ until: (state: Game) => boolean): AmbassadorRevivalIncomeTransition => {
+ const before = structuredClone(game); act(step.actor, step.action); reach(until);
+ return { before, step: structuredClone(step), after: structuredClone(game) };
+ };
+ for (const player of game.players) act(player.id, { type: 'ready' });
+ const lobby = structuredClone(game);
+ act(game.host, { type: 'start' });
+ const setup = structuredClone(game);
+ assert.equal(game.status, 'setup'); assert.equal(game.advanced, false);
+ assert.deepEqual(game.expansions, ['ecaz', 'ix']);
+ assert.ok(game.players.every(player => player.hand.length === 0));
+ const originalTreachery = game.deck.map(card => card.id).sort();
+ const poisons = game.deck.filter(card => card.kind === 'poison').slice(0, 2);
+ const fillers = game.deck.filter(card => card.kind === 'worthless').slice(0, 2);
+ const karama = game.deck.find(card => card.effect === 'karama');
+ assert.equal(poisons.length, 2); assert.equal(fillers.length, 2); assert.ok(karama);
+ const deal = [poisons[0].id, poisons[1].id, fillers[0].id, fillers[1].id, karama.id];
+ const dealOrder = deal.map((id, position) => {
+ const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
+ [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
+ return { position, from, card: id };
+ });
+ const beforeStartingDeal = structuredClone(game);
+ let packetCreation: AmbassadorRevivalIncomeTransition | null = null;
+ for (let count = 0; game.status === 'setup' && count < 80; count++) {
+ const step = nextAmbassadorRevivalIncomeStep(game); assert.ok(step);
+ const initializesForces = game.setupStage === 'traitors' && step.action.type === 'traitor' &&
+ game.players.filter(player => player.traitorChoices.length > 0).length === 1;
+ if (initializesForces) {
+ const before = structuredClone(game);
+ game = withOriginalIncomePacket(packet.rolls, () => applyAction(game, step.actor, step.action));
+ actions.push(structuredClone(step));
+ packetCreation = { before, step: structuredClone(step), after: structuredClone(game) };
+ } else act(step.actor, step.action);
+ }
+ assert.equal(game.status, 'playing'); assert.ok(packetCreation);
+ const afterSetup = structuredClone(game);
+ assert.ok(ambassadorRevivalIncomePlayer(game, owner).hand.some(card => card.id === poisons[0].id));
+ assert.ok(ambassadorRevivalIncomePlayer(game, opponent).hand.some(card => card.id === poisons[1].id));
+ assert.ok(ambassadorRevivalIncomePlayer(game, entrant).hand.some(card => card.id === karama.id));
+ assert.equal(ambassadorRevivalIncomePlayer(game, tleilaxu).faceDancers?.length, 3);
+ const ecazToken = game.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz')!;
+ const revivalToken = game.ecazAmbassadors!.tokens.find(token => token.effect === (copied ? 'beneGesserit' : 'tleilaxu'))!;
+ assert.equal(ecazToken.zone, 'supply'); assert.equal(revivalToken.zone, 'supply');
+ assert.equal(game.ecazAmbassadors!.tokens.find(token => token.effect === 'tleilaxu')!.zone, copied ? 'pool' : 'supply');
+ const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
+ const spiceOrder = [0, 1].map(position => {
+ const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
+ assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
+ game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
+ });
+ assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
+ reach(state => state.turn === 1 && state.phase === 4 && !state.response && !state.phaseOpening &&
+ state.decision?.kind === 'ecazPlacement');
+ const placements = [
+ transition({ actor: owner, action: { type: 'decision', token: ecazToken.id, territory: 'arrakeen' } },
+ state => !state.response && state.decision?.kind === 'ecazPlacement'),
+ transition({ actor: owner, action: { type: 'decision', token: revivalToken.id, territory: 'habbanya_ridge_sietch' } },
+ state => !state.response && state.decision?.kind === 'ecazPlacement'),
+ ];
+ assert.equal(ambassadorRevivalIncomePlayer(placements[0].after).spice,
+ ambassadorRevivalIncomePlayer(placements[0].before).spice - 1);
+ assert.equal(ambassadorRevivalIncomePlayer(placements[1].after).spice,
+ ambassadorRevivalIncomePlayer(placements[1].before).spice - 2);
+ act(owner, { type: 'decision', decline: true });
+ reach(state => state.phase === 5 && state.active === owner && ambassadorRevivalIncomeClean(state));
+ const ownShipment = transition({ actor: owner,
+ action: { type: 'ship', amount: 1, territory: 'carthag', sector: 11 } }, ambassadorRevivalIncomeClean);
+ reach(state => state.phase === 5 && state.active === opponent && ambassadorRevivalIncomeClean(state));
+ const acquisitionArrival = transition({ actor: opponent,
+ action: { type: 'ship', amount: 1, territory: 'arrakeen', sector: 10 } },
+ state => !state.response && state.pendingAmbassador?.stage === 'offer');
+ assert.equal(game.pendingAmbassador!.token, ecazToken.id);
+ const acquisition = transition({ actor: owner, action: { type: 'decision', event: game.pendingAmbassador!.event,
+ trigger: true, beneficiary: owner, choice: 'duke' } }, ambassadorRevivalIncomeClean);
+ assert.equal(game.dukeVidal!.controller, owner); assert.equal(game.dukeVidal!.source, 'ecaz');
+ assert.equal(game.dukeVidal!.leader.dead, false);
+ reach(state => state.phase === 6 && !state.battle && ambassadorRevivalIncomeClean(state));
+ assert.equal(game.active, owner, 'The actual first native circle gives Ecaz the battle choice');
+ const beforeBattle = structuredClone(game), battleStep: AmbassadorRevivalIncomeStep = {
+ actor: owner, action: { type: 'chooseBattle', territory: 'carthag', target: opponent },
+ };
+ act(battleStep.actor, battleStep.action);
+ reach(state => !!state.battle && ambassadorRevivalIncomeClean(state) && !state.battle.preparation &&
+ (!state.battle.preLeader || state.battle.preLeader.closed));
+ assert.equal(game.battle!.attacker, owner);
+ const enemyLeader = ambassadorRevivalIncomePlayer(game, opponent).leaders
+ .filter(leader => !leader.dead && !leader.usedAt).sort((a, b) => a.strength - b.strength)[0]; assert.ok(enemyLeader);
+ const planSteps: AmbassadorRevivalIncomeStep[] = [
+ { actor: owner, action: { type: 'battlePlan', leader: DUKE_VIDAL_ID, dial: 0, support: 0, weapon: poisons[0].id } },
+ { actor: opponent, action: { type: 'battlePlan', leader: enemyLeader.id, dial: 0, support: 0, weapon: poisons[1].id } },
+ ];
+ for (const step of planSteps) {
+ act(step.actor, step.action);
+ reach(state => !state.response && !state.phaseOpening && !state.decision);
+ }
+ const revealed = structuredClone(game);
+ reach(state => !state.battle && ambassadorRevivalIncomeClean(state));
+ const afterDeath = structuredClone(game);
+ assert.equal(game.dukeVidal!.leader.dead, true); assert.equal(game.dukeVidal!.leader.deaths, 1);
+ assert.equal(game.dukeVidal!.controller, null);
+ assert.equal(ambassadorRevivalIncomePlayer(game).tanks, 0);
+ assert.ok(ambassadorRevivalIncomePlayer(game).leaders.every(leader => !leader.dead));
+ assert.equal(ambassadorRevivalIncomePlayer(game).forces['carthag:11'], 1);
+ assert.equal(ambassadorRevivalIncomePlayer(game, opponent).leaders.find(leader => leader.id === enemyLeader.id)!.dead, true);
+ reach(state => state.turn === 2 && state.phase === 5 && state.active === entrant && ambassadorRevivalIncomeClean(state));
+ const beforeArrival = structuredClone(game);
+ assert.equal(ambassadorRevivalIncomePlayer(game, entrant).shipped, false);
+ assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === revivalToken.id)!.zone, 'placed');
+ const arrival = transition({ actor: entrant,
+ action: { type: 'ship', amount: 1, territory: 'habbanya_ridge_sietch', sector: 17 } },
+ state => !state.response && state.pendingAmbassador?.stage === 'offer');
+ const offer = structuredClone(game), event = game.pendingAmbassador!.event;
+ assert.equal(game.pendingAmbassador!.token, revivalToken.id);
+ const triggerStep: AmbassadorRevivalIncomeStep = { actor: owner,
+ action: { type: 'decision', event, trigger: true, beneficiary: owner, choice: 'effect' } };
+ act(triggerStep.actor, triggerStep.action);
+ let beforeCopy: Game | null = null, copyStep: AmbassadorRevivalIncomeStep | null = null;
+ if (copied) {
+ assert.equal(game.pendingAmbassador?.stage, 'copy'); beforeCopy = structuredClone(game);
+ copyStep = { actor: owner, action: { type: 'decision', event, effect: 'tleilaxu' } };
+ act(copyStep.actor, copyStep.action);
+ }
+ assert.equal(game.pendingAmbassador?.stage, 'revival');
+ assert.equal(game.pendingAmbassador!.beneficiary, owner); assert.equal(game.pendingAmbassador!.entrant, entrant);
+ assert.equal(game.pendingAmbassador!.effect, 'tleilaxu'); assert.equal(game.decision?.player, owner);
+ assert.equal(game.active, entrant); assert.equal(game.turn, 2);
+ assert.equal(ambassadorRevivalIncomePlayer(game).tanks, 0);
+ assert.ok(ambassadorRevivalIncomePlayer(game).leaders.every(leader => !leader.dead));
+ assert.ok(ambassadorRevivalIncomePlayer(game, entrant).hand.some(card => card.id === karama.id));
+ assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)]
+ .map(card => card.id).sort(), originalTreachery);
+ const currentGrant = structuredClone(game), ownerView = viewGame(game, owner), entrantView = viewGame(game, entrant);
+ return { game, lobby, setup, beforeStartingDeal, afterSetup, packetCreation, packet, deal, dealOrder,
+ spiceOrder, originalTreachery, originalSpice, placements, ownShipment, acquisitionArrival, acquisition,
+ beforeBattle, battleStep, planSteps, revealed, afterDeath, beforeArrival, arrival, offer,
+ triggerStep, beforeCopy, copyStep, currentGrant, ownerView, entrantView,
+ copied, advanced: false as const, owner, beneficiary: owner, opponent, entrant, tleilaxu, incomeOwner: tleilaxu,
+ duke: DUKE_VIDAL_ID, enemyLeader: enemyLeader.id, token: revivalToken.id, acquisitionToken: ecazToken.id,
+ event, territory: 'habbanya_ridge_sietch', location: 'habbanya_ridge_sietch:17',
+ poison: { ecaz: poisons[0].id, harkonnen: poisons[1].id }, cancelCard: karama.id, cancelActor: entrant,
+ entrantContinuation: { actor: entrant, action: { type: 'endMovement' } } as AmbassadorRevivalIncomeStep,
+ actions, sourceActionCount: actions.length,
+ staging: ['Only five unopened starting-deal positions, two unopened ordinary land Spice positions and original scalar lotteries are selected. Nine recorded draws select the initial native Ambassador packet during the final original traitor choice. Other scalar draws preserve original Fisher–Yates order; UUID/byte entropy remains native. Public Basic Ecaz/Harkonnen/Tleilaxu/Guild setup selects original E3+Ix decks, deals actual Face Dancers and gives Guild the physical Karama. Paid 1+2 Ambassador placements, paid shipments, actual Ecaz-token Duke acquisition and sealed native poison plans produce his first death. Ecaz wins the 0–0 attacker tie at dial 0 with its force intact and no dead native leaders. Original readiness and movement endings reach the next turn; Guild pays and enters the previously placed Tleilaxu or BG marker. The native grant is open before any revival or income action; Guild still holds its original Karama for the owner-controlled cancellation programme. No dealt card, force, resource, death, token, clock, queue or effect receipt is installed. Native Tleilaxu voluntary Face Dance is declined through its original control.'] };
+}
+export type AmbassadorRevivalIncomeFixture = ReturnType;
diff --git a/tests/tleilaxu-ambassador-income.test.ts b/tests/tleilaxu-ambassador-income.test.ts
new file mode 100644
index 00000000..ce80c1b5
--- /dev/null
+++ b/tests/tleilaxu-ambassador-income.test.ts
@@ -0,0 +1,123 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ createAmbassadorRevivalReceipt,
+ validateAmbassadorRevivalReceipt,
+ AmbassadorRevivalIncomeError,
+ type AmbassadorRevivalSource,
+ type AmbassadorRevivalReturn,
+ type AmbassadorRevivalReceipt,
+} from '../game/tleilaxu-ambassador-income';
+
+const source = (): AmbassadorRevivalSource => ({
+ event: 'original-ambassador-event', owner: 'ecaz', entrant: 'guild', token: 'ambassador-tleilaxu',
+ territory: 'arrakeen', sector: 10, turn: 3, phase: 5, beneficiary: 'atreides', incomeOwner: 'tleilaxu',
+});
+const forces = (): AmbassadorRevivalReturn => ({ kind: 'forces', amount: 3, elite: 1 });
+
+// Pure committed-return witnesses, not earned game histories or live custody
+// checks. Native Ambassador and the original free-income ledger stay engine-owned.
+void test('positive force, leader and KH returns produce detached one-spice Ambassador receipts', () => {
+ for (const returned of [forces(), { kind: 'leader', leader: 'duke-vidal', deaths: 2 },
+ { kind: 'kwisatz', cycle: 1 }] as AmbassadorRevivalReturn[]) {
+ const originalSource = source(), before = structuredClone({ source: originalSource, returned });
+ Object.freeze(originalSource); Object.freeze(returned);
+ const receipt = createAmbassadorRevivalReceipt(originalSource, returned);
+ assert.equal(receipt.version, 1);
+ assert.equal(receipt.amount, 1);
+ assert.deepEqual(receipt.source, originalSource);
+ assert.deepEqual(receipt.returned, returned);
+ assert.notEqual(receipt.source, originalSource);
+ assert.notEqual(receipt.returned, returned);
+ validateAmbassadorRevivalReceipt(originalSource, JSON.parse(JSON.stringify(receipt)));
+ assert.deepEqual({ source: originalSource, returned }, before);
+ receipt.source.territory = 'carthag';
+ assert.equal(originalSource.territory, 'arrakeen');
+ if (receipt.returned.kind === 'forces') receipt.returned.amount = 4;
+ else if (receipt.returned.kind === 'leader') receipt.returned.deaths = 3;
+ else receipt.returned.cycle = 2;
+ assert.deepEqual(returned, before.returned);
+ }
+});
+
+void test('receipt binding is deterministic across equivalent public source property insertion orders', () => {
+ const original = source(), reordered = Object.fromEntries(Object.entries(original).reverse()) as AmbassadorRevivalSource;
+ const first = createAmbassadorRevivalReceipt(original, forces());
+ const second = createAmbassadorRevivalReceipt(reordered, forces());
+ assert.equal(first.signature, second.signature);
+ validateAmbassadorRevivalReceipt(original, second);
+ validateAmbassadorRevivalReceipt(reordered, first);
+ const firstPhase = { ...original, phase: 0, sector: 1, turn: 1 };
+ validateAmbassadorRevivalReceipt(firstPhase, createAmbassadorRevivalReceipt(firstPhase, { kind: 'forces', amount: 1, elite: 0 }));
+ const finalSector = { ...original, sector: 18, phase: Number.MAX_SAFE_INTEGER };
+ validateAmbassadorRevivalReceipt(finalSector, createAmbassadorRevivalReceipt(finalSector, { kind: 'forces', amount: 4, elite: 4 }));
+});
+
+void test('a separately valid receipt from another source cannot settle this original Ambassador income', () => {
+ const original = source();
+ const changes: Partial[] = [
+ { event: 'another-event' }, { owner: 'another-owner' }, { entrant: 'another-entrant' },
+ { token: 'copied-ambassador' }, { territory: 'carthag' }, { sector: 11 },
+ { turn: 4 }, { phase: 1 }, { beneficiary: 'another-beneficiary' }, { incomeOwner: 'another-income-owner' },
+ ];
+ for (const change of changes) {
+ const changed = { ...original, ...change }, receipt = createAmbassadorRevivalReceipt(changed, forces());
+ validateAmbassadorRevivalReceipt(changed, receipt);
+ assert.throws(() => validateAmbassadorRevivalReceipt(original, receipt), AmbassadorRevivalIncomeError);
+ }
+});
+
+void test('force types, leader identity and preserved death or KH cycle values remain bound after commitment', () => {
+ const original = source();
+ const changedForces = createAmbassadorRevivalReceipt(original, forces());
+ assert.ok(changedForces.returned.kind === 'forces');
+ changedForces.returned.elite = 0;
+ assert.throws(() => validateAmbassadorRevivalReceipt(original, changedForces), /committed return/);
+ for (const returned of [
+ { kind: 'forces', amount: 4, elite: 1 },
+ { kind: 'leader', leader: 'ecaz-0', deaths: 1 },
+ { kind: 'leader', leader: 'duke-vidal', deaths: 3 },
+ { kind: 'kwisatz', cycle: 2 },
+ ] as AmbassadorRevivalReturn[]) {
+ const initial: AmbassadorRevivalReturn = returned.kind === 'forces' ? forces() :
+ returned.kind === 'leader' ? { kind: 'leader', leader: 'duke-vidal', deaths: 2 } : { kind: 'kwisatz', cycle: 1 };
+ const receipt = createAmbassadorRevivalReceipt(original, initial);
+ receipt.returned = returned;
+ assert.throws(() => validateAmbassadorRevivalReceipt(original, receipt), /committed return/);
+ }
+});
+
+void test('malformed public sources and zero, excessive or untyped returns cannot create income receipts', () => {
+ const original = source();
+ for (const invalid of [
+ { ...original, event: '' }, { ...original, owner: ' ' }, { ...original, entrant: 1 },
+ { ...original, token: undefined }, { ...original, territory: '' }, { ...original, beneficiary: '' },
+ { ...original, incomeOwner: '' }, { ...original, sector: 0 }, { ...original, sector: 19 },
+ { ...original, sector: 1.5 }, { ...original, turn: 0 }, { ...original, turn: NaN },
+ { ...original, phase: -1 }, { ...original, phase: 0.5 }, { ...original, phase: Infinity },
+ { ...original, phase: Number.MAX_SAFE_INTEGER + 1 }, { ...original, wallet: 1 },
+ ]) assert.throws(() => createAmbassadorRevivalReceipt(invalid as AmbassadorRevivalSource, forces()), AmbassadorRevivalIncomeError);
+ for (const invalid of [
+ { kind: 'forces', amount: 0, elite: 0 }, { kind: 'forces', amount: 5, elite: 0 },
+ { kind: 'forces', amount: 1.5, elite: 0 }, { kind: 'forces', amount: 3, elite: -1 },
+ { kind: 'forces', amount: 3, elite: 4 }, { kind: 'forces', amount: 3, elite: 0.5 },
+ { kind: 'forces', amount: 3 }, { kind: 'forces', amount: 3, elite: 0, cost: 0 },
+ { kind: 'leader', leader: '', deaths: 1 }, { kind: 'leader', leader: 'ecaz-0', deaths: 0 },
+ { kind: 'leader', leader: 'ecaz-0', deaths: Number.MAX_SAFE_INTEGER + 1 },
+ { kind: 'kwisatz', cycle: 0 }, { kind: 'kwisatz', cycle: NaN }, { kind: 'kwisatz', cycle: 1.5 },
+ { kind: 'unknown', amount: 1 },
+ ]) assert.throws(() => createAmbassadorRevivalReceipt(original, invalid as AmbassadorRevivalReturn), AmbassadorRevivalIncomeError);
+});
+
+void test('receipt reward, source shape, format version and signature cannot be changed into another bank award', () => {
+ const original = source(), receipt = createAmbassadorRevivalReceipt(original, forces());
+ for (const changed of [
+ { ...receipt, version: 2 }, { ...receipt, amount: 0 }, { ...receipt, amount: 2 },
+ { ...receipt, signature: '' }, { ...receipt, extra: true },
+ { ...receipt, source: { ...receipt.source, hiddenBalance: 5 } },
+ { ...receipt, returned: { kind: 'forces', amount: 0, elite: 0 } },
+ ]) assert.throws(() => validateAmbassadorRevivalReceipt(original, changed as AmbassadorRevivalReceipt), AmbassadorRevivalIncomeError);
+ const before = structuredClone(receipt);
+ validateAmbassadorRevivalReceipt(original, receipt);
+ assert.deepEqual(receipt, before);
+});