{offer.deckCount} in deck · {offer.discardCount} discarded · {Object.values(offer.held).filter((held) => held === true).length} held
-
Playable effects include Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit, Emperor, CHOAM and Moritani Cunning, plus Emperor Secret Ally’s three extra free revivals, Fremen Secret Ally’s ordinary three free revivals, Richese Secret Ally reserve shipping and Guild Cunning’s second shipment or absent-Guild Secret Ally shipping. Use the controls shown at each effect’s timing. CHOAM’s Kull Wahad effect, Moritani Cunning relocation and Grumman combinations, and other unfinished effects remain unavailable.
+
Playable effects include Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit, Emperor, CHOAM and Moritani Cunning, plus Emperor Secret Ally’s bank-auction purchase and three extra free revivals, Fremen Secret Ally’s ordinary three free revivals, Richese Secret Ally reserve shipping and Guild Cunning’s second shipment or absent-Guild Secret Ally shipping. Use the controls shown at each effect’s timing. CHOAM’s Kull Wahad effect, Moritani Cunning relocation and Grumman combinations, and other unfinished effects remain unavailable.
diff --git a/components/nexus-emperor-secret-ally.tsx b/components/nexus-emperor-secret-ally.tsx
index 532f5c7a..0086a047 100644
--- a/components/nexus-emperor-secret-ally.tsx
+++ b/components/nexus-emperor-secret-ally.tsx
@@ -2,7 +2,7 @@
import { useState } from 'react';
import type { Action, GameView } from '@/game/engine';
-import { nexusEmperorRevivalAction } from '@/game/nexus-emperor-secret-ally-options';
+import { nexusEmperorPurchaseAction, nexusEmperorRevivalAction } from '@/game/nexus-emperor-secret-ally-options';
import { Button } from '@/components/ui/button';
export function NexusEmperorSecretAlly({
@@ -18,6 +18,47 @@ export function NexusEmperorSecretAlly({
const offer = game.nexusEmperorSecretAlly;
const owner = game.players.find((player) => player.id === game.me);
const emperorSeated = game.players.some((player) => player.faction === 'emperor');
+ const purchase =
+ offer?.purchase &&
+ game.nexusCards?.card === 'emperor' &&
+ !!owner &&
+ !emperorSeated &&
+ game.status === 'playing' &&
+ game.phase === 3 &&
+ game.decision?.kind === 'auctionPayment' &&
+ game.decision.player === game.me
+ ? offer.purchase
+ : null;
+ if (purchase) {
+ const action = nexusEmperorPurchaseAction(game);
+ const blocked = busy || !action;
+ const reason = purchase.blocked ??
+ (!action
+ ? (owner?.spice ?? 0) < purchase.price
+ ? `You need ${purchase.price} spice in your own supply for this bid.`
+ : 'Finish the current interaction before spending the Emperor Nexus card.'
+ : null);
+ return (
+
+
Emperor Secret Ally
+
+ Your final bid is {purchase.price} spice. You must have all {purchase.price} spice
+ in your own supply without ally aid. Spend the Emperor Nexus card to keep that
+ spice instead of paying the bank; you receive the auction card normally.
+
+ {reason &&
{reason}
}
+
+
+ );
+ }
const eligible =
offer?.revival &&
game.nexusCards?.card === 'emperor' &&
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index ca78ee8f..9183899d 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -80,6 +80,17 @@ normal-first elite selection and battle-loss accounting remain explicitly
provisional interpretations, not a publisher or user ruling; full variant and
public mode gates remain closed. This source is not in the live image.
+The bounded [Emperor Nexus Secret Ally bank-auction purchase](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md#bank-auction-purchase-alternative)
+connects the separate retained-price alternative in Basic/Advanced
+classic-faction Nexus-only games. An unallied winner proves the final bid from
+their own spice, keeps it, takes the physical Treachery lot and spends the
+Nexus; owner controls, legal AI, signed history and saved SQLite continuation
+are wired. Every eligible winner receives the same payment confirmation
+regardless of hidden card custody; this privacy-preserving timing is
+**provisional**, with the prior user question still pending. Seller payments,
+allied splits, non-Bidding sources, Betrayal and complete Nexus remain gated.
+This source is not in the live image.
+
5,408 offline tests, 52 HTTP tests, build, twenty-two administrator HTTP groups
and desktop/mobile browser acceptance passed. Deployment delivery was
unconfirmed at that checkpoint; the current production revision is recorded
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 4d037788..9847a4e6 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,32 @@
# Dune implementation status
+## 27 September 2026 — Bounded Emperor Nexus bank-auction purchase
+
+[The separate Secret Ally purchase alternative](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md#bank-auction-purchase-alternative)
+lets an unallied holder with Emperor absent retain the full personally
+affordable winning bid on an ordinary bank-paid Treachery auction. The actual
+lot enters the winner's hand, the unique Emperor Nexus enters the discard,
+and ordinary auction follow-ups continue. A uniform winner confirmation in
+eligible classic-faction Nexus-only games avoids revealing hidden ownership;
+its timing remains a provisional product choice pending the recorded user
+question. Owner-only controls, all four legal AI profiles, signed event/receipt,
+JSON integrity and concurrent in-memory SQLite CAS/restart/replay are connected.
+Seller-paid/allied/expansion and non-Bidding transactions remain gated, as do
+the forced Emperor-payment Betrayal alternative and complete Nexus acceptance.
+
+Independent review found and prompted repair of a stale cross-lot offer event
+and a bypass of normal-auction physical custody/hand-capacity preflight.
+The offer now binds the public auction index and final price without exposing
+the hidden lot face. Twenty-four focused purchase/automatic-payment cases
+passed; the frozen offline suite passed 5,610/5,610 across 708 files.
+Isolated project TypeScript excluding only the unrelated Fremen Nexus draft,
+scoped oxlint and the app build passed. `npm run check` and full lint stop at
+that draft's missing `Player.fremenNexusMovementBlockedTurn`. A read-only local
+browser fixture displayed the owner's purchase control, bid proof and ordinary
+payment choice; a rival seat saw only the uniform waiting decision and no
+private offer or card. No room write occurred. Production remains on the
+last verified `c9a3625` image.
+
## 27 September 2026 — Bounded Reinforcements Battle Plan
[The independent three-card runtime](REINFORCEMENTS_RUNTIME.md) now permits
diff --git a/docs/NEXUS_EMPEROR_RULES.md b/docs/NEXUS_EMPEROR_RULES.md
index 95c55627..8d1bfc03 100644
--- a/docs/NEXUS_EMPEROR_RULES.md
+++ b/docs/NEXUS_EMPEROR_RULES.md
@@ -1,6 +1,6 @@
# Emperor Nexus: battle strength, purchase and revival
-Primary-source audit, 10 September 2026. This is a contract for future implementation, not a runtime-completion claim. The [common Nexus rules](NEXUS_CARD_RULES.md) and current release gates remain in force. No new user question was sent.
+Primary-source audit, 10 September 2026, with a bounded purchase-runtime note added 27 September. The source contract does not certify the full mode. The [common Nexus rules](NEXUS_CARD_RULES.md) and current release gates remain in force; no new user ruling is assumed.
## Authority
@@ -50,6 +50,25 @@ For an ordinary bank-paid auction, the practical result is one purchased card, u
Special seller payments require a separate ruling: if Richese or another faction would receive that price, is the seller unpaid or paid by the bank? The text only tells the buyer to keep spice. Likewise, it does not expressly decide purchases outside Bidding, such as a card acquisition from an Ambassador. Do not silently route income or broaden a generic purchase hook to those sources.
+### Bounded bank-auction purchase prototype (27 September 2026)
+
+In Basic/Advanced classic-faction Nexus-only games with Emperor absent, the
+unallied holder can spend the physical card at a normal Bidding winner-payment
+window. The full positive final bid must be affordable from the buyer's own
+spice without ally contribution. The bank receives no spice; the winner keeps
+that balance, receives the one purchased physical Treachery Card and continues
+through normal auction follow-ups. One private signed receipt records the
+original lot, final price and unchanged buyer balance across JSON/SQLite
+recovery. No Richese/CHOAM seller payment, allied split, expansion, special
+purchase or generic card-acquisition hook is inferred from this path.
+
+Every winner in this bounded public auction context receives the same payment
+confirmation, even without the card or Karama. Otherwise a pause only for the
+concealed holder would disclose Nexus ownership. This is a **provisional
+privacy-preserving product choice**, not a printed timing rule or a user ruling;
+the previously asked confirmation-versus-gate question remains open.
+The normal spice and Karama payment choices remain available where legal.
+
## Secret Ally: three additional free revivals
The express additional/beyond-limits wording supplies a separate force allowance rather than spending the holder's ordinary three-force quota. It revives forces, not a leader or Cheap Hero. Recruits doubles free rates and changes the normal limit; it does not double a fixed three-counter card grant. [Printed card](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani), [E3, p.11](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=11)
@@ -78,10 +97,11 @@ receipt, suppression and seat-private offer survive JSON and room restart;
replay cannot spend it twice. Karama remains an independent alternative in
the same window.
-This does not implement the forced-purchase Betrayal alternative or the
-Secret Ally purchase. Emperor Cunning still has its own earlier declaration
-and response; one unique Emperor Nexus card cannot be both Cunning and
-Betrayal at once. The wider Nexus module remains gated.
+This does not implement the forced-purchase Betrayal alternative. The bounded
+Secret Ally bank-auction purchase is connected above; Emperor Cunning still
+has its own earlier declaration and response. One unique Emperor Nexus card
+cannot be both Cunning and Betrayal at once. The wider Nexus module remains
+gated.
The following material cases remain unresolved; no new question was sent:
diff --git a/docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md b/docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md
index 9538954d..cc24d642 100644
--- a/docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md
+++ b/docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md
@@ -1,7 +1,7 @@
-# Emperor Nexus Secret Ally revival
+# Emperor Nexus Secret Ally revival and bank-auction purchase
-14 September 2026. **Prototyped**, with **Partial** rules coverage. This connects
-the separate three-force revival alternative in Basic and Advanced base-faction
+27 September 2026. **Prototyped**, with **Partial** rules coverage. These
+separate Secret Ally alternatives connect in Basic and Advanced classic-faction
games with Nexus alone. Public module starts and publication remain gated.
## Rules and connected behavior
@@ -37,18 +37,32 @@ physical elite selector only when it offers a real choice, shows unavailability
reasons, and disables submission while busy. The existing Nexus inspector retains
all three printed panels. This is legal functional support, not calibrated strategy.
-## Purchase alternative: pending interface decision
+## Bank-auction purchase alternative
The printed purchase option preserves the actual buyer's spice after proving
-the price is held. It supplies a choice when buying. A pause only for the hidden
-holder would disclose eligibility; a per-bid instruction would commit earlier,
-before opponents finish passing or bidding. Neither is silently substituted.
+the final price is held. An unallied holder, with Emperor absent, can spend the
+physical Nexus at the winner-payment decision of an ordinary bank auction.
+The winner must have the full positive bid in their own spendable spice and
+cannot use ally contribution. One actual Treachery lot enters their hand,
+their spice remains unchanged, and normal auction/card follow-ups continue.
+The public log announces the price/proof/use without revealing excess spice or
+the Treachery card face. A private signed receipt records the lot, price and
+unchanged balance; independent use markers and physical custody survive JSON
+and in-memory SQLite CAS/restart. Rival seat views omit the offer and receipt.
-The user has been asked whether to permit a uniform winner payment step,
-including otherwise forced spice payments, as an exception to the preference
-against forced confirmations. The answer is pending. The purchase effect is
-unimplemented; its source-specific seller-payment and non-Bidding boundaries also
-remain in the [source contract](NEXUS_EMPEROR_RULES.md#secret-ally-retain-the-actual-purchase-price).
+**Timing is a provisional privacy choice, not a user or publisher ruling.**
+Every winner in this bounded public auction context receives the same payment
+confirmation, including a player with only spice and no Nexus card. Otherwise
+the holder-only pause would disclose hidden ownership. The existing ordinary
+spice/Karama payment choices stay available; bots use a legal purchase when
+owned and otherwise use the ordinary decision. The previously asked
+confirmation-versus-gate question remains pending; this prototype does not
+settle it for release.
+
+Seller-paid Richese/CHOAM transactions, allied split, expansions, other
+purchase sources and the forced Emperor-payment Betrayal alternative remain
+gated. The [source contract](NEXUS_EMPEROR_RULES.md#secret-ally-retain-the-actual-purchase-price)
+records those payment boundaries.
## Prototype entry and evidence
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index ca4ae26d..7fccc244 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -85,7 +85,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| CHOAM Secret Ally battle timing | The printed after-victory inspection is optional and the Nexus identity stays secret. The connected prototype chooses a uniform use-or-continue window for every winner when Nexus is enabled and CHOAM absent; nonholders pass, while a holder may decide after the result. This privacy-preserving prompt is a product inference, not publisher text. A holder-only pause would reveal custody and a precommitment would remove the printed choice. Ordinary Karama cancellation remains unestablished by retrieved sources; no response is invented. | [After-victory inspection boundary](NEXUS_CHOAM_SECRET_ALLY.md#after-victory-inspection-audit) |
| 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) |
-| Emperor Secret Ally purchase timing | The purchase choice follows winning the bid. A holder-only payment pause exposes concealed card eligibility, while per-bid preauthorization moves the choice earlier. The user has been asked whether to permit a uniform winner payment confirmation, including sole spice payments, or retain the purchase gate. No answer is assumed; the independent three-force revival grant can proceed. | [Purchase source contract](NEXUS_EMPEROR_RULES.md#secret-ally-retain-the-actual-purchase-price) |
+| Emperor Secret Ally purchase timing | The bounded normal bank-auction prototype chooses a uniform winner payment confirmation in classic-faction Nexus-only games with Emperor absent, including otherwise sole spice payments. This prevents a holder-only pause from exposing hidden card eligibility. It is a provisional product privacy choice, **not** a user or publisher ruling; the previously asked confirmation-versus-gate question remains open. Seller-paid/allied purchases and other sources stay gated. | [Source contract and bounded prototype](NEXUS_EMPEROR_RULES.md#secret-ally-retain-the-actual-purchase-price), [runtime](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md#bank-auction-purchase-alternative) |
| 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/bots.ts b/game/bots.ts
index 8f917158..179028bb 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -79,7 +79,7 @@ import { quoteSmugglerShipment } from './smuggler-shipment';
import { quoteSmugglerNoField } from './smuggler-no-field';
import { spiceBankerBattleMaximum, spiceBankerModeSupported } from './spice-banker';
import { nexusGuildSecretAllyAction, nexusGuildSecretAllyCanAct, nexusGuildSecretAllyQuote } from './nexus-guild-secret-ally-options';
-import { nexusEmperorRevivalAction } from './nexus-emperor-secret-ally-options';
+import { nexusEmperorPurchaseAction, nexusEmperorRevivalAction } from './nexus-emperor-secret-ally-options';
import { nexusFremenRevivalAction } from './nexus-fremen-revival-options';
import { nexusRicheseAction, nexusRicheseQuote } from './nexus-richese-options';
import { liveShipmentPromises, matchesShipment } from './shipment-promises';
@@ -3824,6 +3824,8 @@ export function botActions(g: GameView): Action[] {
const action = nexusEmperorRevivalAction(g, elite);
if (action) return [action];
}
+ const emperorPurchase = nexusEmperorPurchaseAction(g);
+ if (emperorPurchase) return [emperorPurchase];
const bureaucrat = g.bureaucrat?.pending;
if (bureaucrat) {
if (bureaucrat.owner !== g.me || g.decision?.kind !== 'bureaucratPayment' ||
diff --git a/game/engine.ts b/game/engine.ts
index b236a647..4c02071f 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -39,6 +39,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, emperorNexusEvent, emperorNexusSignature, emperorNexusModeSupported, emperorNexusPools, emperorNexusRevivalElites, validateEmperorNexusRevival, type NexusEmperorRevival } from './nexus-emperor-secret-ally';
+import { emperorNexusPurchaseEvent, quoteEmperorNexusPurchase, signEmperorNexusPurchase, validateEmperorNexusPurchase, type EmperorNexusPurchase } from './nexus-emperor-purchase';
import { FREMEN_NEXUS_FREE_FORCES, fremenNexusRevivalOffer, fremenNexusRevivalPools, fremenNexusRevivalSignature, validateFremenNexusRevival, type FremenNexusRevival } from './nexus-fremen-revival';
import { captureFremenCunningOccurrence, quoteFremenCunning, quoteFremenCunningRide, validateFremenCunningSelection, type FremenCunningOccurrence, type FremenCunningRideAuthorization, type FremenCunningSelection } from './nexus-fremen-cunning';
import { truthKnowledgeOf } from './truthtrance-knowledge';
@@ -1056,6 +1057,8 @@ export type Game = {
nexusEcazInquiryLast?: { event: string };
nexusEmperorSecretHistory?: NexusEmperorRevival[];
nexusEmperorSecretEvents?: string[];
+ nexusEmperorPurchaseHistory?: EmperorNexusPurchase[];
+ nexusEmperorPurchaseEvents?: string[];
nexusFremenRevivalHistory?: FremenNexusRevival[];
nexusFremenRevivalEvents?: string[];
treacheryDiscardSequence?: number;
@@ -2093,10 +2096,42 @@ function playNexusBgBetrayal(g: Game, p: Player, action: Action) {
log(g, `${p.name} spent Bene Gesserit Nexus Betrayal to prevent ${getPlayer(g, record.target).name}'s Voice in this battle. The battle and any later Prescience remain available.`,
{ faction: p.faction, name: 'Bene Gesserit Nexus Betrayal' });
}
+/** The public payment window must not depend on who privately holds Emperor Nexus. */
+function emperorNexusUniformAuctionPayment(g: Game): boolean {
+ return !!g.nexusCards?.cards && !byFaction(g,'emperor') &&
+ emperorNexusModeSupported(g) && !g.ecazTreachery && !g.sandtrout &&
+ !!g.auction && !g.currentAuctionSale;
+}
+
function nexusEmperorSecretAllyOffer(g:Game,owner:string) {
const p = g.players.find(p => p.id === owner);
if (!p || g.nexusCards?.cards?.hands[owner] !== 'emperor' || byFaction(g,'emperor')) return null;
- const event = emperorNexusEvent(g.turn,g.phase,owner,g.nexusEmperorSecretHistory?.length ?? 0);
+ const purchasing = g.status === 'playing' && g.phase === 3 &&
+ g.decision?.kind === 'auctionPayment' && g.decision.player === owner &&
+ g.auction?.bidder === owner && !g.currentAuctionSale;
+ const event = purchasing
+ ? emperorNexusPurchaseEvent(g.turn,owner,g.nexusEmperorPurchaseHistory?.length ?? 0,g.auction!.index,g.auction!.bid)
+ : emperorNexusEvent(g.turn,g.phase,owner,g.nexusEmperorSecretHistory?.length ?? 0);
+ let purchase: {blocked:string|null;price:number}|null = null;
+ if (purchasing) {
+ let purchaseBlock:string|null = null;
+ if (p.ally) purchaseBlock = 'Emperor Nexus Secret Ally requires an unallied buyer.';
+ else if (!emperorNexusModeSupported(g) || g.ecazTreachery || g.sandtrout)
+ purchaseBlock = 'Emperor Nexus purchase currently requires classic factions and Nexus alone.';
+ else if (g.response || g.truthtrance || g.phaseOpening || g.pendingKarama ||
+ g.pendingTreacheryDiscard || g.pendingNullentropy || g.pendingExchange ||
+ g.pendingRicheseGift || g.pendingRichesePurchaseIncome || g.pendingAmbassador ||
+ g.pendingCapture || g.battle || g.nexusTraitorPending || g.nexusCards?.phase?.stage === 'drawing')
+ purchaseBlock = 'Finish the current interaction before this auction payment.';
+ else {
+ try {
+ normalKaramaAuction(g,p,true);
+ quoteEmperorNexusPurchase(g.auction!.bid,p.spice,g.auction!.allyPayment ?? 0);
+ }
+ catch (error) { purchaseBlock = error instanceof Error ? error.message : 'The buyer cannot prove the full price.'; }
+ }
+ purchase = {blocked:purchaseBlock,price:g.auction!.bid};
+ }
let blocked:string|null = null;
if (p.ally) blocked = 'Emperor Nexus Secret Ally requires an unallied holder.';
else if (!emperorNexusModeSupported(g)) blocked = 'Emperor Nexus Secret Ally currently requires base factions and Nexus alone; expansion and other module combinations remain pending.';
@@ -2110,24 +2145,42 @@ function nexusEmperorSecretAllyOffer(g:Game,owner:string) {
blocked = 'Emperor Nexus revival with free-revival suppression or expanded revival effects remains pending.';
const eliteOptions = emperorNexusRevivalElites(p,g.advanced);
if (!blocked && !eliteOptions.length) blocked = 'Emperor Nexus needs exactly three eligible forces in Tanks; a smaller return remains pending.';
- return {event,revival:{blocked,eliteOptions},purchase:null};
+ return {event,revival:{blocked,eliteOptions},purchase};
}
function nexusEmperorSecretIntegrity(g:Game) {
const history = g.nexusEmperorSecretHistory, events = g.nexusEmperorSecretEvents;
- if (history === undefined) {
- requireRule(events === undefined,'Emperor Nexus has lost its saved use history.'); return;
+ if (history === undefined)
+ requireRule(events === undefined,'Emperor Nexus has lost its saved revival history.');
+ else {
+ requireRule(g.nexusCards?.cards && emperorNexusModeSupported(g) && Array.isArray(history) && history.length > 0 &&
+ Array.isArray(events) && events.length === history.length && new Set(events).size === events.length,
+ 'Emperor Nexus has lost its physical module or independent use markers.');
+ for (const [index,record] of history.entries()) {
+ nexusRule(() => validateEmperorNexusRevival(g,record));
+ requireRule(record.sequence === index && events[index] === record.event,
+ 'Emperor Nexus has duplicated or reordered a completed revival.');
+ }
+ const last = history.at(-1)!;
+ if (last.turn === g.turn) requireRule(g.nexusCards.cards.discard.includes('emperor'),
+ 'Emperor Nexus has reopened its already spent physical card.');
}
- requireRule(g.nexusCards?.cards && emperorNexusModeSupported(g) && Array.isArray(history) && history.length > 0 &&
- Array.isArray(events) && events.length === history.length && new Set(events).size === events.length,
- 'Emperor Nexus has lost its physical module or independent use markers.');
- for (const [index,record] of history.entries()) {
- nexusRule(() => validateEmperorNexusRevival(g,record));
- requireRule(record.sequence === index && events[index] === record.event,
- 'Emperor Nexus has duplicated or reordered a completed use.');
+ const purchases = g.nexusEmperorPurchaseHistory, purchaseEvents = g.nexusEmperorPurchaseEvents;
+ if (purchases === undefined)
+ requireRule(purchaseEvents === undefined,'Emperor Nexus has lost its saved purchase history.');
+ else {
+ requireRule(g.nexusCards?.cards && emperorNexusModeSupported(g) && !g.ecazTreachery && !g.sandtrout &&
+ Array.isArray(purchases) && purchases.length > 0 && Array.isArray(purchaseEvents) &&
+ purchases.length === purchaseEvents.length && new Set(purchaseEvents).size === purchaseEvents.length,
+ 'Emperor Nexus purchase has lost its physical module or independent use markers.');
+ for (const [index,record] of purchases.entries()) {
+ nexusRule(() => validateEmperorNexusPurchase(g,record));
+ requireRule(record.sequence === index && purchaseEvents[index] === record.event,
+ 'Emperor Nexus purchase has duplicated or reordered a completed use.');
+ }
+ const last = purchases.at(-1)!;
+ if (last.turn === g.turn) requireRule(g.nexusCards.cards.discard.includes('emperor'),
+ 'Emperor Nexus purchase has reopened its already spent physical card.');
}
- const last = history.at(-1)!;
- if (last.turn === g.turn) requireRule(g.nexusCards.cards.discard.includes('emperor'),
- 'Emperor Nexus has reopened its already spent physical card.');
}
function playNexusEmperorRevive(g:Game,p:Player,action:Action) {
const offer = nexusEmperorSecretAllyOffer(g,p.id);
@@ -2147,6 +2200,31 @@ function playNexusEmperorRevive(g:Game,p:Player,action:Action) {
log(g,`${p.name} spent Emperor Nexus Secret Ally to return three forces${elite ? `, including ${elite} Fedaykin` : ''} from Tanks to reserves for free. Ordinary force and free-revival allowances are unchanged.`,
{faction:p.faction,name:'Emperor Nexus revival'});
}
+function playNexusEmperorPurchase(g:Game,p:Player,action:Action) {
+ const offer = nexusEmperorSecretAllyOffer(g,p.id), purchase = offer?.purchase;
+ requireRule(purchase && !purchase.blocked && action.event === offer.event &&
+ Object.keys(action).sort().join(',') === 'event,type',
+ purchase?.blocked ?? 'Choose your current Emperor Nexus bank-auction payment.');
+ const auction = g.auction!;
+ normalKaramaAuction(g,p,true);
+ nexusRule(() => quoteEmperorNexusPurchase(auction.bid,p.spice,auction.allyPayment ?? 0));
+ const purchased = auction.cards[auction.index], auctionIndex = auction.index;
+ requireRule(purchased && auction.bidder === p.id && g.decision?.kind === 'auctionPayment' &&
+ g.decision.player === p.id,'The original winning bank-auction lot is no longer available.');
+ const beforeSpice = p.spice;
+ g.decision = null;
+ g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players));
+ settleAuction(g,true,false,true);
+ const record:EmperorNexusPurchase = {kind:'purchase',event:offer.event,owner:p.id,turn:g.turn,
+ phase:3,sequence:g.nexusEmperorPurchaseHistory?.length ?? 0,faction:p.faction,
+ advanced:g.advanced,price:auction.bid,auctionIndex,card:purchased.id,beforeSpice,afterSpice:p.spice,signature:''};
+ record.signature = signEmperorNexusPurchase(record);
+ nexusRule(() => validateEmperorNexusPurchase(g,record));
+ (g.nexusEmperorPurchaseHistory ??= []).push(record);
+ (g.nexusEmperorPurchaseEvents ??= []).push(record.event);
+ log(g,`${p.name} spent Emperor Nexus Secret Ally after proving the ${record.price}-spice winning bid, bought the Treachery card and kept their own spice. The physical Nexus card was discarded.`,
+ {faction:p.faction,name:'Emperor Nexus purchase'});
+}
function nexusEcazBetrayalIntegrity(g: Game) {
const history = g.nexusEcazBetrayalHistory;
if (history === undefined) return;
@@ -8614,7 +8692,7 @@ function auctionNext(g: Game) {
a.active = next.player;
g.active = a.active;
}
-function settleAuction(g: Game, free = false, automatic = false) {
+function settleAuction(g: Game, free = false, automatic = false, emperorNexus = false) {
const a = g.auction!;
const winner = getPlayer(g, a.bidder!);
if (!free) payWithAlly(g, winner, a.bid, a.allyPayment ?? 0);
@@ -8629,7 +8707,7 @@ function settleAuction(g: Game, free = false, automatic = false) {
};
log(
g,
- `${faction(winner.faction).name} won a treachery card for ${free ? 'a Karama' : `${a.bid} spice`}.${automatic ? ' The declared spice payment was the only available payment method.' : ''}`,
+ `${faction(winner.faction).name} won a treachery card for ${emperorNexus ? `the Emperor Nexus card after proving ${a.bid} spice` : free ? 'a Karama' : `${a.bid} spice`}.${automatic ? ' The declared spice payment was the only available payment method.' : ''}`,
automatic
? { faction: winner.faction, name: 'Auction payment' }
: undefined,
@@ -9909,7 +9987,8 @@ function finishAutomaticDecision(g: Game): boolean {
// split or consume a card on behalf of a player, including a BG conversion.
if (
auctionSpicePaymentFunded(g, p) &&
- !p.hand.some((card) => !karamaSpendingBlock(g, p, card))
+ !p.hand.some((card) => !karamaSpendingBlock(g, p, card)) &&
+ !emperorNexusUniformAuctionPayment(g)
) {
g.decision = null;
settleAuction(g, false, true);
@@ -22328,6 +22407,7 @@ function applyActionInner(
if (t === 'nexusChoamBetrayal') { playNexusChoamBetrayal(g, p, action); return g; }
if (t === 'nexusEcazInquiry') { playNexusEcazInquiry(g, p, action); return g; }
if (t === 'nexusEmperorRevive') { playNexusEmperorRevive(g,p,action); return g; }
+ if (t === 'nexusEmperorPurchase') { playNexusEmperorPurchase(g,p,action); return g; }
if (t === 'nexusEmperorBetrayal') { playNexusEmperorBetrayal(g,p,action); return g; }
if (t === 'nexusBgBetrayal') { playNexusBgBetrayal(g,p,action); return g; }
if (t === 'nexusFremenRevive') { playNexusFremenRevive(g,p,action); return g; }
diff --git a/game/nexus-emperor-purchase.ts b/game/nexus-emperor-purchase.ts
new file mode 100644
index 00000000..5a82de73
--- /dev/null
+++ b/game/nexus-emperor-purchase.ts
@@ -0,0 +1,139 @@
+import { FACTIONS, type FactionId } from './catalog';
+import { emperorNexusEvent } from './nexus-emperor-secret-ally';
+
+export type EmperorNexusPurchase = {
+ kind: 'purchase';
+ event: string;
+ owner: string;
+ turn: number;
+ phase: 3;
+ sequence: number;
+ faction: FactionId;
+ advanced: boolean;
+ price: number;
+ auctionIndex: number;
+ card: string;
+ beforeSpice: number;
+ afterSpice: number;
+ signature: string;
+};
+
+/** Bind an offer to its public auction position and final bid, never its hidden card face. */
+export function emperorNexusPurchaseEvent(
+ turn: number, owner: string, sequence: number, auctionIndex: number, price: number,
+): string {
+ return `${emperorNexusEvent(turn, 3, owner, sequence)}:${auctionIndex}:${price}`;
+}
+
+export class EmperorNexusPurchaseError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'EmperorNexusPurchaseError';
+ }
+}
+
+/** Only a personally affordable, bank-paid winning bid can be reimbursed. */
+export function quoteEmperorNexusPurchase(
+ price: number,
+ ownSpice: number,
+ allyPayment: number,
+): void {
+ if (
+ !Number.isSafeInteger(price) ||
+ price < 1 ||
+ !Number.isSafeInteger(ownSpice) ||
+ ownSpice < price ||
+ allyPayment !== 0
+ )
+ throw new EmperorNexusPurchaseError(
+ 'Emperor Nexus purchase requires a positive whole bid paid entirely from the winner\'s own spice.',
+ );
+}
+
+/** Consistency signature, not cryptographic authentication; field order is fixed. */
+export function signEmperorNexusPurchase(record: EmperorNexusPurchase): string {
+ return JSON.stringify([
+ 'emperorNexusPurchase',
+ record.kind,
+ record.event,
+ record.owner,
+ record.turn,
+ record.phase,
+ record.sequence,
+ record.faction,
+ record.advanced,
+ record.price,
+ record.auctionIndex,
+ record.card,
+ record.beforeSpice,
+ record.afterSpice,
+ ]);
+}
+
+const whole = (value: unknown): value is number =>
+ Number.isSafeInteger(value) && (value as number) >= 0;
+const text = (value: unknown): value is string =>
+ typeof value === 'string' && value.trim().length > 0;
+const plain = (value: unknown): value is Record =>
+ value !== null &&
+ typeof value === 'object' &&
+ !Array.isArray(value) &&
+ [Object.prototype, null].includes(Object.getPrototypeOf(value));
+
+/** Historical purchase evidence survives subsequent auctions and card transfers. */
+export function validateEmperorNexusPurchase(
+ context: {
+ turn: number;
+ advanced: boolean;
+ players: readonly { id: string; faction: FactionId }[];
+ },
+ record: EmperorNexusPurchase,
+): void {
+ const validContext =
+ context &&
+ whole(context.turn) &&
+ context.turn > 0 &&
+ typeof context.advanced === 'boolean' &&
+ Array.isArray(context.players) &&
+ context.players.length > 0 &&
+ context.players.every(
+ (player) =>
+ player &&
+ text(player.id) &&
+ FACTIONS.some((faction) => faction.id === player.faction),
+ ) &&
+ new Set(context.players.map((player) => player.id)).size ===
+ context.players.length &&
+ new Set(context.players.map((player) => player.faction)).size ===
+ context.players.length;
+ if (
+ !validContext ||
+ !plain(record) ||
+ Object.keys(record).sort().join(',') !==
+ 'advanced,afterSpice,auctionIndex,beforeSpice,card,event,faction,kind,owner,phase,price,sequence,signature,turn' ||
+ 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) ||
+ context.players.find((player) => player.id === record.owner)?.faction !==
+ record.faction ||
+ record.advanced !== context.advanced ||
+ record.event !==
+ emperorNexusPurchaseEvent(record.turn, record.owner, record.sequence, record.auctionIndex, record.price) ||
+ !whole(record.auctionIndex) ||
+ !whole(record.beforeSpice) ||
+ !whole(record.afterSpice) ||
+ !whole(record.price) ||
+ record.price < 1 ||
+ record.beforeSpice < record.price ||
+ record.afterSpice !== record.beforeSpice ||
+ record.signature !== signEmperorNexusPurchase(record)
+ )
+ throw new EmperorNexusPurchaseError(
+ 'Emperor Nexus purchase has lost its original bidder, paid price or full reimbursement.',
+ );
+}
diff --git a/game/nexus-emperor-secret-ally-options.ts b/game/nexus-emperor-secret-ally-options.ts
index eb567d43..5cf4ff21 100644
--- a/game/nexus-emperor-secret-ally-options.ts
+++ b/game/nexus-emperor-secret-ally-options.ts
@@ -29,3 +29,38 @@ export function nexusEmperorRevivalAction(
return null;
return { type: 'nexusEmperorRevive', event: offer.event, elite };
}
+
+/** Spend the physical card only at the owner's completed, personally funded auction payment. */
+export function nexusEmperorPurchaseAction(game: GameView): Action | null {
+ const offer = game.nexusEmperorSecretAlly;
+ const purchase = offer?.purchase;
+ const owner = game.players.find((player) => player.id === game.me);
+ if (
+ !offer ||
+ !purchase ||
+ purchase.blocked ||
+ !owner ||
+ owner.ally ||
+ game.nexusCards?.card !== 'emperor' ||
+ game.players.some((player) => player.faction === 'emperor') ||
+ game.status !== 'playing' ||
+ game.phase !== 3 ||
+ game.decision?.kind !== 'auctionPayment' ||
+ game.decision.player !== game.me ||
+ game.response ||
+ game.truthtrance ||
+ game.phaseOpening ||
+ game.automaticContinuationPending ||
+ game.nexusCards.waiting.length ||
+ game.nexusTraitors?.pending ||
+ !Number.isSafeInteger(purchase.price) ||
+ purchase.price < 1 ||
+ !game.auction ||
+ game.auction.bidder !== game.me ||
+ game.auction.bid !== purchase.price ||
+ (game.auction.allyPayment ?? 0) !== 0 ||
+ (owner.spice ?? 0) < purchase.price
+ )
+ return null;
+ return { type: 'nexusEmperorPurchase', event: offer.event };
+}
diff --git a/game/reference.ts b/game/reference.ts
index c9e41608..1bb9f35e 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -3979,25 +3979,25 @@ export const RULE_TOPICS: RuleTopic[] = [
},
{
id: 'nexus-emperor-secret-ally',
- title: 'Emperor Nexus Secret Ally: extra revivals',
+ title: 'Emperor Nexus Secret Ally: extra revivals and bank-auction purchase',
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Prototyped',
- summary: 'When Emperor is absent, an unallied holder can spend its Nexus card during Revival to return exactly three additional forces free. The purchase alternative and combined-module acceptance remain unfinished.',
+ summary: 'When Emperor is absent, an unallied holder can spend its Nexus card on exactly three additional free force returns during Revival or on an ordinary bank-auction purchase after proving the full winning bid from personal spice. Seller-paid, allied, non-Bidding and combined-module acceptance remain unfinished.',
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.',
'The Advanced one-per-turn Fedaykin limit still applies across ordinary revivals and this card. The extra total allowance does not permit another elite revival. Fewer than three eligible counters is an unresolved case and remains unavailable.',
- 'This prototype supports Basic and Advanced base factions with Nexus alone. Other expansion, Homeworld, suppression and combined-module effects retain their separate boundaries. Keeping the card creates no public decision or extra confirmation.',
- 'The purchase alternative is not yet playable. It requires a choice when buying without revealing concealed card eligibility; its uniform winner-payment interaction awaits a product decision.',
+ 'The revival prototype supports Basic and Advanced base factions with Nexus alone. Other expansion, Homeworld, suppression and combined-module effects retain their separate boundaries. Keeping the card during Revival creates no public decision or extra confirmation.',
+ 'At an eligible normal bank-auction winner payment, prove the positive final bid entirely from your own spice, then spend Emperor Nexus to receive that physical Treachery Card without losing spice. Every winner sees the same payment confirmation even if they hold no Nexus card; this privacy-preserving timing remains a provisional product choice pending the existing user question. Ordinary spice or legal Karama payment is still available.',
],
related: ['nexus-cards', 'revival'],
checklist: [
- { area: 'Implementation', status: 'Partial', detail: 'Connected fixed three-force return with preserved ordinary allowances, physical elite limits and saved card-use receipts. Purchase and combined producers remain unfinished.', evidence: ['game/nexus-emperor-secret-ally.ts', 'game/engine.ts'] },
- { area: 'Player controls', status: 'Partial', detail: 'Private optional revival controls, legal elite selection, unavailable reasons and readable card reference.', evidence: ['components/nexus-emperor-secret-ally.tsx', 'components/nexus-cards.tsx'] },
- { area: 'AI', status: 'Partial', detail: 'All four profiles use the same projected legal return; no rival hand or spice is consulted. Complete Nexus strategy remains uncalibrated.', evidence: ['game/nexus-emperor-secret-ally-options.ts', 'game/bots.ts'] },
- { area: 'Documentation', status: 'Partial', detail: 'Printed grant, separate allowances, elite cap and purchase timing boundary are explicit.', evidence: ['docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md'] },
- { area: 'Verification', status: 'Partial', detail: 'Focused rules, controls, bots, JSON and SQLite restoration with stale, duplicate and concurrent actions. Broader module acceptance remains open.', evidence: ['tests/nexus-emperor-secret-ally.test.ts', 'tests/nexus-emperor-secret-ally-controls.test.tsx', 'tests/nexus-emperor-secret-ally-bots.test.ts', 'tests/nexus-emperor-secret-ally-recovery.test.ts'] },
+ { area: 'Implementation', status: 'Partial', detail: 'Fixed three-force return preserves ordinary allowances and physical elite limits. The separate bank-auction path retains the personally funded bid, transfers one physical lot and spends the Nexus with signed saved receipts. Seller/allied, other-purchase and combined producers remain unfinished.', evidence: ['game/nexus-emperor-secret-ally.ts', 'game/nexus-emperor-purchase.ts', 'game/engine.ts'] },
+ { area: 'Player controls', status: 'Partial', detail: 'Private revival/auction options, legal elite selection, bid proof and unavailable reasons with readable physical card reference; normal payment remains offered.', evidence: ['components/nexus-emperor-secret-ally.tsx', 'components/nexus-cards.tsx', 'components/game-table.tsx'] },
+ { area: 'AI', status: 'Partial', detail: 'All four profiles use projected legal revival and personally funded bank-purchase choices without rival hand/spice inspection. Complete Nexus strategy remains uncalibrated.', evidence: ['game/nexus-emperor-secret-ally-options.ts', 'game/bots.ts'] },
+ { area: 'Documentation', status: 'Partial', detail: 'Printed alternatives, revival allowances, bid proof, physical purchase and provisional uniform payment timing retain explicit unresolved boundaries.', evidence: ['docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md'] },
+ { area: 'Verification', status: 'Partial', detail: 'Focused Basic/Advanced rules, controls, bots, JSON, browser owner/rival projection and SQLite restart/CAS cover stale cross-lot offers, hand/custody rejection and duplicate requests; broader module acceptance remains 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'] },
],
},
{
@@ -4030,7 +4030,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Bene Gesserit Secret Ally borrowed Voice remains unavailable pending its cancellation and timing interpretation. Its printed permission does not make the other unfinished effects available.',
'Bene Gesserit Betrayal can prevent the native Voice while the ordinary pre-plan response is open. In Nexus games every seat responds even without a canceling card, so the window does not reveal the hidden holder. An unallied opponent spends one physical Bene Gesserit Nexus card to remove only that declared Voice; Prescience and plan commitments continue. Truthtrance has priority. This path does not grant borrowed Voice.',
'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. The separate Secret Ally revival has its own connected controls; Emperor purchase modes remain unfinished.',
+ '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. The separate Secret Ally revival and normal bank-auction purchase have connected controls; seller-paid and forced Emperor payments remain unfinished.',
'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.',
'With Fremen absent, an unallied holder may spend Fremen Secret Ally during Revival to return exactly three eligible forces from Tanks to reserves at no spice cost. Unlike Emperor Secret Ally, these three consume the ordinary force and free-force allowances, including the Advanced one-elite cap. This first path requires base factions and Nexus alone, before any normal force returns, without combined revival effects. Partial returns, prior returns, higher free rates and separate sandworm protection remain unavailable.',
'Native Fremen Cunning has a bounded Nexus-only path: when a natural, accepted additional, or Advanced special-Karama summoned worm appears in a territory initially containing no forces, Fremen may spend their own Nexus card before the worm resolves. The special summon retains its original Spice Blow parent. After the applicable Nexus, choose some forces from one occupied desert territory and ride to a legal Arrakis destination without using normal movement. The choice appears even without the secret card, so declining does not disclose custody. Karama provisionally prevents only the additional remote ride; the original worm still resolves and the card remains spent. An interrupted existing Cunning control is restored, and a selected ride waits for the summoned Nexus without granting a second Cunning offer; Great Maker and combined optional modules await integration.',
diff --git a/tests/fixture-nexus-cards.ts b/tests/fixture-nexus-cards.ts
index 8b171a93..b0218929 100644
--- a/tests/fixture-nexus-cards.ts
+++ b/tests/fixture-nexus-cards.ts
@@ -170,7 +170,7 @@ export function nexusInventory(g: Game): void {
[...NEXUS_FACTIONS].sort(),
);
assert.deepEqual(
- [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
+ [...g.deck, ...g.discard, ...(g.auction?.cards.slice(g.auction.index) ?? []), ...g.players.flatMap((p) => p.hand)]
.map((c) => c.id)
.sort(),
baseDeck()
diff --git a/tests/fixture-nexus-emperor-purchase.ts b/tests/fixture-nexus-emperor-purchase.ts
new file mode 100644
index 00000000..a2a6eb4a
--- /dev/null
+++ b/tests/fixture-nexus-emperor-purchase.ts
@@ -0,0 +1,42 @@
+import assert from 'node:assert/strict';
+import { applyAction, type Game } from '../game/engine';
+import { finishNexusSpice, nexusReady, nexusTurnTwo, orderNexusSpice } from './fixture-nexus-cards';
+
+/** Real Nexus draw, alliance formation, normal Bidding and sealed winner payment. */
+export function wonEmperorNexusBankAuction(advanced: boolean, ids: [string, string, string] = ['p', 'q', 'r']): Game {
+ const [fremen, owner, harkonnen] = ids;
+ let g = nexusTurnTwo({ advanced, seatIds: ids });
+ orderNexusSpice(g, ['worm', 'land', 'land']);
+ g = nexusReady(nexusReady(g));
+ g = applyAction(g, fremen, { type: 'alliance', target: harkonnen });
+ g = applyAction(g, harkonnen, { type: 'alliance', target: fremen });
+ g = finishNexusSpice(g);
+ assert.equal(g.nexusCards!.phase!.stage, 'drawing');
+ const cards = g.nexusCards!.cards!;
+ cards.deck = ['emperor', ...cards.deck.filter(card => card !== 'emperor')];
+ g = applyAction(g, owner, {
+ type: 'nexusCardChoice', turn: g.turn, card: null, choice: 'draw', ownRedraws: 0,
+ });
+ for (const id of g.nexusCards!.phase!.eligible)
+ if (!g.nexusCards!.phase!.done.includes(id))
+ g = applyAction(g, id, {
+ type: 'nexusCardChoice', turn: g.turn, card: null, choice: 'keep', ownRedraws: 0,
+ });
+ assert.equal(g.phase, 2);
+ g = nexusReady(g);
+ for (let i = 0; g.decision?.kind !== 'auctionPayment' && i < 30; i++) {
+ if (g.phaseOpening)
+ g = applyAction(g, g.players.find(p => !g.phaseOpening!.passed.includes(p.id))!.id, { type: 'ready' });
+ else if (g.auction?.active === owner && !g.auction.bid)
+ g = applyAction(g, owner, { type: 'bid', amount: 2 });
+ else if (g.auction)
+ g = applyAction(g, g.auction.active, { type: 'passBid' });
+ else g = nexusReady(g);
+ }
+ assert.equal(g.phase, 3);
+ assert.deepEqual(g.decision, { kind: 'auctionPayment', player: owner });
+ assert.equal(g.auction?.bidder, owner);
+ assert.equal(g.auction?.bid, 2);
+ assert.equal(g.auction?.allyPayment ?? 0, 0);
+ return g;
+}
diff --git a/tests/nexus-emperor-purchase-engine.test.ts b/tests/nexus-emperor-purchase-engine.test.ts
new file mode 100644
index 00000000..c5153ed2
--- /dev/null
+++ b/tests/nexus-emperor-purchase-engine.test.ts
@@ -0,0 +1,132 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
+import { nexusInventory, nexusReload } from './fixture-nexus-cards';
+import { wonEmperorNexusBankAuction } from './fixture-nexus-emperor-purchase';
+
+const owner = 'q';
+function reject(g: Game, player: string, action: Action, reason: RegExp) {
+ const before = JSON.stringify(g);
+ assert.throws(() => applyAction(g, player, action), reason);
+ assert.equal(JSON.stringify(g), before);
+}
+
+void test('real Basic/Advanced normal auction purchase keeps exactly the personally affordable bid and spends the physical Nexus card', () => {
+ for (const advanced of [false, true]) {
+ const g = wonEmperorNexusBankAuction(advanced);
+ const price = g.auction!.bid;
+ const lot = g.auction!.cards[g.auction!.index];
+ const before = JSON.stringify(g);
+ const ownerView = viewGame(g, owner);
+ assert.deepEqual(ownerView.nexusEmperorSecretAlly!.purchase, { blocked: null, price });
+ for (const id of ['p', 'r']) {
+ assert.equal(viewGame(g, id).nexusEmperorSecretAlly, null);
+ assert.equal(JSON.stringify(viewGame(g, id)).includes('nexusEmperorPurchaseHistory'), false);
+ }
+ const action = { type: 'nexusEmperorPurchase', event: ownerView.nexusEmperorSecretAlly!.event };
+ const done = applyAction(g, owner, action);
+ assert.equal(JSON.stringify(g), before);
+ nexusInventory(done);
+ assert.equal(done.players.find(p => p.id === owner)!.spice, g.players.find(p => p.id === owner)!.spice);
+ assert.equal(done.players.find(p => p.id === owner)!.hand.filter(card => card.id === lot.id).length, 1);
+ assert.equal(done.nexusCards!.cards!.hands[owner], null);
+ assert.equal(done.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
+ assert.deepEqual(done.nexusEmperorPurchaseEvents, [action.event]);
+ assert.equal(done.nexusEmperorPurchaseHistory?.[0].card, lot.id);
+ assert.equal(done.nexusEmperorPurchaseHistory?.[0].price, price);
+ assert.equal(done.nexusEmperorPurchaseHistory?.[0].afterSpice, done.nexusEmperorPurchaseHistory?.[0].beforeSpice);
+ assert.equal(done.log.filter(entry => entry.text.includes('spent Emperor Nexus Secret Ally after proving')).length, 1);
+ for (const p of done.players)
+ assert.deepEqual(viewGame(nexusReload(done), p.id), viewGame(done, p.id));
+ reject(done, owner, action, /Emperor Nexus|current|payment|card/i);
+ }
+});
+
+void test('every eligible winner confirms payment regardless of concealed Emperor card ownership', () => {
+ const withCard = wonEmperorNexusBankAuction(false);
+ const without = nexusReload(withCard);
+ without.nexusCards!.cards!.hands[owner] = null;
+ without.nexusCards!.cards!.deck.push('emperor');
+ const waitingWith = normalizeAutomaticGame(withCard);
+ const waitingWithout = normalizeAutomaticGame(without);
+ assert.deepEqual(waitingWith.decision, { kind: 'auctionPayment', player: owner });
+ assert.deepEqual(waitingWithout.decision, waitingWith.decision);
+ assert.equal(viewGame(waitingWithout, owner).nexusEmperorSecretAlly, null);
+ const paid = applyAction(waitingWithout, owner, { type: 'decision', karama: false });
+ assert.equal(paid.players.find(p => p.id === owner)!.spice,
+ without.players.find(p => p.id === owner)!.spice - without.auction!.bid);
+ assert.equal(paid.players.find(p => p.id === owner)!.hand.filter(
+ card => card.id === without.auction!.cards[without.auction!.index].id).length, 1);
+ nexusInventory(paid);
+});
+
+void test('a prior normal purchase cannot authorize spending Emperor Nexus on a later lot or revised bid', () => {
+ let g = wonEmperorNexusBankAuction(false);
+ const first = { type: 'nexusEmperorPurchase', event: viewGame(g, owner).nexusEmperorSecretAlly!.event };
+ const revised = nexusReload(g);
+ revised.auction!.bid++;
+ assert.notEqual(viewGame(revised, owner).nexusEmperorSecretAlly!.event, first.event);
+ reject(revised, owner, first, /current Emperor Nexus/i);
+ g = applyAction(g, owner, { type: 'decision', karama: false });
+ for (let i = 0; g.decision?.kind !== 'auctionPayment' && i < 20; i++) {
+ if (g.auction?.active === owner && !g.auction.bid)
+ g = applyAction(g, owner, { type: 'bid', amount: 2 });
+ else if (g.auction)
+ g = applyAction(g, g.auction.active, { type: 'passBid' });
+ }
+ assert.equal(g.auction?.index, 1);
+ assert.equal(g.decision?.player, owner);
+ assert.notEqual(viewGame(g, owner).nexusEmperorSecretAlly!.event, first.event);
+ reject(g, owner, first, /current Emperor Nexus/i);
+ const second = { type: 'nexusEmperorPurchase', event: viewGame(g, owner).nexusEmperorSecretAlly!.event };
+ const done = applyAction(g, owner, second);
+ assert.equal(done.nexusEmperorPurchaseHistory?.[0].auctionIndex, 1);
+});
+
+void test('invalid normal-lot custody and full winning hand cannot spend the physical Nexus card', () => {
+ const g = wonEmperorNexusBankAuction(false);
+ const action = { type: 'nexusEmperorPurchase', event: viewGame(g, owner).nexusEmperorSecretAlly!.event };
+ const duplicate = nexusReload(g);
+ duplicate.deck.push(structuredClone(g.auction!.cards[g.auction!.index]));
+ assert.match(viewGame(duplicate, owner).nexusEmperorSecretAlly!.purchase!.blocked!, /conflicting physical custody/);
+ reject(duplicate, owner, action, /conflicting physical custody/);
+ const full = nexusReload(g);
+ full.players.find(p => p.id === owner)!.hand.push(...full.deck.splice(0, 4));
+ assert.match(viewGame(full, owner).nexusEmperorSecretAlly!.purchase!.blocked!, /room for the committed/);
+ reject(full, owner, action, /room for the committed/);
+});
+
+void test('stale, foreign, malformed, underfunded and ally-funded purchase attempts reject before card or auction mutation', () => {
+ const g = wonEmperorNexusBankAuction(false);
+ const action = { type: 'nexusEmperorPurchase', event: viewGame(g, owner).nexusEmperorSecretAlly!.event };
+ reject(g, 'p', action, /Emperor Nexus|current|payment/i);
+ reject(g, owner, { ...action, event: 'stale' }, /current|payment/i);
+ reject(g, owner, { ...action, price: 0 }, /current|payment/i);
+ const underfunded = nexusReload(g);
+ underfunded.players.find(p => p.id === owner)!.spice = g.auction!.bid - 1;
+ assert.match(viewGame(underfunded, owner).nexusEmperorSecretAlly!.purchase!.blocked!, /own spice/);
+ reject(underfunded, owner, action, /own spice/);
+ const allyFunded = nexusReload(g);
+ allyFunded.auction!.allyPayment = 1;
+ assert.match(viewGame(allyFunded, owner).nexusEmperorSecretAlly!.purchase!.blocked!, /own spice/);
+ reject(allyFunded, owner, action, /own spice/);
+});
+
+void test('signed historical purchase rejects a changed price, lot, proof or missing independent-use marker after JSON restore', () => {
+ const g = wonEmperorNexusBankAuction(true);
+ const action = { type: 'nexusEmperorPurchase', event: viewGame(g, owner).nexusEmperorSecretAlly!.event };
+ const done = applyAction(g, owner, action);
+ for (const tamper of [
+ (copy: Game) => { copy.nexusEmperorPurchaseHistory![0].price++; },
+ (copy: Game) => { copy.nexusEmperorPurchaseHistory![0].card = 'forged-lot'; },
+ (copy: Game) => { copy.nexusEmperorPurchaseHistory![0].afterSpice--; },
+ (copy: Game) => { delete copy.nexusEmperorPurchaseEvents; },
+ (copy: Game) => { copy.nexusEmperorPurchaseEvents!.push(action.event); },
+ ]) {
+ const copy = nexusReload(done);
+ tamper(copy);
+ const before = JSON.stringify(copy);
+ assert.throws(() => viewGame(copy, owner), /Emperor Nexus purchase/);
+ assert.equal(JSON.stringify(copy), before);
+ }
+});
diff --git a/tests/nexus-emperor-purchase-quote.test.ts b/tests/nexus-emperor-purchase-quote.test.ts
new file mode 100644
index 00000000..b01279d2
--- /dev/null
+++ b/tests/nexus-emperor-purchase-quote.test.ts
@@ -0,0 +1,144 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ EmperorNexusPurchaseError,
+ quoteEmperorNexusPurchase,
+ emperorNexusPurchaseEvent,
+ signEmperorNexusPurchase,
+ validateEmperorNexusPurchase,
+ type EmperorNexusPurchase,
+} from '../game/nexus-emperor-purchase';
+import { emperorNexusEvent } from '../game/nexus-emperor-secret-ally';
+
+const context = {
+ turn: 4,
+ advanced: false,
+ players: [
+ { id: 'winner', faction: 'atreides' as const },
+ { id: 'rival', faction: 'guild' as const },
+ ],
+};
+
+function purchase(): EmperorNexusPurchase {
+ const receipt: EmperorNexusPurchase = {
+ kind: 'purchase',
+ event: emperorNexusPurchaseEvent(3, 'winner', 2, 1, 4),
+ owner: 'winner',
+ turn: 3,
+ phase: 3,
+ sequence: 2,
+ faction: 'atreides',
+ advanced: false,
+ price: 4,
+ auctionIndex: 1,
+ card: 'treachery-17',
+ beforeSpice: 7,
+ afterSpice: 7,
+ signature: '',
+ };
+ receipt.signature = signEmperorNexusPurchase(receipt);
+ return receipt;
+}
+
+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],
+ [5, 4, 0], [5, 4.5, 0], [5, -1, 0], [5, Infinity, 0],
+ [5, 10, 1], [5, 10, -1], [5, 10, NaN],
+ ]) {
+ assert.throws(
+ () => quoteEmperorNexusPurchase(price, ownSpice, allyPayment),
+ EmperorNexusPurchaseError,
+ `price=${price} own=${ownSpice} ally=${allyPayment}`,
+ );
+ }
+});
+
+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) =>
+ assert.throws(
+ () => validateEmperorNexusPurchase(context, { ...receipt, ...patch }),
+ EmperorNexusPurchaseError,
+ );
+ invalid({ kind: 'revival' as never });
+ invalid({ owner: 'rival' });
+ invalid({ faction: 'guild' });
+ invalid({ advanced: true });
+ invalid({ turn: 4 });
+ invalid({ phase: 4 as never });
+ invalid({ sequence: 3 });
+ invalid({ event: emperorNexusEvent(3, 4, 'winner', 2) });
+ invalid({ price: 5 });
+ invalid({ auctionIndex: 2 });
+ invalid({ card: 'treachery-18' });
+ invalid({ beforeSpice: 8 });
+ invalid({ afterSpice: 6 });
+ invalid({ signature: 'forged' });
+});
+
+void test('even re-signed invalid receipts reject future, nonauction, malformed, or non-reimbursed records', () => {
+ const receipt = purchase();
+ const invalid = (patch: Partial) => {
+ const changed = { ...receipt, ...patch };
+ changed.signature = signEmperorNexusPurchase(changed);
+ assert.throws(() => validateEmperorNexusPurchase(context, changed), EmperorNexusPurchaseError);
+ };
+ invalid({ turn: 5, event: emperorNexusEvent(5, 3, 'winner', 2) });
+ invalid({ turn: 0, event: emperorNexusEvent(0, 3, 'winner', 2) });
+ invalid({ phase: 4 as never, event: emperorNexusEvent(3, 4, 'winner', 2) });
+ invalid({ sequence: -1, event: emperorNexusEvent(3, 3, 'winner', -1) });
+ invalid({ sequence: Number.MAX_SAFE_INTEGER + 1, event: emperorNexusEvent(3, 3, 'winner', Number.MAX_SAFE_INTEGER + 1) });
+ invalid({ price: 0 });
+ invalid({ auctionIndex: -1 });
+ invalid({ price: 8 });
+ invalid({ price: 1.5 });
+ invalid({ beforeSpice: 7.5, afterSpice: 7.5 });
+ invalid({ afterSpice: 6 });
+ invalid({ afterSpice: 11 });
+ invalid({ card: ' ' });
+ invalid({ beforeSpice: Number.MAX_SAFE_INTEGER + 1, afterSpice: Number.MAX_SAFE_INTEGER + 1 });
+ invalid({ owner: ' ', event: emperorNexusEvent(3, 3, ' ', 2) });
+ invalid({ owner: 'stranger', event: emperorNexusEvent(3, 3, 'stranger', 2) });
+ invalid({ faction: 'guild' });
+ invalid({ advanced: true });
+ invalid({ extra: 'hidden' } as never);
+ invalid({ price: undefined } as never);
+ assert.throws(
+ () => validateEmperorNexusPurchase(context, null as never),
+ EmperorNexusPurchaseError,
+ );
+ assert.throws(
+ () => validateEmperorNexusPurchase({ ...context, turn: 2 }, receipt),
+ EmperorNexusPurchaseError,
+ );
+ assert.throws(
+ () => validateEmperorNexusPurchase({ ...context, players: context.players.slice(1) }, receipt),
+ EmperorNexusPurchaseError,
+ );
+ assert.throws(
+ () => validateEmperorNexusPurchase({ ...context, players: [context.players[0], context.players[0]] }, receipt),
+ EmperorNexusPurchaseError,
+ );
+});
diff --git a/tests/nexus-emperor-purchase-recovery.test.ts b/tests/nexus-emperor-purchase-recovery.test.ts
new file mode 100644
index 00000000..ccdd34fd
--- /dev/null
+++ b/tests/nexus-emperor-purchase-recovery.test.ts
@@ -0,0 +1,109 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { unitStore } from './fixture-nexus-room-store';
+import { viewGame, type Game } from '../game/engine';
+import type { RoomsClock } from '../db/rooms';
+import { wonEmperorNexusBankAuction } from './fixture-nexus-emperor-purchase';
+
+const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
+const rows = (store: ReturnType) =>
+ store.sqlite.prepare('SELECT state,version FROM rooms ORDER BY code').all();
+
+async function fixture(t: test.TestContext, advanced: boolean) {
+ const store = unitStore();
+ t.after(() => store.sqlite.close());
+ const made = await store.rooms.createRoom('Emperor purchase recovery', 'fremen', advanced, []);
+ const code = made.view.code;
+ const tokens = [made.token];
+ for (const faction of ['atreides', 'harkonnen'] as const)
+ tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
+ const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
+ const ids = auths.map(auth => auth.playerId) as [string, string, string];
+ const g = wonEmperorNexusBankAuction(advanced, ids);
+ g.code = code;
+ g.version = (await store.rooms.readRoom(code)).version;
+ store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
+ .run(JSON.stringify(g), g.version, code);
+ store.writes.length = 0;
+ return { ...store, code, tokens, auths, ids, g };
+}
+
+type Fixture = Awaited>;
+async function restored(f: Fixture, expected: Game) {
+ const before = rows(f);
+ for (const [i, token] of f.tokens.entries()) {
+ const rooms = f.restart();
+ const auth = await rooms.authenticate(f.code, token);
+ const view = await rooms.readSeatView(f.code, auth);
+ assert.deepEqual(view, viewGame(expected, f.ids[i]));
+ assert.equal('nexusEmperorPurchaseHistory' in view, false);
+ assert.equal('nexusEmperorPurchaseEvents' in view, false);
+ if (i !== 1) assert.equal(view.nexusEmperorSecretAlly, null);
+ for (const rival of view.players.filter(player => player.id !== f.ids[i])) {
+ assert.equal('hand' in rival, false);
+ assert.equal('spice' in rival, false);
+ }
+ }
+ await f.restart().continueRoomAutomatic(f.code, clock);
+ assert.deepEqual(rows(f), before);
+}
+
+void test('personally funded purchase survives SQLite restart without losing card or charging bid; stale replay does not commit', async t => {
+ for (const advanced of [false, true]) {
+ const f = await fixture(t, advanced);
+ await restored(f, f.g);
+ const initial = rows(f);
+ const seats = f.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all();
+ const offer = viewGame(f.g, f.ids[1]).nexusEmperorSecretAlly!;
+ const action = { type: 'nexusEmperorPurchase', event: offer.event };
+ for (const [i, malformed] of [[0, action], [1, { ...action, event: 'stale' }], [1, { ...action, price: 0 }]] as const)
+ await assert.rejects(f.restart().act(f.code, f.auths[i], f.g.version, malformed, clock));
+ assert.deepEqual(rows(f), initial);
+ const lot = f.g.auction!.cards[f.g.auction!.index].id;
+ await f.restart().act(f.code, f.auths[1], f.g.version, action, clock);
+ const done = await f.restart().readRoom(f.code);
+ assert.equal(done.version, f.g.version + 1);
+ assert.equal(done.players[1].spice, f.g.players[1].spice);
+ assert.equal(done.players[1].hand.filter(card => card.id === lot).length, 1);
+ assert.equal(done.nexusEmperorPurchaseHistory?.length, 1);
+ assert.deepEqual(done.nexusEmperorPurchaseEvents, [offer.event]);
+ assert.equal(done.nexusCards!.cards!.hands[f.ids[1]], null);
+ assert.equal(done.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
+ await restored(f, done);
+ const saved = rows(f);
+ await assert.rejects(f.restart().act(f.code, f.auths[1], f.g.version, action, clock), /table changed/);
+ await assert.rejects(f.restart().act(f.code, f.auths[1], done.version, action, clock));
+ assert.deepEqual(rows(f), saved);
+ assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats);
+ assert.deepEqual(f.writes.map(write => write.changes), [1]);
+ }
+});
+
+void test('racing Emperor purchase and ordinary auction payment preserve one winner and one physical lot', async t => {
+ const f = await fixture(t, true);
+ let arrivals = 0;
+ let release!: () => void;
+ const gate = new Promise(resolve => { release = resolve; });
+ f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; };
+ const timer = setTimeout(release, 2000);
+ const event = viewGame(f.g, f.ids[1]).nexusEmperorSecretAlly!.event;
+ let outcomes: PromiseSettledResult[];
+ try {
+ outcomes = await Promise.allSettled([
+ f.restart().act(f.code, f.auths[1], f.g.version, { type: 'nexusEmperorPurchase', event }, clock),
+ f.restart().act(f.code, f.auths[1], f.g.version, { type: 'decision', karama: false }, clock),
+ ]);
+ } finally {
+ clearTimeout(timer);
+ delete f.hooks.beforeWrite;
+ }
+ assert.equal(arrivals, 2);
+ assert.equal(outcomes.filter(result => result.status === 'fulfilled').length, 1);
+ const done = await f.restart().readRoom(f.code);
+ const lot = f.g.auction!.cards[f.g.auction!.index].id;
+ assert.equal(done.version, f.g.version + 1);
+ assert.equal(done.players[1].hand.filter(card => card.id === lot).length, 1);
+ assert.equal(done.nexusEmperorPurchaseHistory?.length ?? 0, done.players[1].spice === f.g.players[1].spice ? 1 : 0);
+ assert.deepEqual(f.writes.map(write => write.changes).sort((a, b) => a - b), [0, 1]);
+ await restored(f, done);
+});
diff --git a/tests/nexus-emperor-secret-ally-purchase-controls.test.ts b/tests/nexus-emperor-secret-ally-purchase-controls.test.ts
new file mode 100644
index 00000000..cc2884a0
--- /dev/null
+++ b/tests/nexus-emperor-secret-ally-purchase-controls.test.ts
@@ -0,0 +1,71 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { NexusEmperorSecretAlly } from '../components/nexus-emperor-secret-ally';
+import { botActions } from '../game/bots';
+import { applyAction, viewGame, type GameView } from '../game/engine';
+import { nexusEmperorPurchaseAction } from '../game/nexus-emperor-secret-ally-options';
+import { wonEmperorNexusBankAuction } from './fixture-nexus-emperor-purchase';
+
+const owner = 'q';
+const html = (view: GameView, busy = false) =>
+ renderToStaticMarkup(createElement(NexusEmperorSecretAlly, { game: view, act() {}, busy }));
+
+for (const advanced of [false, true])
+ void test(`${advanced ? 'Advanced' : 'Basic'} owner sees a private bank-paid choice and every bot profile can keep its own funded bid`, () => {
+ const g = wonEmperorNexusBankAuction(advanced);
+ const v = viewGame(g, owner);
+ assert.equal(v.nexusEmperorSecretAlly?.purchase?.blocked, null);
+ assert.equal(v.nexusEmperorSecretAlly?.purchase?.price, 2);
+ assert.deepEqual(nexusEmperorPurchaseAction(v), {
+ type: 'nexusEmperorPurchase', event: v.nexusEmperorSecretAlly!.event,
+ });
+ const text = html(v);
+ assert.match(text, /final bid is 2 spice/);
+ assert.match(text, /all 2 spice.*own supply without ally aid/);
+ assert.match(text, /keep that spice instead of paying the bank/);
+ assert.doesNotMatch(text, /disabled=""/);
+ assert.match(html(v, true), /disabled=""/);
+ assert.equal(text.includes(g.auction!.cards[g.auction!.index].name), false);
+ for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
+ const ownView = viewGame(g, owner);
+ ownView.players.find((player) => player.id === owner)!.bot = difficulty;
+ const [action] = botActions(ownView);
+ assert.deepEqual(action, nexusEmperorPurchaseAction(ownView));
+ const after = applyAction(g, owner, action);
+ assert.equal(after.players.find((player) => player.id === owner)!.spice, g.players.find((player) => player.id === owner)!.spice);
+ assert.ok(after.players.find((player) => player.id === owner)!.hand.some((card) => card.id === g.auction!.cards[g.auction!.index].id));
+ assert.equal(after.nexusCards!.cards!.hands[owner], null);
+ }
+ });
+
+void test('blocked, underfunded, interrupted and foreign owners have no purchase control or bot candidate', () => {
+ const g = wonEmperorNexusBankAuction(false);
+ for (const id of ['p', 'r']) {
+ const v = viewGame(g, id);
+ assert.equal(v.nexusEmperorSecretAlly, null);
+ assert.equal(nexusEmperorPurchaseAction(v), null);
+ assert.equal(html(v), '');
+ assert.ok(!botActions(v).some((action) => action.type === 'nexusEmperorPurchase'));
+ }
+ const owned = viewGame(g, owner);
+ const blocked = structuredClone(owned);
+ blocked.nexusEmperorSecretAlly!.purchase!.blocked = 'Finish this payment first.';
+ assert.equal(nexusEmperorPurchaseAction(blocked), null);
+ assert.match(html(blocked), /Finish this payment first/);
+ assert.match(html(blocked), /disabled=""/);
+ const underfunded = structuredClone(owned);
+ underfunded.players.find((player) => player.id === owner)!.spice = 1;
+ assert.equal(nexusEmperorPurchaseAction(underfunded), null);
+ assert.match(html(underfunded), /disabled=""/);
+ assert.match(html(underfunded), /need 2 spice in your own supply/);
+ const interrupted = structuredClone(owned);
+ interrupted.automaticContinuationPending = true;
+ assert.equal(nexusEmperorPurchaseAction(interrupted), null);
+ assert.match(html(interrupted), /disabled=""/);
+ const foreignDecision = structuredClone(owned);
+ foreignDecision.decision = { kind: 'auctionPayment', player: 'r' };
+ assert.equal(nexusEmperorPurchaseAction(foreignDecision), null);
+ assert.equal(html(foreignDecision), '');
+});