The original Ambassador effect purchases one Treachery Card for 3 spice.
+ Pay continues that purchase; there is no additional decline option.
+ {offer && <>
+
Emperor Nexus · Secret Ally
+
Personally hold the full three-spice price to keep your spice while acquiring
+ the original card. Using this option spends your Emperor Nexus Card.
+
Source recommendation, not a publisher ruling: apply the Emperor
+ Secret Ally purchase benefit to the original Richese Ambassador purchase, outside
+ Bidding. The bank remains unpaid; no seller, reimbursement or substitute income is
+ created. {offer.policy}
+ {offer.blocked && }
+ >}
+
+ {entitled && }
+
+ );
+}
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 87702886..6b06e3ce 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -29,6 +29,23 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
+**Emperor Secret Ally original Ambassador purchase — 14 October.** Original
+Richese trigger now offers uniform beneficiary Pay/eligible Use under visibly
+provisional outside-Bidding purchase composition. No new decline, bank payment,
+reimbursement or fake auction. Version-three receipts bind actual entry/token/
+buyer/card; native phase/resume authority, draw/recycling, private inability
+and movement/worm suffix remain. Actual allied Harkonnen Pay bonus runs once;
+forming alliance still disposes held Nexus originally.
+Affected13-file union111/111, types/lint/build and eight native API profiles
+pass. Four actual390px surfaces prove Basic Use11→11, Advanced Pay11→8,
+irrelevant-card Pay and phase-one worm Use11→11, one original card and real
+entrant/spice controls without overflow/errors. CHOAM closing market only
+sells/trades; no missing direct purchase producer is invented. Other real
+purchase sources, full combinations, assurance and deployed gates remain.
+[Canonical policy/proof](NEXUS_EMPEROR_RULES.md#recommended-original-ambassador-purchase--14-october-2026).
+No saved/deployed room touched; proof receipts remain outside Git. Build
+retains existing route-classification/chunk-size warnings.
+
**Emperor Secret Ally original Richese purchases — 14 October.** Basic/Advanced
cache, legal self-cache and Advanced Black Market now connect uniform buyer
Pay/eligible Use after original Richese Betrayal. Recommended provisional
@@ -38,8 +55,8 @@ bank provenance does not create payment on Use. Signed source receipts preserve
actual lot; original stock, hand limits, bonus and source successor run once.
Affected10-file union108/108, types/lint/build and twenty native API profiles
pass. Actual390px cache/Black/self-cache/diverted-cache Use retains wallets,
-one lot and original bonus/round, with no overflow/errors. Direct CHOAM/
-Ambassador purchases, full combinations, assurance and publication remain open.
+one lot and original bonus/round, with no overflow/errors. The distinct
+Ambassador composition is above; full combinations/assurance/publication remain.
[Source policy and proof](NEXUS_EMPEROR_RULES.md#recommended-richese-seller-unpaid-purchase--14-october-2026).
No saved/deployed room touched; proof receipts remain outside Git. Existing
build route-classification/chunk-size warnings remain.
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 684bb874..8cbacbe2 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,42 @@
# Dune implementation status
+## 14 October 2026 — Emperor Nexus original Ambassador purchase
+
+Connected the existing fixed-three Richese Ambassador purchase classification
+to the printed Emperor Nexus purchase panel without inventing a Bidding-only
+condition. Uniform original-beneficiary Pay/eligible Use follows committed
+trigger, not hidden face/funds/hand capacity. No new beneficiary veto, bank
+payment, reimbursement or fake auction. Original conditional unavailable Pay
+consumes no spice/card and keeps the token spent. Actual unallied holder alone
+can prove/retain three own spice; allied beneficiaries cannot Use.
+
+Version-three signed receipts retain source entry/token/entrant, actual phase,
+buyer, physical drawn card and unchanged price. Malformed original source is a
+hard error; private inability only disables Use. Original phase/resume authority
+also governs source-bound Homeworld arrivals. Draw/recycle happens once at
+commit, never at offer. Original entrant active, movement/spice suffix and native
+bonus stay original; actual Truthtrance discard restores the same decision.
+Minimal profiles share the legal projection. Table decision headings now name
+Ambassador/seller payment and compulsory funding instead of battle spoils.
+
+Affected13-file union111/111, types/lint/build and eight Basic/Advanced actual
+shipment/worm API profiles pass. Four real390px surfaces prove Basic Use11→11,
+Advanced Pay11→8, irrelevant Guild-card Pay and Advanced phase-one worm Use11→11,
+one original card and native movement/spice controls, no overflow/errors.
+Native allied Harkonnen paid acquisition gets its bonus once, automatically
+when nobody can cancel. Fixture-only nonexistent False Wall South Spice Card,
+retained-after-alliance Nexus and forced-response assumptions were removed.
+Source now uses real Rock Outcroppings blow and legal native Fremen reinforcement.
+Copied-action/receipt/default/wording pins are deleted, not re-pinned.
+Temporary QA alias mistakes were fixed against installed Vinext shims; clean
+final surfaces are recorded separately from those failed harness boots.
+
+CHOAM closing market only sells to bank/trades; no direct purchase producer is
+invented. Other real acquisition sources, full combinations, broad assurance
+and deployed acceptance remain open. No saved/deployed room touched. Proof
+receipts remain outside Git; existing build classification/chunk warnings stay.
+[Canonical source and policy](NEXUS_EMPEROR_RULES.md#recommended-original-ambassador-purchase--14-october-2026).
+
## 14 October 2026 — Emperor Nexus original Richese purchase retention
Connected original positive Richese cache and Advanced Black Market sold lots
diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md
index 4c729698..c7f88042 100644
--- a/docs/NEXUS_CARD_RULES.md
+++ b/docs/NEXUS_CARD_RULES.md
@@ -12,6 +12,17 @@ movement, battle geometry and actual casualties stay unchanged. One original
Karama response cancels the entire physical batch and keeps the Nexus spent.
[Source, original native proofs and controls](NEXUS_BENE_GESSERIT_RULES.md#recommended-cunning-card-exceptions--14-october-2026).
+## Recommended Emperor original Ambassador purchase — 14 October 2026
+
+Original Richese Ambassador trigger now offers uniform beneficiary Pay or
+eligible actual unallied Emperor Nexus Use. Recommended visibly provisional
+purchase composition retains three own spice with no bank payment, seller
+reimbursement or fake auction. Actual draw/stock, native bonus and original
+movement/worm continuation run once. Version-three receipts bind the consumed
+entry/token and actual buyer; Truthtrance restores the original decision.
+CHOAM closing sales/trades do not become a direct purchase producer.
+[Source policy and changed-path proof](NEXUS_EMPEROR_RULES.md#recommended-original-ambassador-purchase--14-october-2026).
+
## Recommended Emperor Richese seller retention — 14 October 2026
Original Richese cache and Advanced Black Market now offer uniform buyer Pay
@@ -20,7 +31,7 @@ retains the personally affordable full price, leaves seller/bank unpaid and
delivers one original card through native bonus/round continuation. Self-cache
is legal where originally offered; self-Black remains prohibited. The unpaid
seller is a visibly provisional conservative recommendation, not publisher
-clarification. Direct CHOAM/Ambassador acquisitions remain open.
+clarification. Other purchase sources retain their separate source contracts.
[Source, policy and changed-path proof](NEXUS_EMPEROR_RULES.md#recommended-richese-seller-unpaid-purchase--14-october-2026).
## Recommended Emperor independent extra return — 14 October 2026
diff --git a/docs/NEXUS_EMPEROR_RULES.md b/docs/NEXUS_EMPEROR_RULES.md
index ad806119..e76666d9 100644
--- a/docs/NEXUS_EMPEROR_RULES.md
+++ b/docs/NEXUS_EMPEROR_RULES.md
@@ -48,9 +48,9 @@ Use the final ordinary purchase price and verify the holder's spendable spice be
For an ordinary bank-paid auction, the practical result is one purchased card, unchanged buyer spice and a spent Nexus card. No bank-funded replacement payment is printed. The public proof need establish only that the price is held, not disclose excess private spice. These are implementation consequences of the component, not an extra auction rule.
-Richese cache and Advanced Black Market now use the recommended **seller-unpaid**
-composition below. Direct CHOAM markets, Ambassador acquisitions and other
-purchase sources remain separate; do not broaden a generic acquisition hook.
+Richese cache and Advanced Black Market use the recommended **seller-unpaid**
+composition below. Original Richese Ambassador purchases now have their own
+source-bound continuation; do not broaden a generic acquisition hook.
### Bounded bank-auction purchase prototype (27 September 2026)
@@ -101,8 +101,46 @@ Affected ten-file union108/108 and types/lint/build pass. Actual390px cache,
Black Market, self-cache and bank-diverted cache Use keep original wallets,
deliver one actual card and continue original native bonus/round controls.
No overflow/browser errors; no saved/deployed rooms touched. API proof and
-phone receipts remain outside Git. Direct CHOAM/Ambassador purchase composition,
-full combinations, assurance and publication acceptance remain open.
+phone receipts remain outside Git. The distinct Ambassador composition follows
+below; full combinations, assurance and publication acceptance remain open.
+
+### Recommended original Ambassador purchase — 14 October 2026
+
+The [existing source classification](RICHESE_AMBASSADOR_RULES.md#classification-and-beneficiary-coverage)
+treats the fixed-three Richese Ambassador draw as a purchase. The Emperor Nexus
+purchase panel has no Bidding qualifier. Recommended provisional composition
+therefore applies its retained-price benefit to this original purchase, without
+creating an auction, seller reimbursement or income when no spice is paid.
+Ecaz's trigger remains the optional commitment; there is no new beneficiary veto.
+
+Every committed Richese effect in the supported public native-Ecaz Nexus envelope
+now offers the beneficiary original Pay, regardless private card face, hand room
+or funds. Only the actual unallied Emperor holder, with native Emperor absent,
+three own spice, room and drawable stock, can Use. The original conditional
+unavailable Pay still completes without payment/draw and keeps the token spent.
+An allied beneficiary cannot Use. A legal removed BG-copy token uses the same
+source proof; no new placement, copied effect or phase is invented.
+
+Actual draw/recycling happens once at commit, not at offer. Version-three
+receipts bind original entry, token, entrant, buyer, actual phase, card and
+unchanged price. Live source uses original Ambassador phase/resume authority,
+including separately source-bound Homeworld arrivals; historical evidence does
+not demand future wallets/hands equal old snapshots. Malformed source throws;
+private unavailability only disables Use. Original entrant control, physical
+arrival, native bonus and movement/worm suffix remain original.
+
+Affected13-file union111/111 and types/lint/build pass. Eight Basic/Advanced
+shipment/worm API profiles prove Pay3 versus Use0 and native suffix without
+another draw. Actual390px Basic Use11→11, Advanced Pay11→8, irrelevant-card Pay,
+and Advanced phase-one worm Use11→11 deliver one original card, preserve native
+arrival and resume real movement/spice controls without overflow/browser errors.
+Native allied Harkonnen Pay draws the actual bonus once; its automatic response
+does not manufacture a confirmation when nobody can cancel. Original forming
+alliance disposal is retained. Full combination/assurance/deployed gates stay shut.
+
+CHOAM's original closing market only sells to the bank or trades held cards;
+it is not a missing direct purchase producer. Do not invent an Emperor Nexus
+purchase there. Other real purchase sources retain their separate contracts.
## Secret Ally: three additional free revivals
diff --git a/docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md b/docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md
index d5bd522f..b3015d7a 100644
--- a/docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md
+++ b/docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md
@@ -4,7 +4,8 @@ Updated 14 October 2026. **Prototyped**, with **Partial** rules coverage.
Revival now uses the independent extra source through original native pending
continuations and supported Nexus module envelopes. Bank-auction purchase keeps
its distinct classic-only boundary; original Richese cache/Advanced Black Market
-now have their own seller continuation. Public starts and publication remain gated.
+and the Richese Ambassador have distinct native continuations. Public starts
+and publication remain gated.
## Rules and connected behavior
@@ -73,9 +74,19 @@ Truthtrance discard, hand limits, native Harkonnen bonus and source successor
continue once. Version-two receipts retain original source/payment proof.
[Policy and changed-path evidence](NEXUS_EMPEROR_RULES.md#recommended-richese-seller-unpaid-purchase--14-october-2026).
-Direct CHOAM markets, Ambassador/other acquisitions, allied split and full
-combinations remain open. Normal-auction forced Emperor-payment Betrayal has
-its separate [provisional full-price funding path](NEXUS_EMPEROR_RULES.md#recommended-original-full-price-funding--14-october-2026).
+The original Richese Ambassador now offers uniform beneficiary Pay or eligible
+Emperor Nexus Use after trigger commitment. Under the visibly provisional
+purchase composition, Use proves/retains three own spice and draws the original
+card once with no bank payment, seller reimbursement or fake auction. Original
+private inability and native movement/worm continuation remain; allied buyers
+cannot Use. Version-three source receipts and original phase/resume authority
+preserve the distinct entry. Actual Truthtrance discard restores that decision.
+[Ambassador policy/proof](NEXUS_EMPEROR_RULES.md#recommended-original-ambassador-purchase--14-october-2026).
+
+CHOAM closing sales/trades are not direct purchase producers. Other real
+purchase sources, allied splits and full combinations retain separate contracts.
+Normal-auction forced Emperor-payment Betrayal has its separate
+[provisional full-price funding path](NEXUS_EMPEROR_RULES.md#recommended-original-full-price-funding--14-october-2026).
## Prototype entry and evidence
diff --git a/docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md b/docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md
index 015a63ce..0bda277b 100644
--- a/docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md
+++ b/docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md
@@ -2,11 +2,37 @@
7 September 2026. The Richese Ambassador now resolves its paid card acquisition for Ecaz or any of its eleven possible allied factions. The same effect can be copied through a legal Bene Gesserit Ambassador choice. Full Ecaz and expansion starts remain gated: this checkpoint verifies one effect and its supported interactions, not complete expansion games.
+## Emperor Nexus purchase composition — 14 October 2026
+
+Original native-Ecaz Nexus envelopes with Emperor absent now use a uniform
+beneficiary Pay/eligible Use confirmation after the committed Richese trigger.
+This selects the recommended visibly provisional outside-Bidding purchase
+composition, not a publisher-specific Nexus/Ambassador ruling. Pay continues
+the original required conditional purchase, not a new consent/decline. Only
+an actual unallied Emperor holder with three own spice, room and stock may
+retain that price. No bank payment, reimbursement or fake auction is created.
+Actual recycling/draw, native bonus and original movement/worm suffix run once.
+Original phase/resume proof also governs source-bound Homeworld arrivals;
+broader combination acceptance is not claimed.
+
+Affected13-file union111/111, types/lint/build and eight native API profiles
+pass. Actual390px Basic Use, Advanced Pay, irrelevant-card Pay and phase-one
+worm Use preserve one actual card and original entrant/arrival control with
+no overflow/errors. Native allied Harkonnen Pay gets its original bonus once;
+forming alliance still disposes held Nexus through the original rule.
+[Canonical source and policy](NEXUS_EMPEROR_RULES.md#recommended-original-ambassador-purchase--14-october-2026).
+
## Rules and behavior
[Primary-source review](RICHESE_AMBASSADOR_RULES.md) records source precedence and composition. A fixed three-spice deck acquisition counts as a purchase for Harkonnen's extra card and Emperor's substitution for the bank. Ixian allied replacement expressly requires Bidding, so it does not apply to the implemented movement, shipment or worm entries. No artificial auction or allied shipment funding is created.
-Ecaz chooses whether to trigger and who receives the effect. That choice commits the token and automatically pays/draws; there is no additional beneficiary consent or result confirmation. The chosen beneficiary's current personal spice and actual hand limit apply. If payment, hand capacity or a drawable card is unavailable, the effect completes without payment or acquisition and the token remains committed. This unavailable-effect behavior is a documented interpretation of conditional resolution, rather than a specific publisher failed-cost ruling. Public eligibility and failure wording do not disclose an ally's private balance or fullness.
+Ecaz chooses whether to trigger and who receives the effect. That choice commits
+the token and normally pays/draws automatically. The supported Emperor-absent
+Nexus envelope now has the distinct confirmation above; no beneficiary veto
+is added. Current personal spice, hand limit and drawable stock still determine
+whether original Pay completes or yields no acquisition with the token spent.
+This unavailable-effect behavior is a documented interpretation, not a publisher
+failed-cost rule; public eligibility/failure does not expose private inability.
The standard draw primitive recycles the discard pile when needed. The primary card and payment commit once before any income/bonus opportunity. A server-only receipt retains the original entry, beneficiary, paid card identity, fixed price and child stage. Emperor income, when applicable, precedes Harkonnen's bonus. Cancellation affects only the selected benefit; the original payment/card persist. Both child responses finish through the original Ambassador continuation, never an auction continuation. The original movement is not replayed; a pending worm queue resumes once. The five-token cohort replenishes only after all child effects finish, preserving a permanently removed BG token.
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index ad233b74..91d3cd4d 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -66,6 +66,19 @@ These decisions do not open public module/faction or complete-mode gates.
These decisions do not open public module/faction or complete-mode gates.
+## Recommended Emperor Ambassador retention — 14 October 2026
+
+Select the original Richese Ambassador purchase classification and printed
+Emperor Nexus purchase panel without adding a Bidding restriction. Uniform
+beneficiary Pay follows committed trigger regardless private availability;
+eligible actual unallied holder can prove/retain three own spice. Original
+conditional Pay has no new decline; no bank payment, reimbursement or fake
+auction is created. Original draw/bonus/entry suffix runs once. Native source
+phase/resume authority remains, including bound Homeworld arrivals.
+Visibly provisional composition, not publisher clarification. CHOAM closing
+market sells to bank/trades; no direct purchase producer is invented.
+[Canonical policy/proof](NEXUS_EMPEROR_RULES.md#recommended-original-ambassador-purchase--14-october-2026).
+
## Recommended Emperor Richese seller retention — 14 October 2026
Choose the conservative **seller-unpaid** interpretation: the buyer keeps the
@@ -75,7 +88,8 @@ after original Richese Betrayal. Self-discard precludes acquisition/Nexus spend;
bank diversion preserves provenance but receives nothing on zero-payment Use.
Original stock, hand limits, bonus and successor continue. Basic/Advanced cache,
legal self-cache and Advanced Black Market are covered; self-Black remains
-prohibited. Direct CHOAM/Ambassador purchases and full combinations stay open.
+prohibited. The distinct original Ambassador path follows the current recommendation;
+other real purchase sources and full combinations retain separate contracts.
Visibly provisional source composition selected under decision priority, not
publisher clarification or a separately answered numerical ruling.
[Canonical policy/proof](NEXUS_EMPEROR_RULES.md#recommended-richese-seller-unpaid-purchase--14-october-2026).
@@ -615,7 +629,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Moritani Secret Ally battle timing | The printed after-loss retention is optional and the Nexus card remains secret. The connected prototype offers the same choice to every loser when Moritani is absent and at least one publicly revealed played card is retainable; a loser with no eligible public card has no useful choice, independent of hidden custody. Only an unallied holder may spend the physical card. This prompt is a privacy-preserving product inference, not publisher timing text. Native Moritani alliance retention and its Karama response remain separate; no generic Secret Ally counter is invented. | [Nexus retention boundary](NEXUS_CARD_RULES.md#moritani-secret-ally-retention-timing) |
| Ecaz Secret Ally traitor inquiry | The printed panel asks one selected player whether they hold any of the holder's leaders as Traitors, without identifying a leader; it specifies neither native-versus-controlled scope nor answer audience. The connected prototype chooses native printed faction leaders (including dead/captured), ordinary Traitor Cards rather than Face Dancers, and a holder-only historical yes/no. This is a privacy-preserving product interpretation, **not** a publisher ruling. Foreign Ghola/captive scope and table-wide disclosure remain release boundaries. | [Ecaz inquiry source and bounded runtime](NEXUS_ECAZ_RULES.md#bounded-secret-ally-traitor-inquiry-runtime) |
| Ecaz native Nexus Cunning timing | The printed Cunning takes Duke for this turn even from capture, Tanks or Ghola, without a phase. The quiet Battle prototype now covers living control or set-aside Tanks. Tanks preserve deaths and reset use under a provisional independent-resurrection policy; this is not a numerical user ruling. Captured/Ghola return destinations and Advanced Harkonnen remain guarded; ordinary Ambassador and paid-revival custody are unchanged. | [Source and bounded runtime](NEXUS_ECAZ_RULES.md#bounded-native-cunning-duke-vidal) |
-| Emperor Secret Ally purchase timing | Uniform winning-buyer confirmation prevents a holder-only pause exposing hidden eligibility. The classic bank-auction boundary remains separate; original Richese cache/Advanced Black Market now use the recommended provisional seller-unpaid policy after original Richese Betrayal. The user's 14 October decision priority selects the recommendation, not a publisher timing ruling. Allied and other purchase sources remain open. | [Purchase policy](NEXUS_EMPEROR_RULES.md#recommended-richese-seller-unpaid-purchase--14-october-2026) |
+| Emperor Secret Ally purchase timing | Uniform buyer/committed-beneficiary confirmation prevents a holder-only pause disclosing hidden eligibility; no beneficiary veto is added. Distinct classic bank, Richese cache/Advanced Black Market and original Richese Ambassador paths now select visibly provisional source recommendations under the user's decision priority, not publisher timing rulings. Other real purchase sources and full combinations retain separate contracts. CHOAM closing sales/trades are not direct purchases. | [Original Ambassador policy](NEXUS_EMPEROR_RULES.md#recommended-original-ambassador-purchase--14-october-2026), [seller policy](NEXUS_EMPEROR_RULES.md#recommended-richese-seller-unpaid-purchase--14-october-2026) |
| Discovery prototype inferences | Free entry uses storm order and ordinary source-storm restrictions; these are general-rule inferences. Great Maker currently follows ordinary first-turn worm skipping and Sandtrout suppression; an exhausted token type creates no replacement token. These preserve current source interpretation and physical inventory but are not separately confirmed rulings or complete combined-module certification. | [Interpretations for refinement](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
| Other Homeworld combinations | Population/force splitting, removal custody, replacement shortages, income accounting and placement ordering remain local to their detailed contracts. | [Homeworld rules](HOMEWORLD_RULES.md), [occupation](HOMEWORLD_OCCUPATION_RULES.md) and the effect documents above |
diff --git a/game/admin-seat-ai-shape.ts b/game/admin-seat-ai-shape.ts
index 473ef2a5..cf6b54a3 100644
--- a/game/admin-seat-ai-shape.ts
+++ b/game/admin-seat-ai-shape.ts
@@ -111,6 +111,7 @@ const decisions = {
nexusFremenProtection: true,
nexusEmperorPayment: true,
nexusEmperorSellerPurchase: true,
+ nexusEmperorAmbassadorPurchase: true,
nexusFremenCunningRide: true,
moritaniRetention: true,
moritaniSpecialKarama: true,
diff --git a/game/bots.ts b/game/bots.ts
index bcc62223..fc01c577 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -2696,6 +2696,9 @@ function policyActions(g: GameView): Action[] {
if (d.kind === 'nexusEmperorSellerPurchase')
return [{ type: 'nexusEmperorSellerPurchase', event: d.event,
use: !!g.nexusEmperorSellerPurchase?.canUse && !g.nexusEmperorSellerPurchase.blocked }];
+ if (d.kind === 'nexusEmperorAmbassadorPurchase')
+ return [{ type: 'nexusEmperorAmbassadorPurchase', event: d.event,
+ use: !!g.nexusEmperorAmbassadorPurchase?.canUse && !g.nexusEmperorAmbassadorPurchase.blocked }];
if (d.kind === 'nexusFremenCunningOffer')
return [{ type: 'decision', event: d.event,
accept: g.nexusCards?.card === 'fremen' && me.faction === 'fremen' &&
diff --git a/game/engine.ts b/game/engine.ts
index 80b78c32..0ff6f836 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -45,7 +45,7 @@ import { quoteNexusChoamBetrayal, validateNexusChoamBetrayal, nexusChoamBetrayal
import { quoteEcazInquiry, ecazInquiryAnswer, ecazInquiryHistory, ecazInquirySignature, validateEcazInquiry, type EcazInquiryReceipt } from './nexus-ecaz-inquiry';
import { nexusCleanPlayBlocked } from './nexus-play-boundary';
import { EMPEROR_NEXUS_REVIVALS, emperorNexusModeSupported, quoteEmperorNexusRevival, createEmperorNexusRevival, settleEmperorNexusRevival, validateEmperorNexusRevival, type NexusEmperorRevival } from './nexus-emperor-secret-ally';
-import { emperorNexusPurchaseEvent, quoteEmperorNexusPurchase, signEmperorNexusPurchase, validateEmperorNexusPurchase, emperorNexusSellerPurchaseModeSupported, quoteEmperorNexusSellerPurchase, createEmperorNexusSellerPurchase, type EmperorNexusPurchase } from './nexus-emperor-purchase';
+import { emperorNexusPurchaseEvent, quoteEmperorNexusPurchase, signEmperorNexusPurchase, validateEmperorNexusPurchase, emperorNexusSellerPurchaseModeSupported, quoteEmperorNexusSellerPurchase, createEmperorNexusSellerPurchase, emperorNexusAmbassadorPurchaseModeSupported, quoteEmperorNexusAmbassadorPurchase, createEmperorNexusAmbassadorPurchase, type EmperorNexusPurchase } from './nexus-emperor-purchase';
import { createEmperorPaymentReceipt, validateEmperorPaymentReceipt, emperorPaymentUseBlock, emperorPaymentSourceQuote, requiredEmperorPaymentResponders, emperorPaymentReceiptSignature, type EmperorPaymentReceipt } from './nexus-emperor-payment';
import { FREMEN_NEXUS_FREE_FORCES, fremenNexusRevivalOffer, createFremenNexusRevival, upgradeFremenNexusRevival, validateFremenNexusRevival, type FremenNexusRevival } from './nexus-fremen-revival';
import { captureFremenCunningOccurrence, quoteFremenCunning, quoteFremenCunningRide, validateFremenCunningSelection, type FremenCunningOccurrence, type FremenCunningRideAuthorization, type FremenCunningSelection } from './nexus-fremen-cunning';
@@ -935,6 +935,7 @@ export type Decision =
| { kind: 'auctionPayment'; player: string }
| { kind: 'nexusEmperorPayment'; player: string; event: string }
| { kind: 'nexusEmperorSellerPurchase'; player: string; event: string }
+ | { kind: 'nexusEmperorAmbassadorPurchase'; player: string; event: string }
| {
kind: 'advisor';
player: string;
@@ -1636,12 +1637,14 @@ export type Game = {
| 'loan'
| 'copy'
| 'cards'
+ | 'purchase'
| 'income'
| 'bonus'
| 'move'
| 'ship'
| 'revival'
| 'arrival';
+ nexusPurchase?: { event: string; signature: string };
relocation?: {
order: FremenAmbassadorMove;
next: 'flip' | 'intrusion' | 'terror' | 'finish';
@@ -1663,6 +1666,7 @@ export type Game = {
amount: 3;
emperor: string | null;
stage: 'income' | 'bonus';
+ nexusEvent?: string;
};
beneficiary?: string;
effect?: AmbassadorEffect;
@@ -2805,6 +2809,9 @@ function nexusEmperorSecretIntegrity(g:Game) {
if (!('version' in record))
requireRule(emperorNexusModeSupported(g) && !g.ecazTreachery && !g.sandtrout,
'A normal-bank Emperor purchase lost its original supported profile.');
+ else if (record.version === 3)
+ requireRule(emperorNexusAmbassadorPurchaseModeSupported(g),
+ 'An Emperor Ambassador purchase lost its original supported native profile.');
requireRule(record.sequence === index && purchaseEvents[index] === record.event,
'Emperor Nexus purchase has duplicated or reordered a completed use.');
}
@@ -16839,6 +16846,100 @@ function continueAmbassadorRelocation(g: Game, event: string) {
}
finishAmbassador(g);
}
+const EMPEROR_AMBASSADOR_POLICY = 'Provisional purchase composition: prove three own spice and retain it. No bank payment or reimbursement is invented; original card acquisition and entry continuation still resolve.';
+function emperorAmbassadorSourceSignature(g: Game): string {
+ const entry = g.pendingAmbassador!;
+ const buyer = getPlayer(g, entry.beneficiary!);
+ return JSON.stringify([entry.event, entry.owner, entry.entrant, entry.token,
+ entry.territory, entry.sector, entry.turn, entry.phase, entry.effect,
+ entry.beneficiary, entry.copyChoices, entry.resume, entry.wormRider,
+ entry.guildAdvisorOrigin, entry.revivalEvent, entry.victoryEvent,
+ g.active, buyer.spice, buyer.ally, g.nexusEmperorPurchaseHistory?.length ?? 0]);
+}
+function nexusEmperorAmbassadorIntegrity(g: Game): void {
+ const entry = g.pendingAmbassador;
+ const continuation = g.pendingTreacheryDiscard?.continuation;
+ const decision = g.decision?.kind === 'nexusEmperorAmbassadorPurchase' ? g.decision :
+ continuation?.kind === 'truthtranceDiscard' ? continuation.resume.decision : null;
+ if (!entry?.nexusPurchase) {
+ requireRule(entry?.stage !== 'purchase' && decision?.kind !== 'nexusEmperorAmbassadorPurchase',
+ 'The original Ambassador purchase lost its buyer confirmation.');
+ return;
+ }
+ const source = entry.nexusPurchase;
+ requireRule(Object.keys(source).sort().join(',') === 'event,signature' &&
+ entry.stage === 'purchase' && !entry.purchaseReceipt &&
+ emperorNexusAmbassadorPurchaseModeSupported(g) &&
+ decision?.kind === 'nexusEmperorAmbassadorPurchase' && decision.player === entry.beneficiary &&
+ decision.event === source.event && source.signature === emperorAmbassadorSourceSignature(g),
+ 'The original Ambassador purchase lost its committed entry, price or beneficiary.');
+ const quote = nexusRule(() => quoteEmperorNexusAmbassadorPurchase(g, entry.beneficiary!));
+ requireRule(quote.event === source.event, 'The original Ambassador purchase event changed.');
+}
+function nexusEmperorAmbassadorOffer(g: Game, id: string) {
+ const entry = g.pendingAmbassador;
+ if (!entry?.nexusPurchase || g.decision?.kind !== 'nexusEmperorAmbassadorPurchase' ||
+ g.decision.player !== id || entry.beneficiary !== id) return null;
+ const quote = nexusRule(() => quoteEmperorNexusAmbassadorPurchase(g, id));
+ const blocked = g.truthtrance || pendingNexusTraitors(g) || g.pendingTreacheryDiscard
+ ? 'Finish the original private question or card return before confirming this purchase.' : quote.blocked;
+ return { ...quote, blocked, canUse: !blocked, policy: EMPEROR_AMBASSADOR_POLICY };
+}
+function decideNexusEmperorAmbassadorPurchase(g: Game, p: Player, action: Action): void {
+ const entry = g.pendingAmbassador;
+ requireRule(entry?.nexusPurchase && action.type === 'nexusEmperorAmbassadorPurchase' &&
+ action.event === entry.nexusPurchase.event && typeof action.use === 'boolean' &&
+ Object.keys(action).sort().join(',') === 'event,type,use' &&
+ p.id === entry.beneficiary && g.decision?.kind === 'nexusEmperorAmbassadorPurchase' &&
+ g.decision.player === p.id && !g.truthtrance && !pendingNexusTraitors(g),
+ 'Confirm only your current original Ambassador purchase with Pay or Use.');
+ nexusEmperorAmbassadorIntegrity(g);
+ if (action.use) {
+ const quote = nexusRule(() => quoteEmperorNexusAmbassadorPurchase(g, p.id));
+ requireRule(!quote.blocked, quote.blocked ?? 'This Emperor Nexus purchase is unavailable.');
+ }
+ g.decision = null;
+ finishRicheseAmbassadorPurchase(g, action.use);
+}
+function finishRicheseAmbassadorPurchase(g: Game, use = false): void {
+ const entry = g.pendingAmbassador!;
+ const buyer = getPlayer(g, entry.beneficiary!);
+ if (buyer.spice < 3 || buyer.hand.length >= handLimit(buyer) ||
+ (!g.deck.length && !g.discard.length)) {
+ requireRule(!use, 'This Emperor Nexus purchase is no longer available.');
+ delete entry.nexusPurchase;
+ log(g, `${buyer.name} received no card through the Richese Ambassador. No spice was charged; the trigger remains committed.`);
+ finishAmbassador(g);
+ return;
+ }
+ const card = draw(g);
+ requireRule(card, 'No Treachery Card is available for the Ambassador purchase.');
+ let nexusEvent: string | undefined;
+ if (use) {
+ const receipt = nexusRule(() => createEmperorNexusAmbassadorPurchase(g, buyer.id, card.id));
+ nexusEvent = receipt.event;
+ g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, buyer.id, g.players));
+ (g.nexusEmperorPurchaseHistory ??= []).push(receipt);
+ (g.nexusEmperorPurchaseEvents ??= []).push(receipt.event);
+ } else buyer.spice -= 3;
+ delete entry.nexusPurchase;
+ buyer.hand.push(card);
+ const emperor = byFaction(g, 'emperor');
+ entry.purchaseReceipt = {
+ buyer: buyer.id, card: card.id, amount: 3,
+ emperor: emperor && emperor.id !== buyer.id ? emperor.id : null, stage: 'income',
+ ...(nexusEvent ? { nexusEvent } : {}),
+ };
+ log(g, use
+ ? `${buyer.name} spent Emperor Nexus after proving three own spice, retained it and drew the original Richese Ambassador purchase card. No bank payment or reimbursement is created. Provisional purchase composition.`
+ : `${buyer.name} paid 3 spice and drew the top Treachery Card through the Richese Ambassador. Its identity remains private.`,
+ { faction: buyer.faction, name: 'Richese Ambassador purchase' });
+ if (entry.purchaseReceipt.emperor) {
+ entry.stage = 'income';
+ g.response = { kind: 'emperorIncome', source: 'ambassador', intent: entry.event,
+ owner: entry.purchaseReceipt.emperor, amount: 3, passed: [] };
+ } else ambassadorPurchaseBonus(g);
+}
/** These are consequences of a completed purchase, never an auction lot. */
function validateAmbassadorPurchaseResponse(g: Game, response: ResponseWindow) {
const entry = g.pendingAmbassador;
@@ -16877,6 +16978,16 @@ function validateAmbassadorPurchaseResponse(g: Game, response: ResponseWindow) {
(emperor && emperor.id !== buyer.id ? emperor.id : null),
'This Ambassador purchase has an invalid income recipient.',
);
+ if (receipt.nexusEvent) {
+ const original = g.nexusEmperorPurchaseHistory?.find(record => record.event === receipt.nexusEvent);
+ requireRule(original && 'version' in original && original.version === 3 &&
+ original.entry === entry.event && original.token === entry.token &&
+ original.entrant === entry.entrant && original.owner === receipt.buyer &&
+ original.card === receipt.card && original.turn === entry.turn &&
+ original.phase === entry.phase && !receipt.emperor && response.kind !== 'emperorIncome',
+ 'The retained-price Ambassador purchase lost its original consumed Nexus source.');
+ nexusRule(() => validateEmperorNexusPurchase(g, original));
+ }
requireRule(
response.kind === 'emperorIncome'
? entry.stage === 'income' &&
@@ -17262,50 +17373,13 @@ function resolveAmbassadorEffect(g: Game) {
return;
}
if (effect === 'richese') {
- // Resolve after commitment so Ecaz cannot probe its ally's private funds.
- if (
- p.spice < 3 ||
- p.hand.length >= handLimit(p) ||
- (!g.deck.length && !g.discard.length)
- ) {
- log(
- g,
- `${p.name} received no card through the Richese Ambassador. No spice was charged; the trigger remains committed.`,
- );
- finishAmbassador(g);
- return;
- }
- const card = draw(g);
- requireRule(
- card,
- 'No Treachery Card is available for the Ambassador purchase.',
- );
- p.spice -= 3;
- p.hand.push(card);
- const emperor = byFaction(g, 'emperor');
- entry.purchaseReceipt = {
- buyer: p.id,
- card: card.id,
- amount: 3,
- emperor: emperor && emperor.id !== p.id ? emperor.id : null,
- stage: 'income',
- };
- log(
- g,
- `${p.name} paid 3 spice and drew the top Treachery Card through the Richese Ambassador. Its identity remains private.`,
- { faction: p.faction, name: 'Richese Ambassador purchase' },
- );
- if (entry.purchaseReceipt.emperor) {
- entry.stage = 'income';
- g.response = {
- kind: 'emperorIncome',
- source: 'ambassador',
- intent: entry.event,
- owner: entry.purchaseReceipt.emperor,
- amount: 3,
- passed: [],
- };
- } else ambassadorPurchaseBonus(g);
+ // Uniform timing must not expose a private Nexus face or private ability to pay.
+ if (emperorNexusAmbassadorPurchaseModeSupported(g)) {
+ const quote = nexusRule(() => quoteEmperorNexusAmbassadorPurchase(g, p.id));
+ entry.stage = 'purchase';
+ entry.nexusPurchase = { event: quote.event, signature: emperorAmbassadorSourceSignature(g) };
+ g.decision = { kind: 'nexusEmperorAmbassadorPurchase', player: p.id, event: quote.event };
+ } else finishRicheseAmbassadorPurchase(g);
return;
}
if (
@@ -28710,6 +28784,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
richeseBetrayalIntegrity(state);
nexusEmperorPaymentIntegrity(state);
nexusEmperorSellerIntegrity(state);
+ nexusEmperorAmbassadorIntegrity(state);
kullIntegrity(state);
advancedPreviewIntegrity(state);
harkonnenExchangeIntegrity(state);
@@ -28800,10 +28875,11 @@ export function applyAction(state: Game, id: string, action: Action): Game {
if (g.pendingNexusIxianReplacement) return g;
if (g.pendingGuildBetrayal) {guildBetrayalIntegrity(g); return g;}
if (g.pendingRicheseBetrayal) {richeseBetrayalIntegrity(g);return g;}
- if (g.pendingNexusEmperorPayment || g.pendingNexusEmperorSellerPurchase) {
+ if (g.pendingNexusEmperorPayment || g.pendingNexusEmperorSellerPurchase || g.pendingAmbassador?.nexusPurchase) {
finishActionContinuations(g);
nexusEmperorPaymentIntegrity(g);
nexusEmperorSellerIntegrity(g);
+ nexusEmperorAmbassadorIntegrity(g);
return g;
}
if (g.pendingKull) {
@@ -28924,6 +29000,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
richeseBetrayalIntegrity(g);
nexusEmperorPaymentIntegrity(g);
nexusEmperorSellerIntegrity(g);
+ nexusEmperorAmbassadorIntegrity(g);
marketGholaIntegrity(g);
homeworldRule(() => homeworldGameIntegrity(g));
homeworldBattleLossIntegrity(g);
@@ -28954,7 +29031,7 @@ function finishActionContinuations(g: Game) {
if (g.pendingTreacheryDiscard?.reaction) return;
requireRule(!g.pendingTreacheryDiscard, 'The automatic discard chain did not finish.');
if (g.pendingNexusEmperorPayment) return;
- if (g.pendingNexusEmperorSellerPurchase) return;
+ if (g.pendingNexusEmperorSellerPurchase || g.pendingAmbassador?.nexusPurchase) return;
if (pendingNexusTraitors(g)) return;
observeOccupation(g);
resumeHomeworldRevivalReturn(g);
@@ -29015,7 +29092,7 @@ function settleAutomaticContinuations(g: Game) {
if (g.pendingGuildBetrayal) return;
if (g.pendingRicheseBetrayal) return;
if (g.pendingNexusEmperorPayment) return;
- if (g.pendingNexusEmperorSellerPurchase) return;
+ if (g.pendingNexusEmperorSellerPurchase || g.pendingAmbassador?.nexusPurchase) return;
if (g.pendingKull?.stage === 'offer') return;
if (pendingNexusTraitors(g)) return;
if (g.pendingNullentropy) return;
@@ -29026,7 +29103,7 @@ function settleAutomaticContinuations(g: Game) {
if (g.pendingNexusIxianReplacement) return;
if (g.pendingRicheseBetrayal) return;
if (g.pendingNexusEmperorPayment) return;
- if (g.pendingNexusEmperorSellerPurchase) return;
+ if (g.pendingNexusEmperorSellerPurchase || g.pendingAmbassador?.nexusPurchase) return;
syncTupileSlots(g);
prepareTupileCleanup(g);
if (g.pendingHomeworldTupileCleanup || tupileSourceBlock(g)) return;
@@ -29086,7 +29163,7 @@ function settleAutomaticContinuations(g: Game) {
if (g.pendingGuildBetrayal) return;
if (g.pendingRicheseBetrayal) return;
if (g.pendingNexusEmperorPayment) return;
- if (g.pendingNexusEmperorSellerPurchase) return;
+ if (g.pendingNexusEmperorSellerPurchase || g.pendingAmbassador?.nexusPurchase) return;
// Opposing/automatic consequences can release a now-impossible promise;
// they are not the original actor voluntarily spending a promised resource.
reconcileBattlePromises(g);
@@ -29112,6 +29189,7 @@ export function normalizeAutomaticGame(state: Game): Game {
richeseBetrayalIntegrity(state);
nexusEmperorPaymentIntegrity(state);
nexusEmperorSellerIntegrity(state);
+ nexusEmperorAmbassadorIntegrity(state);
kullIntegrity(state);
advancedPreviewIntegrity(state);
harkonnenExchangeIntegrity(state);
@@ -29152,10 +29230,11 @@ export function normalizeAutomaticGame(state: Game): Game {
if (g.pendingNexusIxianReplacement) return g;
if (g.pendingGuildBetrayal) return g;
if (g.pendingRicheseBetrayal) return g;
- if (g.pendingNexusEmperorPayment || g.pendingNexusEmperorSellerPurchase) {
+ if (g.pendingNexusEmperorPayment || g.pendingNexusEmperorSellerPurchase || g.pendingAmbassador?.nexusPurchase) {
finishActionContinuations(g);
nexusEmperorPaymentIntegrity(g);
nexusEmperorSellerIntegrity(g);
+ nexusEmperorAmbassadorIntegrity(g);
return g;
}
if (g.pendingKull?.stage === 'offer') return g;
@@ -29190,6 +29269,7 @@ export function normalizeAutomaticGame(state: Game): Game {
richeseBetrayalIntegrity(g);
nexusEmperorPaymentIntegrity(g);
nexusEmperorSellerIntegrity(g);
+ nexusEmperorAmbassadorIntegrity(g);
marketGholaIntegrity(g);
prepareTupileCleanup(g);
homeworldRule(() => homeworldGameIntegrity(g));
@@ -29225,7 +29305,7 @@ function applyActionInner(
'Finish the original occupied Tupile hand cleanup before another action.');
ecazTreacheryIntegrity(state);
const g = structuredClone(state);
- if ((g.pendingNexusEmperorPayment || g.pendingNexusEmperorSellerPurchase) &&
+ if ((g.pendingNexusEmperorPayment || g.pendingNexusEmperorSellerPurchase || g.pendingAmbassador?.nexusPurchase) &&
!g.truthtrance && !pendingNexusTraitors(g)) {
const p = getPlayer(g, id);
const opensTruth = action?.type === 'card' && !action.mode &&
@@ -29234,6 +29314,7 @@ function applyActionInner(
if (!opensTruth) {
if (action?.type === 'advanceBots') return g;
if (g.pendingNexusEmperorSellerPurchase) decideNexusEmperorSellerPurchase(g, p, action);
+ else if (g.pendingAmbassador?.nexusPurchase) decideNexusEmperorAmbassadorPurchase(g, p, action);
else decideNexusEmperorPayment(g, p, action);
return g;
}
@@ -29422,6 +29503,11 @@ function applyActionInner(
decideNexusEmperorSellerPurchase(g, p, action);
return g;
}
+ if (g.pendingAmbassador?.nexusPurchase) {
+ if (action.type === 'advanceBots') return g;
+ decideNexusEmperorAmbassadorPurchase(g, p, action);
+ return g;
+ }
if (t === 'card' && action.card === RECRUITS_CARD_ID) {
requireRule(
Object.keys(action).sort().join(',') === 'card,type',
@@ -29767,6 +29853,8 @@ function applyActionInner(
'Use only the original allied-purchase event with Use or Pass.');
requireRule(decision.kind !== 'nexusEmperorSellerPurchase',
'Use only the original seller-lot event with Pay or Use.');
+ requireRule(decision.kind !== 'nexusEmperorAmbassadorPurchase',
+ 'Use only the original Ambassador-purchase event with Pay or Use.');
if (decision.kind === 'nexusIxianReplacement') {
requireRule(false, 'Use only the current purchased-card event with Use or Pass.');
}
@@ -33205,6 +33293,7 @@ export function viewGame(state: Game, id: string) {
richeseBetrayalIntegrity(state);
nexusEmperorPaymentIntegrity(state);
nexusEmperorSellerIntegrity(state);
+ nexusEmperorAmbassadorIntegrity(state);
kullIntegrity(state);
advancedPreviewIntegrity(state);
harkonnenExchangeIntegrity(state);
@@ -33649,6 +33738,7 @@ export function viewGame(state: Game, id: string) {
nexusEmperorBetrayal: nexusEmperorBetrayalOffer(g, id),
nexusEmperorPayment: nexusEmperorPaymentOffer(g, id),
nexusEmperorSellerPurchase: nexusEmperorSellerOffer(g, id),
+ nexusEmperorAmbassadorPurchase: nexusEmperorAmbassadorOffer(g, id),
nexusBgBetrayal: nexusBgBetrayalOffer(g, id),
nexusBorrowedVoice: nexusBorrowedVoiceOffer(g, id),
nexusFremenRevival: fremenNexusRevivalOffer(g,id),
diff --git a/game/nexus-emperor-purchase.ts b/game/nexus-emperor-purchase.ts
index 6c1a07e9..e1fef5e4 100644
--- a/game/nexus-emperor-purchase.ts
+++ b/game/nexus-emperor-purchase.ts
@@ -1,13 +1,17 @@
import { FACTIONS, type FactionId } from './catalog';
-import { handLimit, type Game } from './engine';
+import { handLimit, type Game, type Player } from './engine';
import { emperorNexusEvent } from './nexus-emperor-secret-ally';
import { nexusCardMode, validateNexusCards } from './nexus-cards';
+import { validAmbassadorResume } from './ambassador-resume';
+import { ambassadorPhaseAllowed } from './ambassador-phase';
import {
pairedNexusModulesProfile, singleE1E2NexusProfile, mixedE1E2NexusProfile, mixedE3NexusProfile,
+ standaloneE3NexusProfile, pairedE3NexusProfile,
} from './nexus-module-profile';
import {
pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile,
mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile,
+ standaloneE3NexusLeaderSkillsProfile, pairedE3NexusLeaderSkillsProfile,
} from './leader-skill-profile';
import type { RicheseAuction } from './richese-auction';
import { quoteRicheseSettlement, type RicheseSettlementQuote } from './richese-settlement';
@@ -37,7 +41,31 @@ export type EmperorNexusSellerPurchase = Omit player.faction)).size ===
context.players.length;
- if (plain(record) && 'version' in record) {
+ if (plain(record) && 'version' in record && record.version === 3) {
+ if (
+ !validContext ||
+ Object.keys(record).sort().join(',') !==
+ 'advanced,afterSpice,beforeSpice,card,entrant,entry,event,faction,kind,origin,owner,phase,price,sequence,signature,token,turn,version' ||
+ record.kind !== 'purchase' || record.origin !== 'ambassador' ||
+ record.price !== 3 || !whole(record.phase) || record.phase < 1 || record.phase > 8 ||
+ !whole(record.turn) || record.turn < 1 || record.turn > context.turn ||
+ !whole(record.sequence) ||
+ !text(record.owner) || !text(record.card) || !text(record.entry) ||
+ !text(record.token) || !text(record.entrant) ||
+ record.entrant === record.owner ||
+ !context.players.some(player => player.id === record.entrant) ||
+ context.players.find(player => player.id === record.owner)?.faction !== record.faction ||
+ record.advanced !== context.advanced ||
+ !whole(record.beforeSpice) || record.beforeSpice < record.price ||
+ !whole(record.afterSpice) || record.afterSpice !== record.beforeSpice ||
+ record.event !== emperorNexusAmbassadorPurchaseEvent(
+ record.turn, record.phase, record.owner, record.sequence, record.entry,
+ ) ||
+ record.signature !== signEmperorNexusPurchase(record)
+ )
+ throw new EmperorNexusPurchaseError(
+ 'Emperor Nexus Ambassador purchase has lost its original trigger, buyer, price or retained spice.',
+ );
+ return;
+ }
+ if (plain(record) && 'version' in record && record.version === 2) {
if (
!validContext ||
Object.keys(record).sort().join(',') !==
'advanced,afterSpice,beforeSpice,card,event,faction,kind,lot,origin,owner,phase,price,round,seller,sequence,signature,turn,version' ||
- record.version !== 2 || record.kind !== 'purchase' || record.phase !== 3 ||
+ record.kind !== 'purchase' || record.phase !== 3 ||
!whole(record.turn) || record.turn < 1 || record.turn > context.turn ||
!whole(record.sequence) || !text(record.owner) || !text(record.card) ||
!text(record.round) || !text(record.lot) || !text(record.seller) ||
@@ -179,6 +249,7 @@ export function validateEmperorNexusPurchase(
if (
!validContext ||
!plain(record) ||
+ 'version' in record ||
!('auctionIndex' in record) ||
Object.keys(record).sort().join(',') !==
'advanced,afterSpice,auctionIndex,beforeSpice,card,event,faction,kind,owner,phase,price,sequence,signature,turn' ||
@@ -347,3 +418,125 @@ export function createEmperorNexusSellerPurchase(
validateEmperorNexusPurchase(g, receipt);
return receipt;
}
+
+/** Public Emperor-absent Ecaz envelope only, reusing the original E3 Nexus and
+ * Skills profiles; it never inspects the concealed Nexus face or a hand. */
+export function emperorNexusAmbassadorPurchaseModeSupported(g: Game): boolean {
+ if (!g.nexusCards || g.ecazTreachery || g.sandtrout ||
+ g.players.some(p => p.faction === 'emperor') ||
+ !g.players.some(p => p.faction === 'ecaz')) return false;
+ if (standaloneE3NexusProfile(g) || pairedE3NexusProfile(g) || mixedE3NexusProfile(g))
+ return true;
+ return !!g.leaderSkills && (standaloneE3NexusLeaderSkillsProfile(g) ||
+ pairedE3NexusLeaderSkillsProfile(g) || mixedE3NexusLeaderSkillsProfile(g));
+}
+
+type AmbassadorPurchaseSource = {
+ buyer: Player | null;
+ entry: NonNullable | null;
+ event: string;
+ blocked: string | null;
+};
+
+/** Original consumed-token evidence plus the buyer's private eligibility. The
+ * physical supply is skipped only for the card an already committed draw bound. */
+function ambassadorPurchaseSource(g: Game, owner: string, supply: boolean): AmbassadorPurchaseSource {
+ const entry = g.pendingAmbassador ?? null;
+ const buyer = text(owner) ? g.players.find(p => p.id === owner) ?? null : null;
+ const token = entry && g.ecazAmbassadors?.tokens.find(t => t.id === entry.token);
+ const event = entry && text(entry.event) && text(owner)
+ ? emperorNexusAmbassadorPurchaseEvent(g.turn, g.phase, owner,
+ g.nexusEmperorPurchaseHistory?.length ?? 0, entry.event)
+ : '';
+ let blocked: string | null = null;
+ if (!emperorNexusAmbassadorPurchaseModeSupported(g))
+ blocked = 'Emperor Nexus Ambassador purchase requires a supported native Ecaz Nexus profile with Emperor absent.';
+ else if (g.status !== 'playing')
+ blocked = 'Emperor Nexus Ambassador purchase needs the original game still in progress.';
+ else if (!entry || !text(entry.event) || !text(entry.token))
+ blocked = 'The original Richese Ambassador trigger is no longer current.';
+ else if (!buyer)
+ blocked = 'Emperor Nexus Ambassador purchase lost its original purchaser.';
+ else if (entry.effect !== 'richese')
+ blocked = 'The original Richese Ambassador effect is no longer active.';
+ else if (entry.beneficiary !== owner ||
+ entry.owner !== g.players.find(p => p.faction === 'ecaz')?.id)
+ blocked = 'The original Richese Ambassador purchase belongs to another Ecaz beneficiary.';
+ else if (entry.turn !== g.turn || entry.phase !== g.phase || !ambassadorPhaseAllowed(g))
+ blocked = 'The original Richese Ambassador trigger belongs to another turn or phase.';
+ else if (!validAmbassadorResume(g, entry))
+ blocked = 'The original Richese Ambassador trigger lost its recorded entry source.';
+ else if (entry.entrant === owner || !g.players.some(p => p.id === entry.entrant))
+ blocked = 'The original Richese Ambassador trigger lost its entering player.';
+ else if (!token || !((token.effect === 'richese' && token.zone === 'used') ||
+ (token.effect === 'beneGesserit' && token.zone === 'removed' &&
+ entry.copyChoices.includes('richese'))))
+ blocked = 'The original Richese Ambassador token is no longer consumed for this effect.';
+ if (blocked) throw new EmperorNexusPurchaseError(blocked);
+ if (buyer!.ally)
+ blocked = 'Emperor Nexus Secret Ally requires an unallied buyer.';
+ else if (!g.nexusCards?.cards)
+ blocked = 'Hold the real Emperor Nexus Secret Ally card to retain your purchase price.';
+ else {
+ try {
+ validateNexusCards(g.nexusCards.cards, g.players);
+ if (g.nexusCards.cards.hands[owner] !== 'emperor' ||
+ nexusCardMode('emperor', buyer!.faction, g.players.map(p => p.faction)) !== 'secretAlly')
+ blocked = 'Hold the real Emperor Nexus Secret Ally card to retain your purchase price.';
+ else if (buyer!.hand.length >= handLimit(buyer!))
+ blocked = 'The Richese Ambassador purchase needs room in the buyer’s hand.';
+ else if (!whole(buyer!.spice) || buyer!.spice < 3)
+ blocked = 'The Richese Ambassador purchase needs the three-spice price held in the buyer’s own spice.';
+ else if (supply && !g.deck.length && !g.discard.length)
+ blocked = 'No Treachery Card is available for the Ambassador purchase.';
+ } catch (error) {
+ blocked = error instanceof Error ? error.message
+ : 'Hold the real Emperor Nexus Secret Ally card to retain your purchase price.';
+ }
+ }
+ return { buyer, entry, event, blocked };
+}
+
+/** Pre-commit proof only. Private room, spice, supply and Nexus face disable
+ * Use without gating Pay; malformed original sources remain hard errors. */
+export function quoteEmperorNexusAmbassadorPurchase(
+ g: Game, owner: string,
+): { event: string; price: 3; blocked: string | null } {
+ const source = ambassadorPurchaseSource(g, owner, true);
+ return { event: source.event, price: 3, blocked: source.blocked };
+}
+
+/** Bind the original purchase to the card the engine actually drew before the
+ * buyer's hand changes. The draw already removed it from the deck, so absence
+ * from the physical stock is valid; a duplicate or an undrawn card is not. */
+export function createEmperorNexusAmbassadorPurchase(
+ g: Game, owner: string, card: string,
+): EmperorNexusAmbassadorPurchase {
+ if (!text(owner) || !text(card))
+ throw new EmperorNexusPurchaseError(
+ 'Emperor Nexus Ambassador purchase needs its original buyer and drawn Treachery Card.',
+ );
+ const source = ambassadorPurchaseSource(g, owner, false);
+ if (source.blocked) throw new EmperorNexusPurchaseError(source.blocked);
+ const buyer = source.buyer!, entry = source.entry!;
+ const physical = [
+ ...g.players.flatMap(p => p.hand), ...g.deck, ...g.discard,
+ ...(g.richeseCache ?? []), ...(g.richeseRemoved ?? []),
+ ...(g.ixSetupCards ?? []), ...(g.ixAuction?.cards ?? []),
+ ...(g.ornithopter ? [g.ornithopter.card] : []),
+ ].filter(c => c.id === card);
+ if (physical.length !== 0)
+ throw new EmperorNexusPurchaseError(
+ 'Emperor Nexus Ambassador purchase lost its single physically drawn Treachery Card.',
+ );
+ const receipt: EmperorNexusAmbassadorPurchase = {
+ version: 3, kind: 'purchase', event: source.event, owner: buyer.id,
+ turn: g.turn, phase: g.phase, sequence: g.nexusEmperorPurchaseHistory?.length ?? 0,
+ faction: buyer.faction, advanced: g.advanced, origin: 'ambassador', price: 3,
+ card, beforeSpice: buyer.spice, afterSpice: buyer.spice, signature: '',
+ entry: entry.event, token: entry.token, entrant: entry.entrant,
+ };
+ receipt.signature = signEmperorNexusPurchase(receipt);
+ validateEmperorNexusPurchase(g, receipt);
+ return receipt;
+}
diff --git a/game/reference.ts b/game/reference.ts
index 23ae1f19..97a09d0a 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -1387,7 +1387,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'When another eligible faction enters a marked stronghold, Ecaz may trigger its Ambassador for itself or its current ally, or leave the token in place. Ecaz itself, its ally, advisors and the faction matching the physical Ambassador do not trigger it. Entries with competing arrival reactions remain blocked until their ordering is implemented; a rejected entry does not spend or move its pieces.',
'Emperor grants five bank spice. Atreides records the entrant’s current Treachery hand privately for the beneficiary; Harkonnen records one randomly selected held Traitor identity, or Face Dancer identity for a Tleilaxu entrant. These snapshots remain in the recipient’s private inspection history, identify when they were taken, and do not become a live view of later changes. Opening, enlarging or closing them needs no game acknowledgement.',
'CHOAM lets the beneficiary choose any available hand cards, including none, for three spice each. Ixians require one available card to discard before drawing its replacement. Card controls respect projected reservations. After committing an Ixian exchange, a voluntary interruption must leave a card available for its required discard; a queued Truthtrance counts as already committed. A Bene Gesserit trigger permanently removes its token, then the beneficiary chooses an available effect outside the original supply for this group; unsupported copies remain visibly blocked.',
- 'The Richese Ambassador automatically spends three of the chosen beneficiary’s spice for the top Treachery Card if the purchase can be completed. There is no extra purchase confirmation. An unavailable purchase consumes the triggered token without charging spice or granting a card; the public outcome does not disclose whether the beneficiary lacked spice or hand space. Emperor receives another faction’s payment unless that income is canceled. A Harkonnen buyer may receive its normal bonus card, subject to its hand limit and Karama. These benefits finish before movement or a worm ride resumes. Ixian allied replacement requires Bidding, so it does not apply to these entries.',
+ 'The Richese Ambassador purchases the top Treachery Card for three of the chosen beneficiary’s own spice when executable. Normally this is automatic; in supported Emperor-absent Nexus tables, the committed trigger instead offers uniform beneficiary Pay or eligible Emperor Nexus Use, with no new decline. Under the provisional purchase composition, an actual unallied holder may prove/retain that price, with no bank payment or reimbursement. Original private inability still consumes the token without payment/draw and does not disclose the reason. Native Emperor income and Harkonnen bonus remain applicable to their original sources, with hand limits and Karama; original movement/worm continuation runs once. Ixian replacement still requires Bidding.',
'The Fremen Ambassador lets the beneficiary relocate one group already on the board directly to any available territory and sector, subject to storm and occupancy. Select physical forces from one territory, preserve elite quantities, and optionally include your concealed No-Field without revealing it. This independent move costs no shipment spice and does not use ordinary movement allowances. It can change sectors within one territory, but pieces already in the destination sector stay in place. Homeworlds and reserves are not board territories.',
'The mobile stronghold can receive a non-Ixian group only from the territory it points to. Ecaz and its ally may co-occupy under Occupy. Advisors keep their applicable stance and accompaniment locks; an eligible arrival can request fighters. CHOAM may use Baliset during Shipment and Movement; if allowed, choose another legal destination. Intrusion or Terror resolves before the original entrant continues. A destination that causes both settles the Intrusion first and then offers the committed Terror entry; no payment or force transfer replays. There is no extra decline after triggering; if no legal move remains, the effect finishes without moving pieces and its token stays consumed.',
'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.',
@@ -4346,7 +4346,7 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Prototyped',
- summary: 'With Emperor absent, an unallied holder can choose exactly three additional free returns or retain a personally affordable original purchase price. Classic bank auctions and original Richese cache/Advanced Black Market now have distinct connected paths. Allied funding, direct CHOAM/Ambassador purchases and complete combinations remain unfinished.',
+ summary: 'With Emperor absent, an unallied holder can choose exactly three extra free returns or retain a personally affordable original purchase price. Classic bank auctions, Richese cache/Advanced Black Market and the Richese Ambassador have distinct connected paths. Other real purchase sources and full combinations retain separate contracts.',
steps: [
'Use the private Emperor Secret Ally controls during Revival. Select the physical elite count when more than one legal group is available. The card returns exactly three eligible forces from Tanks to reserves and is discarded once.',
'These returns cost no spice and do not consume ordinary force-revival or free-revival allowances. You may use the card before or after ordinary revivals, while Revival remains open.',
@@ -4354,14 +4354,15 @@ export const RULE_TOPICS: RuleTopic[] = [
'The independent revival grant enters original pending revival. Under the visible provisional policy, native Tleilaxu prevention/income, typed reserves, Tech and eligible Homeworld handling remain; ordinary free-rate blocking does not reprice it. Keeping the card creates no extra public confirmation.',
'At an eligible classic normal bank-auction winner payment, prove the positive final bid from your own spice and spend Emperor Nexus to receive the lot without losing spice. Every winner sees the same payment confirmation; the provisional recommendation is selected under the decision priority. Ordinary spice or legal Karama remains available.',
'Original Richese cache and Advanced Black Market offer Pay or eligible Emperor Nexus Use after original Richese Betrayal. The provisional seller-unpaid policy retains your full personally affordable price without bank reimbursement. One original lot, native Harkonnen bonus and source successor continue; legal self-cache is included but self-Black remains prohibited. Original self-purchase forced discard cannot consume this Nexus or acquire a card.',
+ 'A committed Richese Ambassador trigger now offers its original beneficiary Pay or eligible Emperor Nexus Use, with no new decline. Under the visible provisional purchase composition, prove and keep three own spice while drawing one original deck card; no bank payment, reimbursement or fake auction is made. Original private inability, native bonus and entrant movement/worm continuation remain. Allies cannot Use; actual forming-alliance Nexus disposal stays original.',
],
related: ['nexus-cards', 'revival'],
checklist: [
- { area: 'Implementation', status: 'Partial', detail: 'Independent extra-three enters original pending revival. Distinct classic bank and original Richese seller paths retain personally affordable price and one actual lot with signed source receipts. Original seller/stock/bonus/successor run with no invented reimbursement. Allied and other acquisition producers remain open.', evidence: ['game/nexus-emperor-secret-ally.ts', 'game/nexus-emperor-purchase.ts', 'game/auction-continuation-quote.ts', 'game/engine.ts'] },
- { area: 'Player controls', status: 'Partial', detail: 'Private typed revival and public uniform buyer confirmations with eligible Use, retained-price proof and visible provisional seller-unpaid policy; original Pay and native reactions remain.', evidence: ['components/nexus-emperor-secret-ally.tsx', 'components/nexus-emperor-seller-purchase.tsx', 'components/nexus-cards.tsx', 'components/game-table.tsx'] },
+ { area: 'Implementation', status: 'Partial', detail: 'Independent extra-three remains. Distinct original bank/Richese seller/Ambassador paths retain personally affordable price and actual acquisition with signed source receipts. Native draw/recycling, hand limits, bonus and source successor continue; no invented reimbursement or fake auction. Other real sources and full combinations remain open.', evidence: ['game/nexus-emperor-secret-ally.ts', 'game/nexus-emperor-purchase.ts', 'game/auction-continuation-quote.ts', 'game/engine.ts', 'game/ambassador-phase.ts', 'game/ambassador-resume.ts'] },
+ { area: 'Player controls', status: 'Partial', detail: 'Private typed revival and uniform original winning-buyer/committed-beneficiary confirmations with eligible Use, price proof and visible provisional source policy. Actual Pay, Truthtrance and original entrant controls remain; no new beneficiary veto.', evidence: ['components/nexus-emperor-secret-ally.tsx', 'components/nexus-emperor-seller-purchase.tsx', 'components/nexus-emperor-ambassador-purchase.tsx', 'components/nexus-cards.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Minimal profiles use projected legal revival and original personally funded purchase choices, or Pay, without inspecting rival private custody. Complete strategy/calibration remain unfinished.', evidence: ['game/nexus-emperor-secret-ally-options.ts', 'game/bots.ts'] },
- { area: 'Documentation', status: 'Partial', detail: 'Printed alternatives, independent revival and original bank/Richese purchases distinguish provisional native prevention, uniform timing and seller-unpaid recommendation from publisher rulings. Other acquisition sources and complete combinations remain open.', evidence: ['docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md', 'docs/RULE_DECISIONS.md'] },
- { area: 'Verification', status: 'Partial', detail: 'Current seller10-file union108/108, types/lint/build and twenty original API profiles pass. Actual390px cache/Black/self-cache/diverted-cache Use proves unchanged wallets, one lot and native bonus/successor without overflow/errors. Prior bank/revival evidence remains; broader acceptance and assurance remain open.', evidence: ['tests/nexus-emperor-secret-ally.test.ts', 'tests/nexus-emperor-secret-ally-recovery.test.ts', 'tests/nexus-emperor-purchase-engine.test.ts', 'tests/nexus-emperor-purchase-recovery.test.ts', 'tests/nexus-emperor-secret-ally-purchase-controls.test.ts', 'tests/fixture-nexus-emperor-seller.ts', 'tests/nexus-emperor-seller-purchase.test.ts', 'tests/nexus-emperor-seller-runtime.test.ts'] },
+ { area: 'Documentation', status: 'Partial', detail: 'Printed alternatives and original purchase classification distinguish independent revival, bank/Richese seller/Ambassador source compositions from publisher rulings. Other real acquisition sources and full combinations retain separate contracts; CHOAM closing sales/trades are not direct purchase producers.', evidence: ['docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md', 'docs/RULE_DECISIONS.md', 'docs/RICHESE_AMBASSADOR_RULES.md', 'docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md'] },
+ { area: 'Verification', status: 'Partial', detail: 'Current Ambassador13-file union111/111, types/lint/build and eight original API profiles pass. Four actual390px surfaces prove Pay3/Use0, one card and native movement/spice continuation without overflow/errors. Native allied Hark bonus, original alliance disposal and Truthtrance remain. Prior seller108/108/twenty API profiles and bank/revival evidence remain; broad assurance, combinations and deployed acceptance are open.', evidence: ['tests/nexus-emperor-secret-ally.test.ts', 'tests/nexus-emperor-secret-ally-recovery.test.ts', 'tests/nexus-emperor-purchase-engine.test.ts', 'tests/nexus-emperor-purchase-recovery.test.ts', 'tests/nexus-emperor-secret-ally-purchase-controls.test.ts', 'tests/fixture-nexus-emperor-seller.ts', 'tests/nexus-emperor-seller-purchase.test.ts', 'tests/nexus-emperor-seller-runtime.test.ts', 'tests/fixture-nexus-emperor-ambassador.ts', 'tests/nexus-emperor-ambassador-purchase.test.ts', 'tests/nexus-emperor-ambassador-runtime.test.ts'] },
],
},
{
@@ -4571,7 +4572,8 @@ export const RULE_TOPICS: RuleTopic[] = [
'Emperor Cunning requires an Advanced battle with at least five ordinary Emperor counters and no actual Sardaukar, before submitting your plan. The played Nexus card opens one Karama response. Allowing it makes five ordinary counters count as Sardaukar for this battle; cancellation prevents the entire enhancement and leaves the Nexus card spent.',
'Temporary Sardaukar use the applicable strength and support rules, including the Fremen exception, but remain physically ordinary counters. Their losses go to Tanks as ordinary counters, without changing starred reserves or Tanks. Existing inspected or promised plans remain binding. The role ends after this battle; fewer-than-five groups remain unavailable. Secret Ally independent revival, classic bank and original Richese seller purchases have connected controls; compulsory allied funding remains the separate operation below. Other acquisition sources and full combinations remain open.',
'Emperor Secret Ally revival selects exactly three real normal/starred counters for free, outside ordinary total/free and native allied-extra usage. Declaration spends one Nexus and enters original pending revival; no leader or absent native payer is granted. Under the visible provisional independent-source policy, native Tleilaxu may prevent it and actual free returns award income once; ordinary free-rate blocking and Recruits do not reprice or double this fixed grant. Denial keeps both spent cards without forces/income/Tech or refund. Native typed caps, reserve deposits, Axlotl and eligible newly returned Fedaykin placement continue. Tleilax ordinary Free Revival deployment is not extended to this extra source. Source receipts distinguish requested, returned and denied; old physical receipts never replay.',
- 'Original Richese cache and Advanced Black Market sold lots offer every buyer Pay after original Richese Betrayal. An eligible unallied Emperor Nexus holder, with Emperor absent and full price personally affordable, may Use to keep that spice and leave seller/bank unpaid under the visible provisional recommendation. Actual source stock, hand limits, native Harkonnen bonus and successor continue once. Legal self-cache is included; self-Black remains prohibited. Original self-discard produces no acquisition or Emperor Nexus spend. Direct CHOAM/Ambassador purchases remain open.',
+ 'Original Richese cache and Advanced Black Market sold lots offer every buyer Pay after original Richese Betrayal. An eligible unallied Emperor Nexus holder, with Emperor absent and full price personally affordable, may Use to retain it and leave seller/bank unpaid under the visible provisional recommendation. Actual stock, hand limits, native Harkonnen bonus and successor run once. Legal self-cache is included; self-Black remains blocked. Original self-discard gives no acquisition or Emperor Nexus spend.',
+ 'Original Richese Ambassador trigger offers the beneficiary uniform Pay or eligible Emperor Nexus Use, independently of hidden face or private availability. Under provisional purchase composition, an actual unallied holder proves/retains three own spice and acquires one original deck card, with no bank payment, reimbursement or fake auction. Native source phase/resume authority, private inability, draw/recycling, bonus and movement/worm suffix remain. An ally cannot Use; forming alliance disposes held Nexus originally. CHOAM closing sales/trades are not direct purchases.',
'Emperor Betrayal has a battle alternative in Advanced play: an unallied opponent holding the Emperor Nexus card can spend it during the ordinary pre-plan Sardaukar response to suppress actual Sardaukar strength for this battle. Every seat receives the same response opportunity even with no card, keeping hidden custody private. The starred pieces remain physical Sardaukar; no card grants a new force or cancels their separate support benefit.',
'During an original positive normal-auction purchase by Emperor’s ally, possible unallied held-Nexus rivals acknowledge in storm order before winner payment, regardless of secret face. Only the current actual Emperor-card holder may Use; Pass keeps cards and original payment. Under the provisional conservative full-price interpretation, native Emperor covers the full original price, reusing prepaid aid and adding only missing own spice. The buyer keeps their own spice and acquires one original card. Native Emperor income remains separately cancelable without refund; original next-lot inspection continues. No excess gift, bank-created subsidy or future-income borrowing is granted. Underfunded/fully prepaid Use is unavailable; special seller lots and full combinations remain open.',
'With Fremen absent, an actual unallied Fremen-card holder may enable a fixed ordinary free quota of three at a quiet Revival boundary, then use normal partial/typed revival controls. Activation moves no counters, leader or spice. Actual prior free use counts; paid returns keep their cost and total usage. Native total limits, higher free rates, typed caps, prevention, Homeworld deposits/deployment, Axlotl activity and once-only Tleilaxu income remain original. Recruits doubles its native rate, not this fixed quota. Useless or unknown-ledger grants are unavailable. This is the visibly provisional recommended interpretation; strict old physical receipts adopt metadata without replay. Independent sandworm protection remains the separate operation below.',
@@ -4601,11 +4603,11 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal', 'nexus-guild-betrayal', 'nexus-ixian-replacement', 'nexus-ixian-betrayal'],
checklist: [
- {area:'Implementation',status:'Partial',detail:"Existing extra revival and compulsory funding remain. Emperor Secret Ally now retains personally affordable original Richese cache/Advanced Black Market price after original Betrayal, with strict signed source receipts. Seller/bank receive no invented reimbursement; actual stock, hand limits, Hark bonus and successor continue. Basic/Advanced cache and legal self-cache are included; self-Black stays blocked. Direct CHOAM/Ambassador purchases, wider combinations and assurance remain open.",evidence:['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally.ts', 'game/revival-resume.ts', 'game/revival-cancellation.ts', 'game/homeworld-revival-deployment.ts', 'game/homeworld-revival-return.ts', "game/nexus-emperor-purchase.ts", "game/auction-continuation-quote.ts"]},
- {area:'Player controls',status:'Partial',detail:"Uniform winning-buyer Pay follows original Richese Betrayal regardless concealed face. Actual eligible holder sees Use with retained price and visible provisional seller-unpaid policy. Original Truthtrance discard and bonus controls continue. Actual390px cache, Black Market, self-cache and diverted cache prove usable controls without overflow/errors. Wider surfaces and complete-mode acceptance remain open.",evidence:['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx','components/nexus-emperor-payment.tsx', 'components/nexus-emperor-secret-ally.tsx', "components/nexus-emperor-seller-purchase.tsx"]},
- {area:'AI',status:'Partial',detail:"Minimal profiles use the shared projected seller entitlement and original Pay otherwise, without inspecting rival private custody. Existing extra-return, compulsory funding and other Nexus adapters remain. Strategy and four-tier calibration remain deferred and unfinished.",evidence:['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally-options.ts']},
- {area:'Documentation',status:'Partial',detail:"Printed alternatives retain explicit partial gates. Original Richese seller composition now selects visibly provisional unpaid-seller recommendation under the decision priority, not publisher clarification or a numerical user ruling. Original Betrayal precedes buyer confirmation; direct CHOAM/Ambassador sources and full combinations remain open.",evidence:['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md', "docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md", "docs/RULE_DECISIONS.md"]},
- {area:'Verification',status:'Partial',detail:"Seller affected10-file union108/108 and types/lint/build pass. Genuine original setup/worm/pact/draw and positive cache/Black Market sales exercise Pay/Use, source stock, actual Hark bonus, self-cache, Truthtrance and original Betrayal pass/diversion/self-discard. Actual390px four surfaces prove unchanged wallets, one original card and native source successor without overflow/errors. Prior extra266/266, funding155/155 and quota329/329 evidence remains. Full combinations, assurance and deployed acceptance remain open.",evidence:['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'tests/nexus-fremen-free-quota.test.ts', 'tests/fixture-nexus-fremen-free-quota.ts', 'tests/nexus-fremen-free-quota-runtime.test.ts', 'tests/nexus-fremen-revival.test.ts','tests/nexus-emperor-payment.test.ts','tests/fixture-nexus-emperor-payment.ts','tests/nexus-emperor-payment-runtime.test.ts','docs/NEXUS_EMPEROR_RULES.md', 'tests/nexus-emperor-extra-source.test.ts', 'tests/nexus-emperor-extra-continuation.test.ts', 'tests/fixture-nexus-emperor-extra.ts', 'tests/nexus-emperor-extra-runtime.test.ts', 'tests/fixture-single-nexus-e1.ts', 'docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md', "tests/fixture-nexus-emperor-seller.ts", "tests/nexus-emperor-seller-purchase.test.ts", "tests/nexus-emperor-seller-runtime.test.ts"]},
+ {area:'Implementation',status:'Partial',detail:"Existing independent revival, compulsory funding and Richese seller paths remain. Emperor Secret Ally now applies to the original Richese Ambassador purchase through uniform beneficiary confirmation after trigger. Version3 source evidence binds original token, entry, phase, buyer and actual card; native draw/recycling and movement/worm continuation run once. No bank payment, reimbursement or fake auction is invented. Original source-bound Homeworld phase authority remains; full combinations and assurance are open.",evidence:['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally.ts', 'game/revival-resume.ts', 'game/revival-cancellation.ts', 'game/homeworld-revival-deployment.ts', 'game/homeworld-revival-return.ts', "game/nexus-emperor-purchase.ts", "game/auction-continuation-quote.ts", "game/nexus-emperor-purchase.ts", "game/ambassador-phase.ts", "game/ambassador-resume.ts"]},
+ {area:'Player controls',status:'Partial',detail:"Actual original buyer sees Pay and eligible Use after committed Ambassador trigger, with visible provisional purchase composition and no extra decline. Original Truthtrance discard restores the same decision; entrant control remains native. Actual390px Basic Use, Advanced Pay, irrelevant-card Pay and phase-one worm Use prove one original card and native successor without overflow/errors. Existing Nexus surfaces remain; complete-mode acceptance stays open.",evidence:['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx','components/nexus-emperor-payment.tsx', 'components/nexus-emperor-secret-ally.tsx', "components/nexus-emperor-seller-purchase.tsx", "components/nexus-emperor-ambassador-purchase.tsx"]},
+ {area:'AI',status:'Partial',detail:"Minimal profiles use shared projected original Ambassador entitlement or native Pay, without private rival inspection or borrowing entrant control. Existing independent return, compulsory funding and seller adapters remain. Strategy and four-tier calibration remain deferred and unfinished.",evidence:['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally-options.ts']},
+ {area:'Documentation',status:'Partial',detail:"Printed purchase panel and existing Richese Ambassador purchase classification compose outside Bidding under a visibly provisional recommendation. Uniform confirmation does not add a beneficiary veto; bank payment/reimbursement and fake auctions are not invented. CHOAM closing market only sells/trades, not a missing direct purchase producer. Other real sources, complete combinations and publication gates retain separate contracts.",evidence:['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md', "docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md", "docs/RULE_DECISIONS.md", "docs/RICHESE_AMBASSADOR_RULES.md", "docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md"]},
+ {area:'Verification',status:'Partial',detail:"Ambassador affected13-file union111/111 and types/lint/build pass. Eight actual Basic/Advanced shipment/worm API profiles prove Pay3 versus Use0, actual card and native suffix. Actual390px four surfaces prove Basic11 to11, Advanced11 to8, irrelevant-card Pay and phase-one worm11 to11 with one card and original entrant/spice controls, no overflow/errors. Native Hark allied Pay bonus once, original alliance disposal and actual Truthtrance remain. Fixture-only nonexistent Spice Card/retained-allied-Nexus assumptions and action/default pins were removed. Prior seller108/108, extra266/266, funding155/155 and quota329/329 evidence remains; broad assurance/deployed acceptance stay open.",evidence:['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'tests/nexus-fremen-free-quota.test.ts', 'tests/fixture-nexus-fremen-free-quota.ts', 'tests/nexus-fremen-free-quota-runtime.test.ts', 'tests/nexus-fremen-revival.test.ts','tests/nexus-emperor-payment.test.ts','tests/fixture-nexus-emperor-payment.ts','tests/nexus-emperor-payment-runtime.test.ts','docs/NEXUS_EMPEROR_RULES.md', 'tests/nexus-emperor-extra-source.test.ts', 'tests/nexus-emperor-extra-continuation.test.ts', 'tests/fixture-nexus-emperor-extra.ts', 'tests/nexus-emperor-extra-runtime.test.ts', 'tests/fixture-single-nexus-e1.ts', 'docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md', "tests/fixture-nexus-emperor-seller.ts", "tests/nexus-emperor-seller-purchase.test.ts", "tests/nexus-emperor-seller-runtime.test.ts", "tests/fixture-nexus-emperor-ambassador.ts", "tests/nexus-emperor-ambassador-purchase.test.ts", "tests/nexus-emperor-ambassador-runtime.test.ts"]},
],
},
{
diff --git a/game/richese-cancellation.ts b/game/richese-cancellation.ts
index e6f91021..385dade7 100644
--- a/game/richese-cancellation.ts
+++ b/game/richese-cancellation.ts
@@ -92,6 +92,7 @@ const DECISIONS = {
nexusFremenProtection: true,
nexusEmperorPayment: true,
nexusEmperorSellerPurchase: true,
+ nexusEmperorAmbassadorPurchase: true,
nexusFremenCunningRide: true,
moritaniRetention: true,
moritaniSpecialKarama: true,
diff --git a/tests/fixture-nexus-emperor-ambassador.ts b/tests/fixture-nexus-emperor-ambassador.ts
new file mode 100644
index 00000000..d513a3f9
--- /dev/null
+++ b/tests/fixture-nexus-emperor-ambassador.ts
@@ -0,0 +1,219 @@
+import assert from 'node:assert/strict';
+import { applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer,
+ type Action, type Game, type Player } from '../game/engine';
+import { treacheryDeck, type Card } from '../game/cards';
+import { AMBASSADOR_EFFECTS, validateAmbassadors } from '../game/ecaz-ambassadors';
+import { validateNexusCards } from '../game/nexus-cards';
+import { nextE3NexusEcazStep } from './fixture-e3-nexus-ecaz';
+import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
+
+export type NexusEmperorAmbassadorStep = { actor: string; action: Action };
+export type NexusEmperorAmbassadorTransition = {
+ before: Game; step: NexusEmperorAmbassadorStep; after: Game;
+};
+export type NexusEmperorAmbassadorFixture = {
+ game: Game; owner: string; buyer: string; entrant: string; event: string; token: string;
+ card: Card; actions: NexusEmperorAmbassadorStep[];
+ wallets: Record;
+ chronology: {
+ initial: Game; undealt: Game; afterSetup: Game; firstMentat: NexusEmperorAmbassadorTransition;
+ worm: Game; alliance: Game; draw: NexusEmperorAmbassadorTransition; afterClosingDraw: Game;
+ placement: NexusEmperorAmbassadorTransition; beforeArrival: Game;
+ arrival: NexusEmperorAmbassadorTransition; entry: Game; trigger: NexusEmperorAmbassadorTransition;
+ };
+};
+export const nexusEmperorAmbassadorClean = (g: Game): boolean =>
+ !g.response && !g.phaseOpening && !g.decision && !g.truthtrance && !g.pendingTreacheryDiscard;
+export function nexusEmperorAmbassadorPlayer(g: Game, id: string): Player {
+ const player = g.players.find(p => p.id === id); assert.ok(player); return player;
+}
+export function stepNexusEmperorAmbassador(g: Game, step: NexusEmperorAmbassadorStep,
+ actions?: NexusEmperorAmbassadorStep[]): Game {
+ actions?.push(structuredClone(step));
+ return withClassicDiscoveryNexusLottery(() => applyAction(structuredClone(g), step.actor, structuredClone(step.action)));
+}
+export function nextNexusEmperorAmbassadorStep(g: Game): NexusEmperorAmbassadorStep {
+ if (!g.response && !g.phaseOpening && g.decision?.kind === 'nexusEmperorAmbassadorPurchase')
+ return { actor: g.decision.player,
+ action: { type: 'nexusEmperorAmbassadorPurchase', event: g.decision.event, use: false } };
+ const step = nextE3NexusEcazStep(g);
+ assert.ok(step, `Original Ambassador chronology needs its owner at ${g.turn}/${g.phase}/${g.decision?.kind}`);
+ return step;
+}
+export function advanceNexusEmperorAmbassador(g: Game, until: (g: Game) => boolean,
+ actions?: NexusEmperorAmbassadorStep[]): Game {
+ for (let n = 0; n < 2600; n++) {
+ if (until(g)) return g;
+ assert.notEqual(g.status, 'finished');
+ g = stepNexusEmperorAmbassador(g, nextNexusEmperorAmbassadorStep(g), actions);
+ }
+ throw Error('Original Ambassador chronology did not reach its requested boundary');
+}
+function transition(g: Game, step: NexusEmperorAmbassadorStep,
+ actions: NexusEmperorAmbassadorStep[]): NexusEmperorAmbassadorTransition {
+ return { before: structuredClone(g), step: structuredClone(step),
+ after: stepNexusEmperorAmbassador(g, step, actions) };
+}
+/** Original component shuffle only, while the five Ambassador tokens are undealt. */
+function originalRicheseCohort(operation: () => T): T {
+ const source = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz');
+ const desired = ['richese', ...source.filter(effect => effect !== 'richese')];
+ const current: string[] = [...source], 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]];
+ }
+ const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
+ const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
+ crypto.getRandomValues = (array: V): V => {
+ if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 0xffffffff;
+ else Reflect.apply(native, crypto, [array]);
+ return array;
+ };
+ try { return operation(); }
+ finally {
+ if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
+ else Reflect.deleteProperty(crypto, 'getRandomValues');
+ }
+}
+/** Reorder only physical cards that have not yet left the original Spice deck. */
+function originalBlow(g: Game, worm: boolean, land: string): void {
+ const desired = [...(worm ? ['worm'] : []), land, ...(g.advanced ? ['other'] : [])];
+ for (const [position, face] of desired.entries()) {
+ const index = g.spiceDeck.findIndex((c, i) => i >= position && (face === 'worm'
+ ? 'worm' in c && !c.greatMaker && !c.suppressed && !c.thumper
+ : 'territory' in c && !c.discovery && (face === 'other'
+ ? !['hagga_basin', 'red_chasm', 'rock_outcroppings'].includes(c.territory)
+ : c.territory === face)));
+ assert.ok(index >= position, `Original undealt Spice card ${face} must remain available`);
+ g.spiceDeck.splice(position, 0, g.spiceDeck.splice(index, 1)[0]);
+ }
+}
+export function nexusEmperorAmbassadorCards(g: Game): string[] {
+ return [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand),
+ ...(g.auction?.cards.slice(g.auction.index + Number(!!g.currentAuctionSale)) ?? [])]
+ .map(c => c.id).sort();
+}
+export function nexusEmperorAmbassadorInventory(g: Game): void {
+ validateNexusCards(g.nexusCards!.cards!, g.players); validateAmbassadors(g.ecazAmbassadors!);
+ assert.deepEqual(nexusEmperorAmbassadorCards(g), treacheryDeck(['ecaz']).map(c => c.id).sort());
+ for (const p of g.players) assert.equal(p.reserves + p.tanks +
+ Object.values(p.forces).reduce((sum, amount) => sum + amount, 0), 20);
+}
+/** Original standalone Ecaz Nexus setup, actual other-seat pact and closing draw,
+ * original paid placement, physical entry and committed Richese trigger. Only
+ * undealt deck/token lotteries are selected. No played hand, wallet, alliance,
+ * force, clock, receipt or control frame is installed by this producer. */
+export function createNexusEmperorAmbassadorFixture(options: {
+ advanced?: boolean; nexusCard?: 'emperor' | 'guild'; truthtrance?: boolean;
+ arrival?: 'shipment' | 'worm'; buyer?: 'ecaz' | 'harkonnen';
+} = {}): NexusEmperorAmbassadorFixture {
+ const advanced = options.advanced ?? true, owner = 'ecaz', buyer = options.buyer ?? owner;
+ assert.ok(buyer !== 'harkonnen' || options.arrival !== 'worm');
+ const actions: NexusEmperorAmbassadorStep[] = [];
+ let g = createGame('NEXUSEMPERORAMBASSADOR', newPlayer(owner, 'Ecaz', 'ecaz'), advanced, ['ecaz']);
+ for (const faction of ['guild', 'fremen', ...(buyer === 'harkonnen' ? ['harkonnen' as const] : [])] as const)
+ joinGame(g, newPlayer(faction, faction, faction));
+ g.nexusCards = { cards: null, phase: null };
+ const initial = structuredClone(g);
+ for (const player of g.players)
+ g = stepNexusEmperorAmbassador(g, { actor: player.id, action: { type: 'ready' } }, actions);
+ g = withClassicDiscoveryNexusLottery(() => initializeNexusGameForAudit(g));
+ assert.ok(g.players.every(p => !p.hand.length));
+ const front: Card[] = [];
+ for (const player of g.players) for (let n = 0; n < (player.faction === 'harkonnen' ? 2 : 1); n++) {
+ const index = g.deck.findIndex(card => options.truthtrance && player.id === 'guild'
+ ? card.effect === 'truthtrance' : card.kind === 'worthless' || card.kind === 'shield');
+ assert.ok(index >= 0); front.push(g.deck.splice(index, 1)[0]);
+ }
+ g.deck.unshift(...front);
+ const undealt = structuredClone(g);
+ while (g.status === 'setup') {
+ const step = nextNexusEmperorAmbassadorStep(g);
+ if (step.action.type === 'fremenSetup')
+ step.action.placements = { sietch_tabr: 0, false_wall_south: 10, false_wall_west: 0 };
+ const lastTraitor = step.action.type === 'traitor' && g.players.filter(p => p.traitorChoices.length).length === 1;
+ if (lastTraitor) {
+ actions.push(structuredClone(step));
+ g = originalRicheseCohort(() => applyAction(structuredClone(g), step.actor, step.action));
+ } else g = stepNexusEmperorAmbassador(g, step, actions);
+ }
+ const afterSetup = structuredClone(g);
+ assert.ok(g.ecazAmbassadors!.tokens.some(t => t.effect === 'richese' && t.zone === 'supply'));
+ originalBlow(g, false, 'hagga_basin');
+ let firstMentat: NexusEmperorAmbassadorTransition | undefined;
+ while (g.turn === 1) {
+ const t = transition(g, nextNexusEmperorAmbassadorStep(g), actions); g = t.after;
+ if (g.turn === 2) firstMentat = t;
+ }
+ assert.ok(firstMentat);
+ g = advanceNexusEmperorAmbassador(g, s => s.turn === 2 && s.phase === 1 && nexusEmperorAmbassadorClean(s), actions);
+ originalBlow(g, true, 'rock_outcroppings');
+ g = advanceNexusEmperorAmbassador(g, s => !!s.nexus && !s.spiceWindow && !s.spiceResolution && nexusEmperorAmbassadorClean(s), actions);
+ const worm = structuredClone(g);
+ for (const [actor, target] of [['guild', 'fremen'], ['fremen', 'guild']])
+ g = stepNexusEmperorAmbassador(g, { actor, action: { type: 'alliance', target } }, actions);
+ const alliance = structuredClone(g);
+ g = advanceNexusEmperorAmbassador(g, s => s.nexusCards?.phase?.stage === 'drawing' && nexusEmperorAmbassadorClean(s), actions);
+ const cards = g.nexusCards!.cards!, at = cards.deck.indexOf(options.nexusCard ?? 'emperor');
+ assert.ok(at >= 0); cards.deck.unshift(cards.deck.splice(at, 1)[0]);
+ const draw = transition(g, { actor: owner, action: { type: 'nexusCardChoice', turn: g.turn,
+ card: cards.hands[owner], choice: 'draw', ownRedraws: 0 } }, actions); g = draw.after;
+ g = advanceNexusEmperorAmbassador(g, s => s.nexusCards?.phase?.stage !== 'drawing' && nexusEmperorAmbassadorClean(s), actions);
+ const afterClosingDraw = structuredClone(g);
+ g = advanceNexusEmperorAmbassador(g, s => s.decision?.kind === 'ecazPlacement' && !s.response && !s.phaseOpening, actions);
+ const token = g.ecazAmbassadors!.tokens.find(t => t.effect === 'richese')!.id;
+ const placement = transition(g, { actor: owner, action: { type: 'decision', token, territory: 'arrakeen' } }, actions);
+ g = advanceNexusEmperorAmbassador(placement.after, s => s.phase === 5 && nexusEmperorAmbassadorClean(s), actions);
+ assert.equal(g.ecazAmbassadors!.tokens.find(t => t.id === token)!.location, 'arrakeen');
+ if (options.arrival === 'worm') {
+ g = advanceNexusEmperorAmbassador(g, s => s.turn === 2 && s.phase === 5 &&
+ s.active === 'fremen' && nexusEmperorAmbassadorClean(s), actions);
+ g = stepNexusEmperorAmbassador(g, { actor: 'fremen',
+ action: { type: 'ship', territory: 'rock_outcroppings', sector: 13, amount: 1 } }, actions);
+ g = stepNexusEmperorAmbassador(g, { actor: 'fremen', action: { type: 'endMovement' } }, actions);
+ }
+ if (options.arrival === 'worm' || buyer === 'harkonnen') {
+ g = advanceNexusEmperorAmbassador(g, s => s.turn === 3 && s.phase === 1 && nexusEmperorAmbassadorClean(s), actions);
+ originalBlow(g, true, 'red_chasm');
+ }
+ if (buyer === 'harkonnen') {
+ g = advanceNexusEmperorAmbassador(g, s => !!s.nexus && !s.spiceWindow && !s.spiceResolution && nexusEmperorAmbassadorClean(s), actions);
+ for (const [actor, target] of [[owner, buyer], [buyer, owner]])
+ g = stepNexusEmperorAmbassador(g, { actor, action: { type: 'alliance', target } }, actions);
+ }
+ const entrant = options.arrival === 'worm' ? 'fremen' : 'guild';
+ let arrivalStep: NexusEmperorAmbassadorStep;
+ if (options.arrival === 'worm') {
+ g = advanceNexusEmperorAmbassador(g, s => s.decision?.kind === 'wormRide' && !s.response && !s.phaseOpening, actions);
+ assert.ok(g.decision?.kind === 'wormRide');
+ assert.equal(g.decision!.territory, 'rock_outcroppings');
+ const source = Object.keys(nexusEmperorAmbassadorPlayer(g, entrant).forces).find(key => key.startsWith('rock_outcroppings:'));
+ assert.ok(source);
+ arrivalStep = { actor: entrant, action: { type: 'decision', accept: true,
+ territory: 'arrakeen', sector: 10, forces: { [source]: 1 } } };
+ } else {
+ g = advanceNexusEmperorAmbassador(g, s => s.phase === 5 && s.active === entrant && nexusEmperorAmbassadorClean(s), actions);
+ arrivalStep = { actor: entrant, action: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1 } };
+ }
+ const beforeArrival = structuredClone(g), arrival = transition(g, arrivalStep, actions); g = arrival.after;
+ g = advanceNexusEmperorAmbassador(g, s => !!s.pendingAmbassador && !s.response && !s.phaseOpening, actions);
+ const entry = structuredClone(g);
+ assert.equal(g.pendingAmbassador!.stage, 'offer');
+ const trigger = transition(g, { actor: owner, action: { type: 'decision',
+ event: g.pendingAmbassador!.event, trigger: true, beneficiary: buyer } }, actions); g = trigger.after;
+ assert.ok(g.decision?.kind === 'nexusEmperorAmbassadorPurchase');
+ assert.equal(g.decision!.player, buyer);
+ const event = g.decision!.event, card = g.deck[0]; assert.ok(card);
+ nexusEmperorAmbassadorInventory(g);
+ return { game: g, owner, buyer, entrant, event, token, card, actions,
+ wallets: Object.fromEntries(g.players.map(p => [p.id, p.spice])),
+ chronology: { initial, undealt, afterSetup, firstMentat, worm, alliance, draw,
+ afterClosingDraw, placement, beforeArrival, arrival, entry, trigger } };
+}
+/** Deterministic source successor: finish only the original purchase's response
+ * children. Its actual movement/worm suffix is left for the caller to observe. */
+export function finishNexusEmperorAmbassador(g: Game): Game {
+ return advanceNexusEmperorAmbassador(g, s => !s.pendingAmbassador && !s.response && !s.truthtrance);
+}
diff --git a/tests/nexus-emperor-ambassador-purchase.test.ts b/tests/nexus-emperor-ambassador-purchase.test.ts
new file mode 100644
index 00000000..b46c04a4
--- /dev/null
+++ b/tests/nexus-emperor-ambassador-purchase.test.ts
@@ -0,0 +1,237 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { baseDeck } from '../game/cards';
+import { createGame, handLimit, newPlayer, type Game } from '../game/engine';
+import { createNexusCards, drawNexusCard } from '../game/nexus-cards';
+import {
+ createAmbassadors, placeAmbassador, triggerAmbassador, validateAmbassadors,
+ type AmbassadorEffect,
+} from '../game/ecaz-ambassadors';
+import {
+ EmperorNexusPurchaseError, createEmperorNexusAmbassadorPurchase,
+ emperorNexusAmbassadorPurchaseModeSupported,
+ quoteEmperorNexusAmbassadorPurchase, signEmperorNexusPurchase, validateEmperorNexusPurchase,
+ type EmperorNexusAmbassadorPurchase,
+} from '../game/nexus-emperor-purchase';
+
+const ENTRY = 'ambassador:2:1:57';
+
+type FixtureOptions = {
+ phase?: 1 | 5;
+ /** Ecaz allied with Harkonnen, so the ally would be the beneficiary. */
+ ally?: boolean;
+ beneficiary?: string;
+ /** Ecaz keeps the concealed Emperor Nexus Secret Ally card. */
+ holder?: boolean;
+ spice?: number;
+ fullHand?: boolean;
+ supply?: number;
+ /** Set false to leave the physical token unconsumed in its stronghold. */
+ consumed?: boolean;
+ /** The Bene Gesserit token copies the Richese effect instead of the real token. */
+ copy?: boolean;
+ copyChoices?: AmbassadorEffect[];
+ effect?: 'atreides' | 'richese';
+ entrant?: string;
+ turn?: number;
+};
+
+function fixture(options: FixtureOptions = {}) {
+ const g = createGame('AMBQUOTE', newPlayer('ec', 'Ecaz', 'ecaz'), false, ['ecaz']);
+ g.players.push(newPlayer('at', 'Atreides', 'atreides'), newPlayer('ha', 'Harkonnen', 'harkonnen'));
+ g.order = g.players.map(p => p.id);
+ g.status = 'playing';
+ g.turn = 2;
+ g.phase = options.phase ?? 1;
+ g.active = 'at';
+ g.movementRemaining = [...g.order];
+ g.deck = baseDeck();
+ g.discard = [];
+ for (const player of g.players) {
+ player.hand = [];
+ player.spice = 10;
+ player.ally = null;
+ player.reserves = 20;
+ player.tanks = 0;
+ player.forces = {};
+ }
+ const ecaz = g.players[0], harkonnen = g.players[2];
+ if (options.ally) {
+ ecaz.ally = 'ha';
+ harkonnen.ally = 'ec';
+ }
+ if (options.spice !== undefined) ecaz.spice = options.spice;
+ if (options.fullHand) ecaz.hand = g.deck.splice(0, handLimit(ecaz));
+ if (options.supply !== undefined) g.deck = g.deck.slice(0, options.supply);
+ const cards = createNexusCards(g.players, () => 0);
+ if ((options.holder ?? true) && !options.ally) {
+ cards.deck = ['emperor', ...cards.deck.filter(card => card !== 'emperor')];
+ g.nexusCards = { cards: drawNexusCard(cards, 'ec', g.players, () => 0), phase: null };
+ } else g.nexusCards = { cards, phase: null };
+ let state = createAmbassadors(() => 0.2);
+ const effect = options.copy ? 'beneGesserit' : 'richese';
+ const token = state.tokens.find(t => t.effect === effect)!;
+ state.cohort = [
+ token.id,
+ ...state.tokens.filter(t => t.effect !== 'ecaz' && t.id !== token.id).slice(0, 4).map(t => t.id),
+ ];
+ for (const candidate of state.tokens) {
+ candidate.zone = candidate.effect === 'ecaz' || state.cohort.includes(candidate.id) ? 'supply' : 'pool';
+ candidate.location = null;
+ }
+ state = placeAmbassador(state, token.id, {
+ turn: 1,
+ availableSpice: 20,
+ destination: { id: 'arrakeen', stronghold: true, inStorm: false, allowed: true },
+ }).state;
+ if (options.consumed ?? true) state = triggerAmbassador(state, token.id);
+ validateAmbassadors(state);
+ g.ecazAmbassadors = state;
+ g.pendingAmbassador = {
+ event: ENTRY,
+ owner: 'ec',
+ entrant: options.entrant ?? 'at',
+ token: token.id,
+ territory: 'arrakeen',
+ sector: 10,
+ turn: options.turn ?? g.turn,
+ phase: g.phase,
+ stage: 'purchase',
+ resume: 'none',
+ copyChoices: options.copyChoices ?? (options.copy ? ['richese'] : []),
+ effect: options.effect ?? 'richese',
+ beneficiary: options.beneficiary ?? (options.ally ? 'ha' : 'ec'),
+ };
+ return { g, ecaz };
+}
+
+function lobby(factions: Parameters[2][], expansions: string[]): Game {
+ const g = createGame('AMBMODE', newPlayer(factions[0], factions[0], factions[0]), false, expansions);
+ for (const faction of factions.slice(1)) g.players.push(newPlayer(faction, faction, faction));
+ g.order = g.players.map(p => p.id);
+ return g;
+}
+
+
+void test('private room, spice, supply and the concealed Nexus face only disable Use, never the Pay confirmation', () => {
+ const cases: [string, FixtureOptions, string][] = [
+ ['an allied buyer', { ally: true, beneficiary: 'ec' }, 'ec'],
+ ['an allied beneficiary', { ally: true, beneficiary: 'ha' }, 'ha'],
+ ['a buyer without the real card', { holder: false }, 'ec'],
+ ['two spice', { spice: 2 }, 'ec'],
+ ['a full hand', { fullHand: true }, 'ec'],
+ ['an empty supply', { supply: 0 }, 'ec'],
+ ];
+ for (const [label, options, owner] of cases) {
+ const { g } = fixture(options), before = structuredClone(g);
+ const quote = quoteEmperorNexusAmbassadorPurchase(g, owner);
+ assert.ok(quote.blocked, label);
+ assert.deepEqual(g, before, label);
+ if (options.supply !== 0) assert.throws(() => createEmperorNexusAmbassadorPurchase(g, owner, 'drawn-card'),
+ EmperorNexusPurchaseError, label);
+ }
+ const { g } = fixture();
+ g.players.push(newPlayer('em', 'Emperor', 'emperor'));
+ g.order.push('em');
+ assert.throws(() => quoteEmperorNexusAmbassadorPurchase(g, 'ec'), EmperorNexusPurchaseError);
+});
+
+void test('malformed original source or stale entry cannot be used to retain a purchase price', () => {
+ const cases: [string, FixtureOptions, string][] = [
+ ['an unconsumed token', { consumed: false }, 'ec'],
+ ['a copied effect outside this cohort', { copy: true, copyChoices: [] }, 'ec'],
+ ['another copied effect', { copy: true, copyChoices: ['atreides'] }, 'ec'],
+ ['a different effect', { effect: 'atreides' }, 'ec'],
+ ['another beneficiary', {}, 'ha'],
+ ['a stale turn', { turn: 1 }, 'ec'],
+ ['the buyer entering its own Ambassador', { entrant: 'ec' }, 'ec'],
+ ];
+ for (const [label, options, owner] of cases) {
+ const { g } = fixture(options);
+ assert.throws(() => quoteEmperorNexusAmbassadorPurchase(g, owner), EmperorNexusPurchaseError, label);
+ assert.throws(() => createEmperorNexusAmbassadorPurchase(g, owner, 'drawn-card'), EmperorNexusPurchaseError, label);
+ }
+ // A copied Bene Gesserit token is a legal original source for the same price.
+ assert.equal(quoteEmperorNexusAmbassadorPurchase(fixture({ copy: true }).g, 'ec').blocked, null);
+});
+
+
+void test('the offer keeps its supply proof before the draw and accepts the last drawn card afterwards', () => {
+ const { g } = fixture({ supply: 1 });
+ assert.equal(quoteEmperorNexusAmbassadorPurchase(g, 'ec').blocked, null);
+ const last = g.deck[0];
+ g.deck = [];
+ const receipt = createEmperorNexusAmbassadorPurchase(g, 'ec', last.id);
+ validateEmperorNexusPurchase(g, receipt);
+ const duplicated = fixture();
+ const shared = duplicated.g.deck[0];
+ duplicated.g.discard.push(shared);
+ assert.throws(() => createEmperorNexusAmbassadorPurchase(duplicated.g, 'ec', shared.id),
+ /single physically drawn Treachery Card/);
+ const undrawn = fixture();
+ assert.throws(() => createEmperorNexusAmbassadorPurchase(undrawn.g, 'ec', undrawn.g.deck[0].id),
+ /single physically drawn Treachery Card/);
+ const later = fixture();
+ later.ecaz.hand.push(later.g.deck.shift()!);
+ assert.throws(() => createEmperorNexusAmbassadorPurchase(later.g, 'ec', later.ecaz.hand.at(-1)!.id),
+ EmperorNexusPurchaseError);
+});
+
+void test('version three history rejects a changed trigger, price or retained balance even after re-signing', () => {
+ const { g } = fixture();
+ const card = g.deck[0];
+ g.deck = g.deck.slice(1);
+ const receipt = createEmperorNexusAmbassadorPurchase(g, 'ec', card.id);
+ const invalid = (patch: Partial, resign = false) => {
+ const changed = { ...receipt, ...patch };
+ if (resign) changed.signature = signEmperorNexusPurchase(changed);
+ assert.throws(() => validateEmperorNexusPurchase(g, changed), EmperorNexusPurchaseError,
+ JSON.stringify(patch));
+ };
+ // Every recorded field is inside the fixed signature tuple.
+ for (const patch of [
+ { entry: 'other-entry' }, { token: 'ambassador-1' }, { entrant: 'ha' },
+ { card: 'other-card' }, { owner: 'ha' }, { price: 2 as never }, { sequence: 1 },
+ { origin: 'cache' } as never, { phase: 5 as never }, { turn: 1 },
+ ]) invalid(patch);
+ for (const patch of [
+ { version: 2 as never }, { price: 2 as never }, { price: 4 as never },
+ { phase: 0 as never }, { phase: 9 as never }, { turn: 0 }, { turn: 3 },
+ { beforeSpice: 2, afterSpice: 2 }, { afterSpice: 9 },
+ { entrant: 'ec' }, { entry: ' ' }, { token: ' ' }, { entrant: ' ' },
+ { card: ' ' }, { advanced: true },
+ { round: 'round' } as never, { lot: 'lot' } as never, { seller: 'ha' } as never,
+ { auctionIndex: 0 } as never, { bankSubsidy: 3 } as never,
+ ]) invalid(patch, true);
+});
+
+
+void test('the public Ecaz envelope reuses original E3 Nexus and Skills profiles without an Emperor', () => {
+ const standalone = lobby(['ecaz', 'atreides', 'harkonnen'], ['ecaz']);
+ standalone.nexusCards = { cards: null, phase: null };
+ assert.equal(emperorNexusAmbassadorPurchaseModeSupported(standalone), true);
+ // An undealt, concealed Nexus face is not part of the public mode proof.
+ const skills = lobby(['ecaz', 'atreides', 'harkonnen'], ['ecaz']);
+ skills.nexusCards = { cards: null, phase: null };
+ skills.leaderSkills = {} as NonNullable;
+ assert.equal(emperorNexusAmbassadorPurchaseModeSupported(skills), true);
+ const paired = lobby(['ecaz', 'moritani', 'atreides'], ['ecaz']);
+ paired.nexusCards = { cards: null, phase: null };
+ assert.equal(emperorNexusAmbassadorPurchaseModeSupported(paired), true);
+ const mixed = lobby(['ecaz', 'ixians', 'atreides'], ['ecaz', 'ix']);
+ mixed.nexusCards = { cards: null, phase: null };
+ assert.equal(emperorNexusAmbassadorPurchaseModeSupported(mixed), true);
+ const withoutNexus = lobby(['ecaz', 'atreides', 'harkonnen'], ['ecaz']);
+ const withEmperor = lobby(['ecaz', 'emperor', 'harkonnen'], ['ecaz']);
+ withEmperor.nexusCards = { cards: null, phase: null };
+ const classicOnly = lobby(['atreides', 'harkonnen'], []);
+ classicOnly.nexusCards = { cards: null, phase: null };
+ const sandtrout = lobby(['ecaz', 'atreides', 'harkonnen'], ['ecaz']);
+ sandtrout.nexusCards = { cards: null, phase: null };
+ sandtrout.sandtrout = true;
+ const treachery = lobby(['ecaz', 'atreides', 'harkonnen'], ['ecaz']);
+ treachery.nexusCards = { cards: null, phase: null };
+ treachery.ecazTreachery = true;
+ for (const unsupported of [withoutNexus, withEmperor, classicOnly, sandtrout, treachery])
+ assert.equal(emperorNexusAmbassadorPurchaseModeSupported(unsupported), false);
+});
diff --git a/tests/nexus-emperor-ambassador-runtime.test.ts b/tests/nexus-emperor-ambassador-runtime.test.ts
new file mode 100644
index 00000000..680d64ce
--- /dev/null
+++ b/tests/nexus-emperor-ambassador-runtime.test.ts
@@ -0,0 +1,185 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, viewGame, type Action, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import {
+ createNexusEmperorAmbassadorFixture, finishNexusEmperorAmbassador,
+ nexusEmperorAmbassadorCards, nexusEmperorAmbassadorInventory, nexusEmperorAmbassadorPlayer,
+ nextNexusEmperorAmbassadorStep, stepNexusEmperorAmbassador,
+} from './fixture-nexus-emperor-ambassador';
+
+function rejectsUnchanged(game: Game, actor: string, action: Action): void {
+ const before = structuredClone(game);
+ assert.throws(() => applyAction(game, actor, action));
+ assert.deepEqual(game, before, 'Rejection must precede spending, drawing and Ambassador continuation');
+}
+
+for (const advanced of [false, true]) for (const arrival of ['shipment', 'worm'] as const) void test(
+ `${advanced ? 'Advanced' : 'Basic'} original ${arrival}: Pay 3 versus Use 0 purchases the same physical card exactly once`, () => {
+ const f = createNexusEmperorAmbassadorFixture({ advanced, arrival });
+ const before = structuredClone(f.game), own = nexusEmperorAmbassadorPlayer(before, f.buyer);
+ assert.deepEqual(f.game.deck, f.chronology.entry.deck, 'Offering confirmation cannot reserve or draw a card');
+ assert.deepEqual(f.game.discard, f.chronology.entry.discard);
+ assert.equal(own.hand.length, nexusEmperorAmbassadorPlayer(f.chronology.entry, f.buyer).hand.length);
+ if (arrival === 'shipment') assert.equal(f.game.active, f.entrant, 'The buyer does not replace the original movement actor');
+ else assert.equal(f.game.pendingAmbassador!.resume, 'wormRide');
+ const offer = viewGame(f.game, f.buyer).nexusEmperorAmbassadorPurchase!;
+ assert.equal(offer.canUse, true); assert.equal(offer.blocked, null);
+ for (const rival of f.game.players.filter(p => p.id !== f.buyer)) {
+ const view = viewGame(f.game, rival.id);
+ assert.equal(view.nexusEmperorAmbassadorPurchase, null);
+ assert.equal(view.players.find(p => p.id === f.buyer)!.hand, undefined);
+ assert.equal(JSON.stringify(view.ambassadorEntry).includes(f.card.id), false);
+ }
+ const outcomes = [false, true].map(use => {
+ const action = { type: 'nexusEmperorAmbassadorPurchase', event: f.event, use };
+ let game = stepNexusEmperorAmbassador(f.game, { actor: f.buyer, action });
+ game = finishNexusEmperorAmbassador(game);
+ assert.equal(nexusEmperorAmbassadorPlayer(game, f.buyer).spice, own.spice - (use ? 0 : 3));
+ assert.equal(nexusEmperorAmbassadorPlayer(game, f.buyer).hand.length, own.hand.length + 1,
+ 'Ecaz must not receive a fabricated native Harkonnen bonus');
+ assert.equal(nexusEmperorAmbassadorPlayer(game, f.buyer).hand.filter(c => c.id === f.card.id).length, 1);
+ assert.deepEqual(game.deck, before.deck.slice(1));
+ assert.deepEqual(game.discard, before.discard);
+ assert.equal(game.nexusCards!.cards!.hands[f.buyer], use ? null : 'emperor');
+ assert.equal(game.nexusCards!.cards!.discard.filter(c => c === 'emperor').length, use ? 1 : 0);
+ assert.equal(game.response?.kind === 'emperorIncome', false);
+ assert.equal(game.auction ?? null, null); assert.equal(game.currentAuctionSale ?? null, null);
+ for (const rival of game.players.filter(p => p.id !== f.buyer))
+ assert.equal(rival.spice, f.wallets[rival.id], 'No seller, subsidy, reimbursement or income is invented');
+ assert.deepEqual(game.players.map(p => [p.id, p.forces, p.reserves, p.shipped, p.moved]),
+ before.players.map(p => [p.id, p.forces, p.reserves, p.shipped, p.moved]));
+ assert.deepEqual(game.movementRemaining, before.movementRemaining);
+ assert.deepEqual(nexusEmperorAmbassadorCards(game), nexusEmperorAmbassadorCards(before));
+ nexusEmperorAmbassadorInventory(game);
+ if (use) {
+ assert.equal(game.nexusEmperorPurchaseEvents!.filter(event => event === f.event).length, 1);
+ } else assert.deepEqual(game.nexusEmperorPurchaseHistory ?? [], before.nexusEmperorPurchaseHistory ?? []);
+ rejectsUnchanged(game, f.buyer, action);
+ return game;
+ });
+ assert.deepEqual(f.game, before);
+ assert.deepEqual(outcomes[0].decision, outcomes[1].decision);
+ assert.deepEqual(outcomes[0].wormRides, outcomes[1].wormRides);
+ if (arrival === 'shipment') {
+ for (const game of outcomes) {
+ assert.equal(game.active, f.entrant);
+ const next = stepNexusEmperorAmbassador(game, { actor: f.entrant, action: { type: 'endMovement' } });
+ assert.notEqual(next.active, f.entrant);
+ assert.deepEqual(next.deck, game.deck, 'The original movement successor cannot repeat the purchase');
+ }
+ } else {
+ const steps = outcomes.map(nextNexusEmperorAmbassadorStep);
+ for (const [i, game] of outcomes.entries()) {
+ const next = stepNexusEmperorAmbassador(game, steps[i]);
+ assert.deepEqual(next.deck, game.deck);
+ assert.equal(nexusEmperorAmbassadorPlayer(next, f.buyer).hand.length, own.hand.length + 1);
+ }
+ }
+ },
+);
+
+void test('wrong actor, stale event, extra cost fields, coerced input and original-trigger replay are atomic refusals', () => {
+ const f = createNexusEmperorAmbassadorFixture();
+ for (const rival of f.game.players.filter(p => p.id !== f.buyer))
+ rejectsUnchanged(f.game, rival.id, { type: 'nexusEmperorAmbassadorPurchase', event: f.event, use: true });
+ for (const action of [
+ { type: 'nexusEmperorAmbassadorPurchase', event: 'stale', use: true },
+ { type: 'nexusEmperorAmbassadorPurchase', event: f.event, use: true, price: 0 },
+ { type: 'nexusEmperorAmbassadorPurchase', event: f.event, use: false, allyPayment: 3 },
+ { type: 'nexusEmperorAmbassadorPurchase', event: f.event, use: true, card: f.card.id },
+ { type: 'nexusEmperorAmbassadorPurchase', event: f.event, use: true, cost: f.card.id },
+ { type: 'nexusEmperorAmbassadorPurchase', event: f.event, use: 'true' },
+ { type: 'nexusEmperorAmbassadorPurchase', event: f.event, use: 1 },
+ { type: 'nexusEmperorAmbassadorPurchase', event: f.event },
+ { type: 'decision', event: f.event, use: true },
+ f.chronology.trigger.step.action,
+ ]) rejectsUnchanged(f.game, f.buyer, action);
+ nexusEmperorAmbassadorInventory(f.game);
+});
+
+for (const advanced of [false, true]) void test(
+ `${advanced ? 'Advanced' : 'Basic'} irrelevant original Nexus draw still opens uniform buyer Pay, never Use`, () => {
+ const f = createNexusEmperorAmbassadorFixture({ advanced, nexusCard: 'guild' });
+ const offer = viewGame(f.game, f.buyer).nexusEmperorAmbassadorPurchase!;
+ assert.equal(offer.canUse, false);
+ rejectsUnchanged(f.game, f.buyer, { type: 'nexusEmperorAmbassadorPurchase', event: f.event, use: true });
+ const game = finishNexusEmperorAmbassador(stepNexusEmperorAmbassador(f.game, { actor: f.buyer,
+ action: { type: 'nexusEmperorAmbassadorPurchase', event: f.event, use: false } }));
+ assert.equal(nexusEmperorAmbassadorPlayer(game, f.buyer).spice, f.wallets[f.buyer] - 3);
+ assert.equal(game.nexusCards!.cards!.hands[f.buyer], 'guild');
+ assert.deepEqual(game.deck, f.game.deck.slice(1));
+ assert.equal(nexusEmperorAmbassadorPlayer(game, f.buyer).hand.filter(c => c.id === f.card.id).length, 1);
+ nexusEmperorAmbassadorInventory(game);
+ },
+);
+
+void test('actual setup-dealt Truthtrance suspends the original Ambassador confirmation and restores the same purchase', () => {
+ const f = createNexusEmperorAmbassadorFixture({ truthtrance: true });
+ const card = nexusEmperorAmbassadorPlayer(f.game, 'guild').hand.find(c => c.effect === 'truthtrance');
+ assert.ok(card);
+ assert.ok(nexusEmperorAmbassadorPlayer(f.chronology.afterSetup, 'guild').hand.some(c => c.id === card.id));
+ let game = stepNexusEmperorAmbassador(f.game, { actor: 'guild', action: { type: 'card', card: card.id } });
+ assert.equal(game.truthtrance!.stage, 'priority');
+ rejectsUnchanged(game, f.buyer, { type: 'nexusEmperorAmbassadorPurchase', event: f.event, use: true });
+ while (game.truthtrance?.stage === 'priority') {
+ const actor = game.players.find(p => !game.truthtrance!.passed.includes(p.id))!.id;
+ game = stepNexusEmperorAmbassador(game, { actor, action: { type: 'truthPass' } });
+ }
+ const name = nexusEmperorAmbassadorPlayer(game, f.buyer).hand[0].name;
+ game = stepNexusEmperorAmbassador(game, { actor: 'guild', action: { type: 'truthAsk',
+ question: { kind: 'fact', target: f.buyer, fact: { kind: 'hand', name } } } });
+ assert.equal(viewGame(game, f.buyer).truthAnswer, 'yes');
+ game = stepNexusEmperorAmbassador(game, { actor: f.buyer, action: { type: 'truthAnswer', answer: 'yes' } });
+ assert.equal(game.truthtrance, null);
+ assert.ok(game.decision?.kind === 'nexusEmperorAmbassadorPurchase');
+ assert.deepEqual(game.deck, f.game.deck);
+ assert.equal(nexusEmperorAmbassadorPlayer(game, f.buyer).spice, f.wallets[f.buyer]);
+ game = finishNexusEmperorAmbassador(stepNexusEmperorAmbassador(game, { actor: f.buyer,
+ action: { type: 'nexusEmperorAmbassadorPurchase', event: f.event, use: true } }));
+ assert.equal(nexusEmperorAmbassadorPlayer(game, f.buyer).hand.filter(c => c.id === f.card.id).length, 1);
+ assert.equal(game.discard.filter(c => c.id === card.id).length, 1);
+ nexusEmperorAmbassadorInventory(game);
+});
+
+for (const advanced of [false, true]) void test(
+ `${advanced ? 'Advanced' : 'Basic'} real later alliance gives native Harkonnen buyer Pay-only and one original bonus`, () => {
+ const f = createNexusEmperorAmbassadorFixture({ advanced, buyer: 'harkonnen' });
+ assert.equal(nexusEmperorAmbassadorPlayer(f.game, f.buyer).ally, f.owner);
+ assert.equal(nexusEmperorAmbassadorPlayer(f.game, f.owner).ally, f.buyer);
+ assert.equal(viewGame(f.game, f.buyer).nexusEmperorAmbassadorPurchase!.canUse, false);
+ assert.equal(viewGame(f.game, f.owner).nexusEmperorAmbassadorPurchase, null);
+ for (const actor of [f.owner, f.buyer])
+ rejectsUnchanged(f.game, actor, { type: 'nexusEmperorAmbassadorPurchase', event: f.event, use: true });
+ const hand = nexusEmperorAmbassadorPlayer(f.game, f.buyer).hand.length;
+ let game = stepNexusEmperorAmbassador(f.game, { actor: f.buyer,
+ action: { type: 'nexusEmperorAmbassadorPurchase', event: f.event, use: false } });
+ const bonus = f.game.deck[1]; assert.ok(bonus);
+ game = finishNexusEmperorAmbassador(game);
+ assert.equal(nexusEmperorAmbassadorPlayer(game, f.buyer).hand.length, hand + 2);
+ assert.equal(nexusEmperorAmbassadorPlayer(game, f.buyer).spice, f.wallets[f.buyer] - 3);
+ assert.equal(nexusEmperorAmbassadorPlayer(game, f.buyer).hand.filter(c => c.id === bonus.id).length, 1);
+ assert.deepEqual(game.deck, f.game.deck.slice(2));
+ assert.deepEqual(game.nexusCards!.cards, f.game.nexusCards!.cards,
+ 'The original paid purchase must not consume another card after native alliance disposal.');
+ for (const rival of game.players.filter(p => p.id !== f.buyer)) assert.equal(rival.spice, f.wallets[rival.id]);
+ assert.equal(game.auction ?? null, null); assert.equal(game.currentAuctionSale ?? null, null);
+ nexusEmperorAmbassadorInventory(game);
+ },
+);
+
+void test('original Easy policy resolves both actual native buyer entitlements without borrowing movement control', () => {
+ for (const nexusCard of ['emperor', 'guild'] as const) {
+ const f = createNexusEmperorAmbassadorFixture({ nexusCard });
+ const view = viewGame(f.game, f.buyer);
+ view.players.find(p => p.id === f.buyer)!.bot = 'Easy';
+ const action = botActions(view)[0]; assert.ok(action);
+ const game = finishNexusEmperorAmbassador(stepNexusEmperorAmbassador(f.game, { actor: f.buyer, action }));
+ assert.equal(game.active, f.entrant);
+ assert.equal(nexusEmperorAmbassadorPlayer(game, f.buyer).spice,
+ f.wallets[f.buyer] - (nexusCard === 'emperor' ? 0 : 3));
+ assert.equal(game.nexusCards!.cards!.hands[f.buyer], nexusCard === 'emperor' ? null : 'guild');
+ assert.equal(nexusEmperorAmbassadorPlayer(game, f.buyer).hand.filter(c => c.id === f.card.id).length, 1);
+ nexusEmperorAmbassadorInventory(game);
+ }
+});
diff --git a/tests/nexus-emperor-purchase-quote.test.ts b/tests/nexus-emperor-purchase-quote.test.ts
index b01279d2..eba13156 100644
--- a/tests/nexus-emperor-purchase-quote.test.ts
+++ b/tests/nexus-emperor-purchase-quote.test.ts
@@ -6,7 +6,7 @@ import {
emperorNexusPurchaseEvent,
signEmperorNexusPurchase,
validateEmperorNexusPurchase,
- type EmperorNexusPurchase,
+ type EmperorNexusBankPurchase,
} from '../game/nexus-emperor-purchase';
import { emperorNexusEvent } from '../game/nexus-emperor-secret-ally';
@@ -19,8 +19,8 @@ const context = {
],
};
-function purchase(): EmperorNexusPurchase {
- const receipt: EmperorNexusPurchase = {
+function purchase(): EmperorNexusBankPurchase {
+ const receipt: EmperorNexusBankPurchase = {
kind: 'purchase',
event: emperorNexusPurchaseEvent(3, 'winner', 2, 1, 4),
owner: 'winner',
@@ -41,9 +41,6 @@ function purchase(): EmperorNexusPurchase {
}
void test('only a positive whole bid affordable personally and without ally contribution can be quoted', () => {
- assert.equal(quoteEmperorNexusPurchase(1, 1, 0), undefined);
- assert.equal(quoteEmperorNexusPurchase(5, 9, 0), undefined);
- assert.equal(quoteEmperorNexusPurchase(Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, 0), undefined);
for (const [price, ownSpice, allyPayment] of [
[0, 10, 0], [-1, 10, 0], [1.5, 10, 0], [NaN, 10, 0],
[Infinity, 10, 0], [Number.MAX_SAFE_INTEGER + 1, Number.MAX_SAFE_INTEGER + 1, 0],
@@ -58,26 +55,10 @@ void test('only a positive whole bid affordable personally and without ally cont
}
});
-void test('typed signatures are stable across property order and JSON reload; earlier turns remain valid', () => {
- const receipt = purchase();
- assert.equal(receipt.signature, signEmperorNexusPurchase({ ...receipt, signature: 'ignored' }));
- assert.equal(receipt.signature, signEmperorNexusPurchase({
- signature: '', afterSpice: 7, beforeSpice: 7, card: 'treachery-17', price: 4, auctionIndex: 1,
- advanced: false, faction: 'atreides', sequence: 2, phase: 3,
- turn: 3, owner: 'winner', event: receipt.event, kind: 'purchase',
- }));
- assert.doesNotThrow(() => validateEmperorNexusPurchase(context, JSON.parse(JSON.stringify(receipt))));
- assert.doesNotThrow(() => validateEmperorNexusPurchase({ ...context, advanced: true }, {
- ...receipt, advanced: true, signature: signEmperorNexusPurchase({ ...receipt, advanced: true }),
- }));
- const minimum = { ...receipt, price: 1, event: emperorNexusPurchaseEvent(3, 'winner', 2, 1, 1), beforeSpice: 1, afterSpice: 1 };
- minimum.signature = signEmperorNexusPurchase(minimum);
- assert.doesNotThrow(() => validateEmperorNexusPurchase(context, minimum));
-});
void test('receipt fields and financial transitions cannot be changed after signing', () => {
const receipt = purchase();
- const invalid = (patch: Partial) =>
+ const invalid = (patch: Partial) =>
assert.throws(
() => validateEmperorNexusPurchase(context, { ...receipt, ...patch }),
EmperorNexusPurchaseError,
@@ -100,7 +81,7 @@ void test('receipt fields and financial transitions cannot be changed after sign
void test('even re-signed invalid receipts reject future, nonauction, malformed, or non-reimbursed records', () => {
const receipt = purchase();
- const invalid = (patch: Partial) => {
+ const invalid = (patch: Partial) => {
const changed = { ...receipt, ...patch };
changed.signature = signEmperorNexusPurchase(changed);
assert.throws(() => validateEmperorNexusPurchase(context, changed), EmperorNexusPurchaseError);