From 22948ff6e47235d4048abdd8429e615f7a796716 Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Thu, 15 Oct 2026 08:17:32 +1100
Subject: [PATCH] Route Emperor Nexus extra revival through native pending
settlement
---
components/game-table.tsx | 7 +
components/nexus-emperor-secret-ally.tsx | 6 +-
docs/CURRENT_STATUS.md | 17 +
docs/IMPLEMENTATION_STATUS.md | 43 ++
docs/NEXUS_CARD_RULES.md | 14 +
docs/NEXUS_EMPEROR_RULES.md | 65 ++-
docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md | 39 +-
docs/RULE_DECISIONS.md | 14 +
game/engine.ts | 72 +--
game/homeworld-revival-deployment.ts | 7 +-
game/homeworld-revival-return.ts | 6 +-
game/nexus-emperor-secret-ally.ts | 281 ++++++++--
game/reference.ts | 9 +-
game/revival-cancellation.ts | 20 +-
game/revival-resume.ts | 122 ++++-
game/revival.ts | 2 +
...ssic-nexus-skills-payments-runtime.test.ts | 1 +
tests/discovery-classic-nexus-effects.test.ts | 19 +-
...overy-classic-nexus-skills-runtime.test.ts | 1 +
tests/fixture-nexus-emperor-extra.ts | 206 +++++++
tests/fixture-single-nexus-e1.ts | 5 +-
.../nexus-emperor-extra-continuation.test.ts | 502 ++++++++++++++++++
tests/nexus-emperor-extra-runtime.test.ts | 298 +++++++++++
tests/nexus-emperor-extra-source.test.ts | 304 +++++++++++
...exus-emperor-secret-ally-controls.test.tsx | 66 ---
tests/nexus-emperor-secret-ally.test.ts | 42 +-
...us-skills-modules-payments-runtime.test.ts | 1 +
27 files changed, 1901 insertions(+), 268 deletions(-)
create mode 100644 tests/fixture-nexus-emperor-extra.ts
create mode 100644 tests/nexus-emperor-extra-continuation.test.ts
create mode 100644 tests/nexus-emperor-extra-runtime.test.ts
create mode 100644 tests/nexus-emperor-extra-source.test.ts
delete mode 100644 tests/nexus-emperor-secret-ally-controls.test.tsx
diff --git a/components/game-table.tsx b/components/game-table.tsx
index ba17ad3a..8d4cf773 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -3022,6 +3022,13 @@ export function GameTable({
to prevent their normal force and leader revivals for this
turn.
+ {g.decision.source === 'nexusEmperor' && (
+
+ Provisional policy: this pending Emperor Nexus extra return also
+ requires your clearance. Prevention returns no counters or revival
+ income; both the Nexus and your special Karama remain spent.
+
+ )}
{g.revival.specialKaramaBlock && (
{g.revival.specialKaramaBlock}
diff --git a/components/nexus-emperor-secret-ally.tsx b/components/nexus-emperor-secret-ally.tsx
index 0086a047..6542f91f 100644
--- a/components/nexus-emperor-secret-ally.tsx
+++ b/components/nexus-emperor-secret-ally.tsx
@@ -88,10 +88,12 @@ export function NexusEmperorSecretAlly({
Emperor Secret Ally
- Spend the Emperor Nexus card to revive 3 additional forces for free,
- beyond your ordinary revival quota. The elite cap still applies.
+ Spend the Emperor Nexus card to request exactly 3 additional forces for free
+ through the normal revival continuation, beyond ordinary force/free allowances.
+ Native typed caps and prevention still apply; Recruits does not double this grant.
{offer.revival.blocked && {offer.revival.blocked}
}
+ {offer.revival.policy && {offer.revival.policy}
}
{options.length > 1 && (
Elite forces to revive
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 7fa366ca..3e6ca213 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 extra-return continuation — 14 October.**
+Exactly three now enters native pending revival after one Nexus spend.
+Requested/returned/denied receipts track real settlement; ordinary force/free
+and native allied-extra usage stay unchanged. Native Tleilaxu prevention/
+once-only income, actual typed reserves, Axlotl and Homeworld handling continue
+under explicit provisional independent-source policy. Recruits/ordinary free
+blocking do not reprice this fixed grant; no leader/provider/refund is invented.
+Native Ix typed counts and newly returned Fedaykin placement remain physical.
+Affected16-file union266/266, types/lint/build pass. Fourteen real API profiles
+continue to Movement, including Basic/Advanced native deposits and Fedaykin.
+Actual390px Allow→ordinary3, Prevent and Fedaykin placement prove unchanged
+ordinary usage, actual income/Tech once, denied no-return/no-refund, and one
+star to Sietch Tabr14. No overflow/browser errors; no saved/deployed room touched.
+[Canonical policy and proof](NEXUS_EMPEROR_RULES.md#recommended-independent-extra-return--14-october-2026).
+Owned QA host/tab/scaffolding is removed; proof receipts remain outside Git.
+Existing build route-classification/chunk-size warnings remain.
+
**Emperor Nexus compulsory original purchase — 14 October.** The conservative
provisional full-price policy now connects normal-auction Betrayal before
winner payment, with neutral public held-Nexus storm-order acknowledgements.
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 98b89d98..e656cd4f 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,48 @@
# Dune implementation status
+## 14 October 2026 — Emperor Nexus original extra-return continuation
+
+Replaced direct physical transfer with a fixed-three source entering original
+pending revival after actual Nexus disposal. Version-two requested/returned/
+denied receipts retain strict event/index/signature and actual typed delta;
+old completed records remain evidence without replay. Ordinary total/free and
+native Emperor allied-extra usage stay unchanged. Independent-source policy
+keeps native Tleilaxu prevention/income, ignores ordinary free-rate repricing/
+Recruits doubling, and grants no leader/missing provider/refund. Denial keeps
+both spent cards without physical return, income or Tech. Native owner sees
+the provisional source on its original clearance control.
+
+Original typed reserve, once-only Axlotl and Homeworld paths now accept the
+source. Native Cyborg count caps survive; new Fedaykin can deploy through
+Southern Hemisphere. Tleilax ordinary Free Revival placement is not fabricated
+for an independently zero-cost grant. Signed snapshots settle before later
+native income/placement; later pools/wallets need not equal old snapshots.
+
+Genuine setup, real paid shipments/battles, worm/pact/closing draw and Revival
+produce classic, Tleilaxu, Ix and Fedaykin sources. Wrong Card.kind Karama
+selection was repaired with a conserved original undealt effect selector.
+Test-only constructor expansion admission, mismatched post-declaration ledger
+and orphaned source fixtures were repaired without changing legality. A false
+postbattle wallet pin ignored automatic Collection and was deleted. Native Ix
+requests an actually affordable follow-on, without minting spice. Legal Fremen
+reinforcement uses Sietch Tabr and native zero-support dial6/5; initial out-of-
+range and paid-support assumptions were corrected in the producer only.
+Obsolete guard, SSR wording, action-copy and decision/default pins are deleted,
+not re-pinned; current consumers require completed physical receipts.
+
+Affected16-file union266/266 and types/lint/build pass. Fourteen actual API
+profiles reach Movement, including native Homeworld deposits and Basic/Advanced
+Fedaykin source placement. Actual390px Allow returns3/free-income1/Axlotl1
+without ordinary usage; normal3 then costs2 and pays native income2 without
+repeating bank/Tech bonuses. Prevent spends both cards and commits denied with
+unchanged pools/wallets/Tech. Actual returned star places once at Sietch Tabr14:
+Tanks5→2/reserves4→7→6, ordinary/free0, spice3, native income1. No overflow or
+browser errors; no saved/deployed room is touched. Original publication,
+whole-mode, broader combinations and assurance gates remain.
+[Canonical source contract](NEXUS_EMPEROR_RULES.md#recommended-independent-extra-return--14-october-2026).
+Owned QA host/tab/scaffolding is removed; API/phone receipts remain outside Git.
+Existing build route-classification/chunk-size warnings remain.
+
## 14 October 2026 — Emperor Nexus original compulsory purchase
Connected original positive normal-auction allied obligation through a neutral
diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md
index e1d58d93..55250db1 100644
--- a/docs/NEXUS_CARD_RULES.md
+++ b/docs/NEXUS_CARD_RULES.md
@@ -12,6 +12,20 @@ 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 independent extra return — 14 October 2026
+
+Secret Ally exact-three now uses original pending revival, with requested/
+returned/denied source receipts instead of a quiet direct transfer. Native
+Tleilaxu Allow/Prevent/income, physical typed deposits, once-only Axlotl activity
+and original Homeworld handling continue. Ordinary total/free and native
+Emperor-extra ledgers remain unchanged; ordinary free blocking, larger rates
+and Recruits do not reprice/double this fixed source. Denial retains both spent
+cards with no forces/income/Tech or refund. Prevention/free-rate distinction
+is visibly provisional. Native Cyborg caps and newly returned Fedaykin placement
+remain their original rules; Tleilax's ordinary Free Revival deployment is
+not extended merely because this source costs zero.
+[Source and changed-path proof](NEXUS_EMPEROR_RULES.md#recommended-independent-extra-return--14-october-2026).
+
## Recommended Emperor compulsory funding — 14 October 2026
Normal-auction Betrayal now forces native Emperor to cover the original
diff --git a/docs/NEXUS_EMPEROR_RULES.md b/docs/NEXUS_EMPEROR_RULES.md
index 2e9bde9c..4463dc2f 100644
--- a/docs/NEXUS_EMPEROR_RULES.md
+++ b/docs/NEXUS_EMPEROR_RULES.md
@@ -75,19 +75,57 @@ The express additional/beyond-limits wording supplies a separate force allowance
The FAQ retains the one-per-turn Sardaukar/Fedaykin revival cap even through Ghola, Emperor alliance extras and Tleilaxu. Applying that distinction here preserves the special-type cap while extending total quantity. Emperor is absent, so an eligible native holder cannot actually be reviving Emperor Sardaukar. Free Cyborgs trigger no paid-Cyborg Ix bonus. Southern Hemisphere deployment still applies to eligible returned Fedaykin through the existing [deployment contract](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md). [November FAQ, p.2](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=2)
-E1 pays Tleilaxu for factions using free revival or Ghola; E2's La La La prevents Free Revival during Revival. Their texts do not expressly distinguish this new Nexus return from the ordinary free-revival event. Preserve the already-pending [income accounting boundary](TLEILAXU_AMBASSADOR_RULES.md) rather than minting another payment or assuming immunity. No source gives this card a special exception to Tleilaxu's revival prohibition. [E1, p.7](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=7), [E2, p.7](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=7)
+### Recommended independent extra return — 14 October 2026
-Fresh supported classic Nexus may additionally preserve original Tech and/or
-Advanced Stronghold Cards. This actual free batch invokes the original Axlotl
-hook, independently of its extra-versus-ordinary allowance distinction, and
-collects only at the native Revival phase end. Subsequent ordinary free return
-does not accrue twice. Strongholds add no revival bonus. The same supported
-profile preserves the existing bank-auction purchase and its original buyer
-wallet/card delivery; that purchase is not an industry trigger. Native Emperor
-absence, exact-three/elite caps and quiet source restrictions remain.
-See [scope and authority](NEXUS_CARD_RULES.md#classic-tech-and-stronghold-composition--4-october-2026);
-this does not admit native Tleilaxu income, Homeworlds, Skills or seller-paid
-and forced-Emperor producers, and leaves their pending questions unchanged.
+The fixed extra-three declaration now enters original pending revival instead
+of transferring counters directly. `nexusEmperorRevive` still selects exactly
+three actual normal/starred counters and spends one physical Nexus. A signed
+version-two source receipt is **requested** before any return, then **returned**
+only at native physical settlement, or **denied** by original Tleilaxu prevention.
+Strict old completed receipts remain historical evidence; no counters replay.
+
+The visible provisional composition retains native Tleilaxu prevention and
+once-only free-return income. Ordinary free-rate blocking, expanded limits,
+higher free rates and Recruits do not reprice/double this independent fixed
+grant. It consumes neither ordinary total/free ledgers nor native Emperor's
+allied-extra ledger. No missing-native-Emperor payer, extra Karama response,
+leader grant or refund is invented. Denial leaves Nexus and native Karama spent
+without forces, payment, revival income or Axlotl activity. This is source
+composition, not publisher clarification or a separately answered numerical ruling.
+
+Existing native Nexus roster/module predicates supply revival admission
+independently of unchanged bank-purchase guards. Original native Tleilaxu
+Allow/Prevent and income, typed reserve deposit, Axlotl activity and Homeworld
+return/deployment continue. Actual free Cyborgs retain uncapped native typed
+eligibility but do not gain the paid-Cyborg bonus. Newly returned Fedaykin use
+the original Southern Hemisphere placement. Tleilax still requires ordinary
+Free Revival for its own deployment: a zero-cost extra source is not relabelled
+normal. Current higher/low Homeworld pricing for later ordinary requests remains
+native; a deposit may change that later rate without repricing this grant.
+
+Genuine Basic/Advanced classic, native Tleilaxu and Ix histories use real setup,
+paid shipments, native battles, later worm/pact/closing draw and Revival.
+Fourteen actual API profiles include native Homeworld deposits and Basic/
+Advanced Fedaykin placement, all continuing to Movement. The Fremen source uses
+legal Sietch Tabr reinforcement and native zero-support plans; an initial
+Arrakeen/out-of-range and paid-support assumption was corrected in the fixture,
+never by relaxing the engine. Postbattle Collection is not falsely pinned as
+only battle expenditure. Native Ix follow-on chooses an affordable original
+return rather than minting spice.
+
+Actual390px Tleilaxu Allow returns three (Tanks8→5/reserves10→13),
+ordinary/free usage stays0, native free income1 and Axlotl1 accrue once.
+Original ordinary three then costs2, leaves Tanks2/reserves16/ordinary3/free2,
+and pays native Tleilaxu2 without repeating the bank/Tech bonus. Prevent spends
+both physical cards, keeps original pools/wallets/Tech and commits denied.
+The native owner's control explicitly labels the provisional extra source.
+Actual390px newly returned Fedaykin placement moves only that star to Sietch
+Tabr14, leaves normal/free ledgers0 and spice3, and resumes native income1.
+No horizontal overflow or browser errors. Full combinations, source review,
+private/recovery assurance and deployed acceptance remain open.
+The frozen16-file union passes266/266; types/lint/build pass with existing
+route-classification/chunk-size warnings. Proof receipts are outside Git.
+Owned QA host/tab/scaffolding is removed; proof receipts remain outside Git.
The separate fresh classic `leader-skills --nexus-cards` composition additionally
admits the original extra-three return and normal bank purchase with all14
@@ -98,7 +136,8 @@ selected trained Suk retains the original typed casualty receipt while rescue
is pending, completing only after physical settlement; its temporary Sardaukar
stay ordinary counters and original support is paid once. [Bounded scope and
evidence](NEXUS_CARD_RULES.md#classic-nexus-and-leader-skills-composition--4-october-2026)
-does not answer the separate native income, seller-paid or forced-payer rulings.
+remains separate from the connected independent return above and the original
+compulsory funding below. Seller-paid Secret Ally purchase remains unfinished.
## Betrayal and remaining decisions
diff --git a/docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md b/docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md
index 1212708a..c16abc58 100644
--- a/docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md
+++ b/docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md
@@ -1,8 +1,9 @@
# Emperor Nexus Secret Ally revival and bank-auction purchase
-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.
+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. Public starts and publication remain gated.
## Rules and connected behavior
@@ -15,22 +16,24 @@ action does not itself end that phase or invent a new card deadline.
[Printed cards](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani),
[source audit](NEXUS_EMPEROR_RULES.md#secret-ally-three-additional-free-revivals)
-The ordinary Advanced one-per-turn special-force revival cap remains in force.
-For this roster, that means Fedaykin. The holder chooses among legal normal/elite
-groups totaling exactly three; one printed counter cannot become two forces.
-Fewer than three eligible counters remains a recorded source gap, not an implicit
-up-to-three option. Recruits, Tleilaxu income/prevention, CHOAM suppression,
-Homeworld deployment and other combined-module producers remain outside this
-batch. A different player's ordinary Fremen ally grant does not block the holder.
-[November FAQ, p. 2](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=2)
+The Advanced one-per-turn Fedaykin cap remains; native Ix Cyborgs use their
+separate physical count cap. Select actual normal/elite groups totaling exactly
+three. Fewer than three remains unavailable, not an implicit up-to grant.
+Recruits changes the ordinary rate/limit, not this fixed extra count.
+[Independent-source interpretation and changed-path proof](NEXUS_EMPEROR_RULES.md#recommended-independent-extra-return--14-october-2026).
-The engine consumes the physical Nexus and moves the exact force group in one
-action. A private receipt records the original pools, mode and conserved delta;
-independent event markers reject missing, duplicated or reordered history.
-Historical pools are not compared with all later current pools: ordinary revivals,
-Ghola and later battles can legitimately change them. The current card and event
-must still authorize a fresh use. Saved views never expose that receipt or rival
-hands/spice. The private offer creates no table-wide decision or forced pass.
+The card is consumed at declaration, before original pending revival.
+Version-two receipts track requested (no forces yet), returned or denied.
+Native Tleilaxu Allow/Prevent, actual typed reserves, once-only free income,
+Axlotl activity and original Homeworld handling settle the same physical group.
+Prevention remains a visibly provisional composition; ordinary LaLaLa/free
+rates do not reprice this independent extra source. No ordinary force/free or
+native Emperor allied-extra usage grows, and no leader/payer/refund is invented.
+Denial retains both spent cards and returns no forces/income/Tech. Original
+Southern Hemisphere can place the actual returned Fedaykin; Tleilax's ordinary
+Free Revival deployment is not extended to an independently free producer.
+Strict old completed physical receipts stay readable without replay.
+Historical snapshots do not require later pools/wallets to remain unchanged.
All four AI profiles use the same projected legal choices. The UI supplies a
physical elite selector only when it offers a real choice, shows unavailability
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 74f56b68..1fbe4761 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -66,6 +66,20 @@ 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 independent extra return — 14 October 2026
+
+The conservative composition keeps exactly three additional physical forces
+outside ordinary total/free and native Emperor allied-extra usage, with no
+Recruits doubling or ordinary free-rate repricing. Native Tleilaxu prevention
+and once-only actual free-return income still apply. Declaration spends the
+Nexus; native denial spends its Karama and returns no forces/income/Tech,
+without refund. No absent-native-Emperor payment or extra Karama response
+is invented. Original typed deposits and eligible new Fedaykin deployment
+continue; Tleilax's ordinary Free Revival deployment is not relabelled.
+Visibly provisional source composition, not publisher clarification or a
+separately answered numerical user ruling.
+[Native source lifecycle and proof](NEXUS_EMPEROR_RULES.md#recommended-independent-extra-return--14-october-2026).
+
## Recommended Emperor compulsory funding — 14 October 2026
The content-first conservative prototype forces exactly the original full
diff --git a/game/engine.ts b/game/engine.ts
index 16b65def..7c3e0e1c 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -44,7 +44,7 @@ import { quoteNexusMoritaniRetention, nexusMoritaniRetentionSignature, type Nexu
import { quoteNexusChoamBetrayal, validateNexusChoamBetrayal, nexusChoamBetrayalSignature, type NexusChoamBetrayal } from './nexus-choam-betrayal';
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 { EMPEROR_NEXUS_REVIVALS, emperorNexusModeSupported, quoteEmperorNexusRevival, createEmperorNexusRevival, settleEmperorNexusRevival, validateEmperorNexusRevival, type NexusEmperorRevival } from './nexus-emperor-secret-ally';
import { emperorNexusPurchaseEvent, quoteEmperorNexusPurchase, signEmperorNexusPurchase, validateEmperorNexusPurchase, 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';
@@ -878,6 +878,7 @@ export type Decision =
player: string;
recipient: string;
revival: 'forces' | 'leader';
+ source?: 'nexusEmperor';
}
| {
kind: 'faceDance';
@@ -2731,12 +2732,13 @@ function emperorNexusUniformAuctionPayment(g: Game): boolean {
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 revival = quoteEmperorNexusRevival(g, owner);
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);
+ : revival.event;
let purchase: {blocked:string|null;price:number}|null = null;
if (purchasing) {
let purchaseBlock:string|null = null;
@@ -2757,31 +2759,25 @@ function nexusEmperorSecretAllyOffer(g:Game,owner:string) {
}
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 requires a supported classic Nexus profile; expansions and unrelated modules remain pending.';
- else if (g.status !== 'playing' || g.phase !== 4) blocked = 'Use the three-force Emperor Nexus return during Revival.';
- else if (g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingRevival || 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') blocked = 'Finish the current interaction before using Emperor Nexus revival.';
- else if ((g.revivalPrevention?.turn === g.turn && g.revivalPrevention.player === owner) || g.revivalRules?.freeBlocked?.includes(owner) ||
- g.revivalRules?.expanded.includes(owner) || g.freeRevival.includes(owner))
- 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};
+ return {event,revival,purchase};
}
function nexusEmperorSecretIntegrity(g:Game) {
const history = g.nexusEmperorSecretHistory, events = g.nexusEmperorSecretEvents;
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 &&
+ requireRule(g.nexusCards?.cards && 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));
+ if (!('version' in record))
+ requireRule(emperorNexusModeSupported(g),
+ 'A legacy Emperor return lost its original supported roster.');
+ else if (record.stage === 'requested')
+ requireRule(index === history.length - 1 && g.pendingRevival?.nexusEmperorEvent === record.event &&
+ g.pendingRevival.player === record.owner,
+ 'The extra-three request lost its original pending revival.');
requireRule(record.sequence === index && events[index] === record.event,
'Emperor Nexus has duplicated or reordered a completed revival.');
}
@@ -2812,19 +2808,27 @@ function playNexusEmperorRevive(g:Game,p:Player,action:Action) {
requireRule(offer && !offer.revival.blocked && action.event === offer.event &&
Object.keys(action).sort().join(',') === 'elite,event,type' && offer.revival.eliteOptions.includes(action.elite as number),
offer?.revival.blocked ?? 'Choose the current Emperor Nexus revival and an eligible exact three-force group.');
- const elite = action.elite as number, before = emperorNexusPools(p);
+ const record = nexusRule(() => createEmperorNexusRevival(g, p.id, action.elite as number));
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players));
- addNativeReserves(g,p,EMPEROR_NEXUS_REVIVALS,elite); p.tanks -= EMPEROR_NEXUS_REVIVALS;
- if (p.elites) {p.elites.tanks -= elite;p.elites.revived += elite;}
- techIncome(g, 'axlotl', p);
- const record:NexusEmperorRevival = {kind:'revival',event:offer.event,owner:p.id,turn:g.turn,phase:4,
- faction:p.faction,advanced:g.advanced,
- sequence:g.nexusEmperorSecretHistory?.length ?? 0,elite,before,after:emperorNexusPools(p),signature:''};
- record.signature = emperorNexusSignature(record);
(g.nexusEmperorSecretHistory ??= []).push(record);
(g.nexusEmperorSecretEvents ??= []).push(record.event);
- 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.`,
+ log(g, `${p.name} spent Emperor Nexus Secret Ally to request exactly three additional forces for free through the original revival continuation. Ordinary force/free allowances are unchanged; native Tleilaxu prevention applies under the provisional independent-extra-return policy.`,
{faction:p.faction,name:'Emperor Nexus revival'});
+ beginRevival(g, { player: p.id, kind: 'forces', amount: EMPEROR_NEXUS_REVIVALS,
+ elite: record.elite, free: EMPEROR_NEXUS_REVIVALS, normalCost: 0, cost: 0,
+ checks: [], nexusEmperorEvent: record.event });
+}
+function finishNexusEmperorRevival(g: Game, revival: PendingRevival, outcome: 'returned' | 'denied') {
+ if (!revival.nexusEmperorEvent) return;
+ const history = g.nexusEmperorSecretHistory;
+ const index = history?.findIndex(record => record.event === revival.nexusEmperorEvent) ?? -1;
+ const record = history?.[index];
+ requireRule(record && 'version' in record && record.version === 2 &&
+ record.stage === 'requested' && record.owner === revival.player,
+ 'The extra-three revival lost its original consumed Nexus source.');
+ history![index] = nexusRule(() => settleEmperorNexusRevival(g, record, outcome));
+ if (outcome === 'denied')
+ log(g, `${getPlayer(g, revival.player).name}'s Emperor Nexus extra return was prevented. The Nexus remains spent; no counters, payment, revival income or Tech activity was granted.`);
}
function playNexusEmperorPurchase(g:Game,p:Player,action:Action) {
const offer = nexusEmperorSecretAllyOffer(g,p.id), purchase = offer?.purchase;
@@ -24280,7 +24284,8 @@ function finishRevival(
return;
}
const deploymentGrant = revival.kind === 'forces'
- ? requireHomeworldRevivalGrant(g, p, revival.emperorExtra ? 'emperorExtra' : 'normal', {
+ ? requireHomeworldRevivalGrant(g, p, revival.nexusEmperorEvent ? 'nexusEmperor' :
+ revival.emperorExtra ? 'emperorExtra' : 'normal', {
amount: revival.amount!, elite: revival.elite ?? 0, free: revival.free,
})
: null;
@@ -24294,13 +24299,14 @@ function finishRevival(
if (revival.kind === 'forces') {
const n = revival.amount!,
elite = revival.elite ?? 0;
- const source = revival.emperorExtra ? 'emperorExtra' : 'normal';
+ const source = revival.nexusEmperorEvent ? 'nexusEmperor' :
+ revival.emperorExtra ? 'emperorExtra' : 'normal';
const group = { amount: n, elite, free: revival.free };
addNativeReserves(g, p, n, elite);
p.tanks -= n;
if (revival.emperorExtra)
g.emperorExtra[p.id] = (g.emperorExtra[p.id] ?? 0) + n;
- else {
+ else if (!revival.nexusEmperorEvent) {
// Preserve unknown legacy usage instead of guessing how earlier paid returns were priced.
if (p.freeForcesRevived !== undefined || p.revived === 0)
p.freeForcesRevived = (p.freeForcesRevived ?? 0) + revival.free;
@@ -24310,6 +24316,7 @@ function finishRevival(
p.elites.tanks -= elite;
p.elites.revived += elite;
}
+ finishNexusEmperorRevival(g, revival, 'returned');
if (quote.techIncome) {
const income = quote.techIncome;
const token = g.techTokens![income.id];
@@ -24375,7 +24382,8 @@ function beginRevival(g: Game, revival: PendingRevival) {
'Tleilaxu prevented this faction’s normal revivals for this turn.',
);
if (revival.kind === 'forces')
- requireHomeworldRevivalGrant(g, getPlayer(g, revival.player), revival.emperorExtra ? 'emperorExtra' : 'normal',
+ requireHomeworldRevivalGrant(g, getPlayer(g, revival.player),
+ revival.nexusEmperorEvent ? 'nexusEmperor' : revival.emperorExtra ? 'emperorExtra' : 'normal',
{ amount: revival.amount!, elite: revival.elite ?? 0, free: revival.free });
g.pendingRevival = revival;
const choam = byFaction(g, 'choam');
@@ -24383,7 +24391,7 @@ function beginRevival(g: Game, revival: PendingRevival) {
choam &&
choam.id !== revival.player &&
revival.free > 0 &&
- !revival.emperorExtra
+ !revival.emperorExtra && !revival.nexusEmperorEvent
) {
g.decision = {
kind: 'choamFreeRevival',
@@ -28322,6 +28330,8 @@ export function executeSpecialKaramaIntent(
discard(g, p, card.id);
p.specialKaramaUsed = true;
g.revivalPrevention = { player: target, turn: g.turn };
+ if (g.pendingRevival?.nexusEmperorEvent)
+ finishNexusEmperorRevival(g, g.pendingRevival, 'denied');
g.pendingRevival = null;
g.decision = null;
diff --git a/game/homeworld-revival-deployment.ts b/game/homeworld-revival-deployment.ts
index 69578880..c3a73a29 100644
--- a/game/homeworld-revival-deployment.ts
+++ b/game/homeworld-revival-deployment.ts
@@ -17,6 +17,7 @@ export type HomeworldRevivalDeploymentSource =
| 'normal'
| 'emperorExtra'
| 'ghola'
+ | 'nexusEmperor'
| 'nexusTleilaxu';
export type HomeworldRevivalDeploymentGroup = {
amount: number;
@@ -62,7 +63,7 @@ export function quoteHomeworldRevivalDeployment(
group: HomeworldRevivalDeploymentGroup,
): HomeworldRevivalDeploymentQuote | null {
if (
- !['normal', 'emperorExtra', 'ghola', 'nexusTleilaxu'].includes(source) ||
+ !['normal', 'emperorExtra', 'ghola', 'nexusTleilaxu', 'nexusEmperor'].includes(source) ||
!group ||
typeof group !== 'object' ||
Array.isArray(group) ||
@@ -74,7 +75,9 @@ export function quoteHomeworldRevivalDeployment(
group.elite > group.amount ||
!Number.isSafeInteger(group.free) ||
group.free < 0 ||
- group.free > group.amount
+ group.free > group.amount ||
+ (source === 'nexusEmperor' &&
+ (group.amount !== 3 || group.free !== 3))
)
throw new HomeworldCustodyError(
'Revival deployment needs a valid source and exact newly returned physical group.',
diff --git a/game/homeworld-revival-return.ts b/game/homeworld-revival-return.ts
index 175e590d..5a3adc61 100644
--- a/game/homeworld-revival-return.ts
+++ b/game/homeworld-revival-return.ts
@@ -136,13 +136,15 @@ function validateOriginal(frame: Original): void {
!text(frame.player) ||
!count(frame.turn, 1, Number.MAX_SAFE_INTEGER) ||
!count(frame.phase, 0, 8) ||
- !['normal', 'emperorExtra', 'ghola', 'nexusTleilaxu'].includes(frame.source) ||
+ !['normal', 'emperorExtra', 'ghola', 'nexusTleilaxu', 'nexusEmperor'].includes(frame.source) ||
(frame.source === 'ghola' ? !text(frame.card) : frame.card !== undefined) ||
!keys(frame.group, ['amount', 'elite', 'free']) ||
!count(frame.group.amount, 1) ||
!count(frame.group.elite, 0, frame.group.amount) ||
!count(frame.group.free, 0, frame.group.amount) ||
- (frame.source !== 'normal' && frame.group.free !== 0) ||
+ (frame.source === 'nexusEmperor'
+ ? frame.group.amount !== 3 || frame.group.free !== 3
+ : frame.source !== 'normal' && frame.group.free !== 0) ||
!keys(frame.quote, [
'kind',
'normal',
diff --git a/game/nexus-emperor-secret-ally.ts b/game/nexus-emperor-secret-ally.ts
index 78fd056a..95778576 100644
--- a/game/nexus-emperor-secret-ally.ts
+++ b/game/nexus-emperor-secret-ally.ts
@@ -1,7 +1,24 @@
import type { Game, Player } from './engine';
-import { eliteRevivalRemaining } from './revival';
-import { classicNexusModulesProfile } from './nexus-module-profile';
-import { classicNexusLeaderSkillsProfile } from './leader-skill-profile';
+import { eliteRevivalRemaining, revivalPrevented } from './revival';
+import { FACTIONS } from './catalog';
+import {
+ classicNexusModulesProfile,
+ pairedNexusModulesProfile,
+ singleE1E2NexusProfile,
+ mixedE1E2NexusProfile,
+ standaloneE3NexusProfile,
+ pairedE3NexusProfile,
+ mixedE3NexusProfile,
+} from './nexus-module-profile';
+import {
+ classicNexusLeaderSkillsProfile,
+ pairedNexusLeaderSkillsProfile,
+ singleE1E2NexusLeaderSkillsProfile,
+ mixedE1E2NexusLeaderSkillsProfile,
+ standaloneE3NexusLeaderSkillsProfile,
+ pairedE3NexusLeaderSkillsProfile,
+ mixedE3NexusLeaderSkillsProfile,
+} from './leader-skill-profile';
export const EMPEROR_NEXUS_REVIVALS = 3;
export type EmperorNexusPools = {
@@ -14,7 +31,7 @@ export type EmperorNexusPools = {
freeForcesRevived: number | null;
spice: number;
};
-export type NexusEmperorRevival = {
+type EmperorNexusRevivalIdentity = {
kind: 'revival';
event: string;
owner: string;
@@ -25,9 +42,23 @@ export type NexusEmperorRevival = {
advanced: boolean;
elite: number;
before: EmperorNexusPools;
- after: EmperorNexusPools;
signature: string;
};
+/** Old receipts are completed physical evidence, not permission to return again. */
+export type NexusEmperorRevival =
+ | (EmperorNexusRevivalIdentity & {
+ version?: never;
+ stage?: never;
+ after: EmperorNexusPools;
+ })
+ | (EmperorNexusRevivalIdentity & { version: 2 } & (
+ | { stage: 'requested'; after: null }
+ | { stage: 'returned' | 'denied'; after: EmperorNexusPools }
+ ));
+export type NexusEmperorRevivalRequest = Extract<
+ NexusEmperorRevival,
+ { version: 2; stage: 'requested' }
+>;
export function emperorNexusPools(p: Player): EmperorNexusPools {
return {
reserves: p.reserves,
@@ -91,60 +122,198 @@ export function emperorNexusRevivalElites(
choices.push(elite);
return choices;
}
+const poolKeys =
+ 'eliteReserves,eliteRevived,eliteTanks,freeForcesRevived,reserves,revived,spice,tanks';
+const receiptKeys =
+ 'advanced,after,before,elite,event,faction,kind,owner,phase,sequence,signature,turn';
+const receiptV2Keys =
+ 'advanced,after,before,elite,event,faction,kind,owner,phase,sequence,signature,stage,turn,version';
+const whole = (n: unknown): n is number =>
+ Number.isSafeInteger(n) && (n as number) >= 0;
+function shape(value: unknown, keys: string): boolean {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
+ const prototype: unknown = Object.getPrototypeOf(value);
+ return (prototype === Object.prototype || prototype === null) &&
+ Object.keys(value).sort().join(',') === keys;
+}
+function validPools(pool: EmperorNexusPools): boolean {
+ return shape(pool, poolKeys) &&
+ Object.entries(pool).every(([key, n]) =>
+ (key === 'freeForcesRevived' && n === null) || whole(n)) &&
+ pool.eliteReserves <= pool.reserves && pool.eliteTanks <= pool.tanks &&
+ pool.reserves + pool.tanks <= 20;
+}
+function samePools(a: EmperorNexusPools, b: EmperorNexusPools): boolean {
+ return Object.keys(b).every(key =>
+ a[key as keyof EmperorNexusPools] === b[key as keyof EmperorNexusPools]);
+}
+function returnedPools(r: NexusEmperorRevival): EmperorNexusPools {
+ return {
+ ...r.before,
+ reserves: r.before.reserves + EMPEROR_NEXUS_REVIVALS,
+ tanks: r.before.tanks - EMPEROR_NEXUS_REVIVALS,
+ eliteReserves: r.before.eliteReserves + r.elite,
+ eliteTanks: r.before.eliteTanks - r.elite,
+ eliteRevived: r.before.eliteRevived + r.elite,
+ };
+}
+type ReceiptContext = Pick;
+
+/** Validate historical evidence only; later ordinary returns and income are not replayed. */
export function validateEmperorNexusRevival(
- g: Game,
+ g: ReceiptContext,
r: NexusEmperorRevival,
): void {
- const p = r && g.players.find((p) => p.id === r.owner);
- const validPools = (pool: EmperorNexusPools) =>
- pool &&
- Object.keys(pool).sort().join(',') ===
- 'eliteReserves,eliteRevived,eliteTanks,freeForcesRevived,reserves,revived,spice,tanks' &&
- Object.entries(pool).every(
- ([key, n]) =>
- (key === 'freeForcesRevived' && n === null) ||
- (Number.isSafeInteger(n) && n! >= 0),
- ) &&
- pool.eliteReserves <= pool.reserves &&
- pool.eliteTanks <= pool.tanks &&
- pool.reserves + pool.tanks <= 20;
- if (
- !p ||
- Object.keys(r).sort().join(',') !==
- 'advanced,after,before,elite,event,faction,kind,owner,phase,sequence,signature,turn' ||
- r.kind !== 'revival' ||
- r.phase !== 4 ||
- !Number.isSafeInteger(r.turn) ||
- r.turn < 1 ||
- r.turn > g.turn ||
- r.faction !== p.faction ||
- r.advanced !== g.advanced ||
- !Number.isSafeInteger(r.sequence) ||
- r.sequence < 0 ||
- !Number.isSafeInteger(r.elite) ||
- r.elite < 0 ||
- r.elite > 3 ||
+ const p = r && g.players.find(p => p.id === r.owner);
+ const legacy = shape(r, receiptKeys);
+ if (!p || !(legacy || shape(r, receiptV2Keys)) ||
+ r.kind !== 'revival' || r.phase !== 4 ||
+ typeof r.owner !== 'string' || !r.owner.trim() ||
+ !whole(r.turn) || r.turn < 1 || r.turn > g.turn ||
+ r.faction !== p.faction || r.advanced !== g.advanced ||
+ !whole(r.sequence) || !whole(r.elite) || r.elite > EMPEROR_NEXUS_REVIVALS ||
r.event !== emperorNexusEvent(r.turn, 4, r.owner, r.sequence) ||
- r.signature !== emperorNexusSignature(r) ||
- !validPools(r.before) ||
- !validPools(r.after) ||
- r.before.tanks < 3 ||
+ r.signature !== emperorNexusSignature(r) || !validPools(r.before) ||
+ r.before.tanks < EMPEROR_NEXUS_REVIVALS ||
r.elite > r.before.eliteTanks ||
- 3 - r.elite > r.before.tanks - r.before.eliteTanks ||
- (g.advanced && r.before.eliteRevived + r.elite > 1) ||
- (p.faction !== 'fremen' &&
- (r.elite || r.before.eliteTanks || r.before.eliteReserves)) ||
- JSON.stringify(r.after) !==
- JSON.stringify({
- ...r.before,
- reserves: r.before.reserves + 3,
- tanks: r.before.tanks - 3,
- eliteReserves: r.before.eliteReserves + r.elite,
- eliteTanks: r.before.eliteTanks - r.elite,
- eliteRevived: r.before.eliteRevived + r.elite,
- })
- )
- throw new Error(
- 'Emperor Nexus revival has lost its exact original physical return or allowance.',
- );
+ EMPEROR_NEXUS_REVIVALS - r.elite > r.before.tanks - r.before.eliteTanks)
+ throw new Error('Emperor Nexus revival has lost its exact original physical return or allowance.');
+ if (legacy) {
+ if (g.expansions.length ||
+ g.players.some(seat => !['atreides', 'harkonnen', 'fremen', 'guild', 'beneGesserit'].includes(seat.faction)) ||
+ (g.advanced && r.before.eliteRevived + r.elite > 1) ||
+ (p.faction !== 'fremen' &&
+ (r.elite || r.before.eliteTanks || r.before.eliteReserves)) ||
+ !r.after || !validPools(r.after) ||
+ JSON.stringify(r.after) !== JSON.stringify(returnedPools(r)))
+ throw new Error('Emperor Nexus revival has lost its exact original physical return or allowance.');
+ return;
+ }
+ const typed = p.faction === 'ixians' || p.faction === 'fremen';
+ if (r.version !== 2 ||
+ g.players.some(seat => seat.faction === 'emperor') ||
+ (!typed && (r.elite || r.before.eliteTanks || r.before.eliteReserves || r.before.eliteRevived)) ||
+ (typed && r.before.eliteReserves + r.before.eliteTanks > (p.faction === 'ixians' ? 7 : 3)) ||
+ (g.advanced && p.faction !== 'ixians' && r.before.eliteRevived + r.elite > 1) ||
+ (r.before.freeForcesRevived !== null && r.before.freeForcesRevived > r.before.revived) ||
+ (r.stage === 'requested'
+ ? r.after !== null
+ : !['returned', 'denied'].includes(r.stage) || !r.after || !validPools(r.after) ||
+ !samePools(r.after, r.stage === 'returned' ? returnedPools(r) : r.before)))
+ throw new Error('Emperor Nexus revival has lost its requested, returned or denied source facts.');
+}
+
+/** Reuse original fresh roster/module predicates, independently of purchase admission. */
+function revivalModeSupported(g: Game): boolean {
+ if (g.semutaPreview || g.advancedPreview || g.kullPreview || g.nexusKullPreview ||
+ g.guildBetrayalPreview || g.richeseBetrayalPreview || g.nexusIxianReplacementPreview ||
+ g.nexusIxianBetrayalPreview || g.nexusHarkonnenBetrayalPreview || g.ecazTreachery)
+ return false;
+ if (g.leaderSkills)
+ return classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(g) ||
+ singleE1E2NexusLeaderSkillsProfile(g) || mixedE1E2NexusLeaderSkillsProfile(g) ||
+ standaloneE3NexusLeaderSkillsProfile(g) || pairedE3NexusLeaderSkillsProfile(g) ||
+ mixedE3NexusLeaderSkillsProfile(g);
+ if (classicNexusModulesProfile(g) || pairedNexusModulesProfile(g) ||
+ singleE1E2NexusProfile(g) || mixedE1E2NexusProfile(g) ||
+ standaloneE3NexusProfile(g) || pairedE3NexusProfile(g) || mixedE3NexusProfile(g))
+ return true;
+ // Bare classic and paired E1/E2 Nexus predate the optional-module profiles.
+ if (!g.nexusCards || g.techTokens || g.strongholdCards || g.discoveryEnabled ||
+ g.discoveries || g.discoveryStash || g.greatMaker ||
+ g.players.length < 2 || g.players.length > 6 ||
+ new Set(g.players.map(p => p.faction)).size !== g.players.length) return false;
+ if (!g.expansions.length)
+ return g.players.every(p => FACTIONS.some(f => f.id === p.faction && f.expansion === 'base'));
+ if (g.expansions.length !== 1 || !['ix', 'choam'].includes(g.expansions[0])) return false;
+ const expansion = g.expansions[0];
+ return g.players.every(p => FACTIONS.some(f => f.id === p.faction &&
+ (f.expansion === 'base' || f.expansion === expansion))) &&
+ g.players.filter(p => FACTIONS.some(f => f.id === p.faction && f.expansion === expansion)).length === 2;
+}
+
+/** Provisional composition, not a publisher clarification or numerical user ruling. */
+const revivalPolicy =
+ 'Provisional composed policy: exactly three additional free physical forces from Tanks, beyond ordinary force and free-revival allowances. Native typed caps and Tleilaxu prevention still apply; this is not ordinary free-rate revival, so ordinary free blocking (including LaLaLa) and higher or expanded rates do not reprice it. Recruits does not double this fixed grant. Spend the card on declaration; denial returns no forces and grants no revival income or Tech benefit. No leader, missing-native-Emperor payment, extra Karama response or refund is granted. Actual returns continue through native Tleilaxu, Tech and Homeworld handling.';
+
+export function quoteEmperorNexusRevival(g: Game, owner: string): {
+ event: string;
+ blocked: string | null;
+ eliteOptions: number[];
+ policy: string;
+} {
+ const p = g.players.find(p => p.id === owner);
+ const event = emperorNexusEvent(g.turn, g.phase, owner, g.nexusEmperorSecretHistory?.length ?? 0);
+ const eliteOptions = p ? emperorNexusRevivalElites(p, g.advanced) : [];
+ let blocked: string | null = null;
+ if (!p || g.nexusCards?.cards?.hands[owner] !== 'emperor')
+ blocked = 'Only the actual holder of the Emperor Nexus card may use this revival.';
+ else if (g.players.some(p => p.faction === 'emperor'))
+ blocked = 'Emperor Secret Ally requires the native Emperor absent from the roster.';
+ else if (p.ally) blocked = 'Emperor Nexus Secret Ally requires an unallied holder.';
+ else if (!revivalModeSupported(g))
+ blocked = 'Emperor Nexus revival requires an original supported Nexus roster and module profile.';
+ else if (g.status !== 'playing' || g.phase !== 4)
+ blocked = 'Use the three-force Emperor Nexus return during Revival.';
+ else if (g.response || g.decision || g.truthtrance || g.phaseOpening ||
+ g.pendingRevival || g.pendingKarama || g.pendingTreacheryDiscard ||
+ g.pendingNullentropy || g.pendingExchange || g.pendingRicheseGift ||
+ g.pendingRichesePurchaseIncome || g.pendingAmbassador || g.pendingCapture ||
+ g.battle || g.nexusTraitorPending || g.nexusTraitorParent ||
+ g.nexusCards?.phase?.stage === 'drawing' ||
+ (g.homeworldRevivalReturn && g.homeworldRevivalReturn.stage !== 'complete'))
+ blocked = 'Finish the current interaction before using Emperor Nexus revival.';
+ else if (revivalPrevented(g, owner))
+ blocked = 'Native Tleilaxu prevention blocks this revival.';
+ else if (!eliteOptions.length)
+ blocked = 'Emperor Nexus needs exactly three eligible forces in Tanks.';
+ return { event, blocked, eliteOptions, policy: revivalPolicy };
+}
+
+/** Pure declaration receipt: the engine commits card custody and the original pending revival. */
+export function createEmperorNexusRevival(
+ g: Game,
+ owner: string,
+ elite: number,
+): NexusEmperorRevivalRequest {
+ const quote = quoteEmperorNexusRevival(g, owner);
+ if (quote.blocked || !quote.eliteOptions.includes(elite))
+ throw new Error(quote.blocked ?? 'Choose an eligible exact three-force typed group.');
+ const p = g.players.find(p => p.id === owner)!;
+ const r: NexusEmperorRevivalRequest = {
+ kind: 'revival', version: 2, event: quote.event, owner, turn: g.turn, phase: 4,
+ sequence: g.nexusEmperorSecretHistory?.length ?? 0,
+ faction: p.faction, advanced: g.advanced, elite,
+ before: emperorNexusPools(p), after: null, stage: 'requested', signature: '',
+ };
+ r.signature = emperorNexusSignature(r);
+ validateEmperorNexusRevival(g, r);
+ return r;
+}
+
+/** Capture the actual physical finish before native income or Homeworld deployment. */
+export function settleEmperorNexusRevival(
+ g: Game,
+ receipt: NexusEmperorRevival,
+ outcome: 'returned' | 'denied',
+): Extract {
+ validateEmperorNexusRevival(g, receipt);
+ const saved = g.nexusEmperorSecretHistory?.[receipt.sequence];
+ const p = g.players.find(p => p.id === receipt.owner)!;
+ if (receipt.version !== 2 || receipt.stage !== 'requested' ||
+ !['returned', 'denied'].includes(outcome) ||
+ receipt.turn !== g.turn || g.phase !== 4 || g.status !== 'playing' || p.ally ||
+ !saved || saved.signature !== receipt.signature ||
+ emperorNexusSignature(saved) !== receipt.signature ||
+ g.nexusEmperorSecretEvents?.[receipt.sequence] !== receipt.event ||
+ g.nexusEmperorSecretHistory?.length !== receipt.sequence + 1 ||
+ g.nexusCards?.cards?.hands[receipt.owner] !== null ||
+ g.nexusCards.cards.discard.filter(card => card === 'emperor').length !== 1)
+ throw new Error('Emperor Nexus settlement needs the original committed requested source.');
+ const result = {
+ ...receipt, stage: outcome, after: emperorNexusPools(p), signature: '',
+ };
+ result.signature = emperorNexusSignature(result);
+ validateEmperorNexusRevival(g, result);
+ return result;
}
diff --git a/game/reference.ts b/game/reference.ts
index 7e1df10d..56c459d9 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -4569,6 +4569,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'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. Secret Ally revival and normal bank-auction purchase have connected controls; normal forced allied funding is the separate operation below. Seller-paid Secret Ally purchases remain unfinished.',
+ '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.',
'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.',
@@ -4598,11 +4599,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 physical effects and Fremen fixed ordinary quota remain. Emperor Betrayal now binds original positive normal-auction allied obligation, neutral possible-holder acknowledgements, exact original lot/price/alliance/escrow/wallet and one physical card. Provisional full-price funding reuses prepaid aid, adds only missing own spice and calls native acquisition/income/next-lot continuation. Truthtrance discard restoration is retained; no refund, new bid, excessive gift or bank subsidy. Special seller composition, wider combinations and custody 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']},
- {area:'Player controls',status:'Partial',detail:'Emperor payment uses neutral original-event Continue and a current-holder-only Use with visible full-price interpretation and blocking reason. Actual390px neutral/pass/use followed by original income Allow or printed Karama cancellation preserves native acquisition and next-lot inspection. Existing Fremen normal partial controls, Voice/protection/Cunning and movement guidance remain. Wider controls and complete-mode acceptance stay 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']},
- {area:'AI',status:'Partial',detail:'Existing minimal legal profiles retain original responses and continuation. Fremen holders enable ordinary quota before native revival selection. Emperor compulsory-funding responders pass from the public event unless their current private offer permits Use. Genuine Easy neutral/pass/use and original paid acquisition are exercised; 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']},
+ { area: 'Implementation', status: 'Partial', detail: 'Existing effects and compulsory funding remain. Emperor Secret Ally now declares a signed exact-three independent source into original pending revival, with requested/returned/denied physical receipts and no ordinary or native allied-extra usage. Native Tleilaxu prevention/income, typed reserves, once-only Tech and Homeworld handling continue. Cyborg caps and returned Fedaykin placement stay native; zero-cost extras are not Tleilax ordinary Free Revival. Legacy physical evidence never replays. Wider combinations, custody and seller purchases 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'] },
+ { area: 'Player controls', status: 'Partial', detail: 'Extra-three retains its actual typed selector and labels the provisional independent-source policy. Original native Tleilaxu clearance shows the committed extra source and no-refund consequence. Actual390px Allow then ordinary3, native Prevent and one returned Fedaykin placement use production controls; receipt lifecycle follows actual settlement. Existing compulsory funding and other Nexus controls remain. Wider controls and complete-mode acceptance stay 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'] },
+ { area: 'AI', status: 'Partial', detail: 'Existing minimal profiles retain shared legal grant actions and native responses. Extra-three now enters the original pending pipeline instead of directly moving forces, with actual current typed quote/caps. Genuine Easy classic activation commits the independent physical return; compulsory funding and Fremen ordinary quota remain legal. No strategic tuning or four-tier calibration is claimed; both 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:'Common rules and twelve faces retain remaining custody, inquiry, nested Cunning and combinations. Contracts distinguish named borrowed Voice cancellation, independent worm protection, fresh/storm BG card exception, Fremen ordinary fixed quota and Emperor original full-price funding. Interpretations are visibly provisional, not publisher answers or separately answered numerical rulings. Special seller composition and full-mode gates remain.',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']},
- {area:'Verification',status:'Partial',detail:'Compulsory-funding affected10-file union155/155 passes. Genuine Basic/Advanced setup, real worm/pact/closing draw and Guild price2 auction exercise prepaid0/1, allpass0/1/2, original income allow/cancel, Truthtrance restore and immutable event/actor/key refusals. Four API profiles and two real390px allow/cancel surfaces retain buyer6, reuse prepaid1/add missing1, acquire one card/spend one Nexus and restore lot1 inspection. Native Emperor10 returns on allowance or stays8 after cancellation; no refund/overflow/browser errors. Types/lint/build pass. Prior quota329/329 and BG Cunning65/65 evidence remains; special seller, 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']},
+ { area: 'Verification', status: 'Partial', detail: 'Extra-return affected16-file union266/266 and types/lint/build pass. Genuine classic/native-Tleilaxu/Ix/Fedaykin setup, casualties, worm/pact/deal and Revival exercise original Allow/Prevent/income, later ordinary returns, typed caps and actual Homeworld placement. Fourteen API profiles reach Movement, including native deposits and Basic/Advanced Fedaykin. Three real390px surfaces prove requested-before-return, extra3 then ordinary3 with unchanged separate ledgers, once-only income/Tech, denied no-return/no-refund, and one star to Tabr14. Original legal reinforcement geometry and Fremen zero-support plans are respected; fixture-only wrong assumptions/obsolete pins are removed. No overflow/errors. Prior funding155/155/quota329/329 evidence remains; wider combinations, assurance and 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'] },
],
},
{
diff --git a/game/revival-cancellation.ts b/game/revival-cancellation.ts
index c32558e7..f6606a62 100644
--- a/game/revival-cancellation.ts
+++ b/game/revival-cancellation.ts
@@ -64,6 +64,10 @@ function requireRevival(
const nonnegative = (n: unknown): n is number =>
Number.isSafeInteger(n) && (n as number) >= 0;
+/** The Emperor Nexus return is its own fixed source: it moves real Tanks and
+ * reserves without incrementing the ordinary revived/free revival ledgers or
+ * the Emperor-extra allowance, so those counters are never re-floated for it.
+ * Native physical and typed caps below still apply. */
export function continuationCustody(
g: RevivalCancellationContext,
p: Player,
@@ -79,13 +83,14 @@ export function continuationCustody(
requireRevival(
nonnegative(p.reserves + n) &&
nonnegative(p.revived) &&
- (pending.emperorExtra
- ? nonnegative(g.emperorExtra[p.id] ?? 0) &&
- nonnegative((g.emperorExtra[p.id] ?? 0) + n)
- : nonnegative(p.revived + n) &&
- ((p.freeForcesRevived === undefined && p.revived !== 0) ||
- (nonnegative(p.freeForcesRevived ?? 0) &&
- nonnegative((p.freeForcesRevived ?? 0) + pending.free)))),
+ (pending.nexusEmperorEvent !== undefined ||
+ (pending.emperorExtra
+ ? nonnegative(g.emperorExtra[p.id] ?? 0) &&
+ nonnegative((g.emperorExtra[p.id] ?? 0) + n)
+ : nonnegative(p.revived + n) &&
+ ((p.freeForcesRevived === undefined && p.revived !== 0) ||
+ (nonnegative(p.freeForcesRevived ?? 0) &&
+ nonnegative((p.freeForcesRevived ?? 0) + pending.free))))),
'The pending revival would overflow its force or revival counters.',
);
if (p.elites)
@@ -207,6 +212,7 @@ export function quoteRevivalCancellation(
);
requireRevival(
['forces', 'leader', 'kwisatz', 'foreignGhola'].includes(original.kind) &&
+ original.nexusEmperorEvent === undefined &&
nonnegative(original.cost) &&
nonnegative(original.normalCost) &&
original.cost <= original.normalCost &&
diff --git a/game/revival-resume.ts b/game/revival-resume.ts
index 724dc2be..7bcc241f 100644
--- a/game/revival-resume.ts
+++ b/game/revival-resume.ts
@@ -11,6 +11,11 @@ import { TECH_TOKENS, ownedTech } from './tech-tokens';
import { tleilaxuHomeworldFreeIncomeBlocked } from './homeworld-benefits';
import { DUKE_VIDAL_ID } from './duke-vidal';
import { resolveEcazDukeRevival } from './ecaz-duke-revival';
+import {
+ emperorNexusPools,
+ validateEmperorNexusRevival,
+} from './nexus-emperor-secret-ally';
+import { validateNexusCards } from './nexus-cards';
export type RevivalResumeContext = Pick<
Game,
@@ -18,6 +23,7 @@ export type RevivalResumeContext = Pick<
| 'phase'
| 'turn'
| 'advanced'
+ | 'expansions'
| 'homeworlds'
| 'homeworldRevival'
| 'players'
@@ -29,6 +35,9 @@ export type RevivalResumeContext = Pick<
| 'revivalFreeIncome'
| 'dukeVidal'
| 'revivalRequests'
+ | 'nexusCards'
+ | 'nexusEmperorSecretHistory'
+ | 'nexusEmperorSecretEvents'
>;
export type RevivalResumeQuote = {
/** The frozen request, including the unshifted remaining check queue. */
@@ -55,6 +64,84 @@ function requireResume(condition: unknown, message: string): asserts condition {
}
const count = (value: unknown): value is number =>
Number.isSafeInteger(value) && (value as number) >= 0;
+/** The independent source carries its own event marker and nothing else. */
+const nexusEmperorPendingKeys: Record = {
+ player: true,
+ kind: true,
+ amount: true,
+ elite: true,
+ free: true,
+ cost: true,
+ normalCost: true,
+ checks: true,
+ nexusEmperorEvent: true,
+};
+
+/** The consumed card authorizes only its one still-requested frozen source.
+ * Historical returned/denied receipts never authorize another pending revival. */
+function validateNexusEmperorSource(
+ g: RevivalResumeContext,
+ pending: PendingRevival,
+): void {
+ const event = pending.nexusEmperorEvent;
+ requireResume(
+ typeof event === 'string' && event.length > 0 &&
+ Object.keys(pending).every((key) => nexusEmperorPendingKeys[key]) &&
+ pending.kind === 'forces' && pending.amount === 3 &&
+ Number.isSafeInteger(pending.elite) && pending.elite! >= 0 &&
+ pending.elite! <= 3 && pending.free === 3 &&
+ pending.cost === 0 && pending.normalCost === 0 &&
+ pending.payer === undefined && pending.emperorExtra === undefined &&
+ pending.leader === undefined && Array.isArray(pending.checks) &&
+ pending.checks.length === 0,
+ 'The Emperor Nexus source needs its original exact-three free request.',
+ );
+ const owner = g.players.find((p) => p.id === pending.player);
+ const history = g.nexusEmperorSecretHistory;
+ const events = g.nexusEmperorSecretEvents;
+ requireResume(
+ owner && !owner.ally &&
+ !g.players.some((p) => p.faction === 'emperor') &&
+ Array.isArray(history) && Array.isArray(events) &&
+ history.length === events.length &&
+ new Set(events).size === events.length &&
+ history.every((receipt, index) =>
+ receipt && typeof receipt === 'object' &&
+ receipt.event === events[index] && receipt.sequence === index) &&
+ history.filter((receipt) => receipt.event === event).length === 1,
+ 'The Emperor Nexus request has lost its original owner or source history.',
+ );
+ const receipt = history.find((entry) => entry.event === event)!;
+ const currentPools = emperorNexusPools(owner);
+ requireResume(
+ receipt.version === 2 && receipt.stage === 'requested' &&
+ receipt.owner === owner.id && receipt.turn === g.turn &&
+ receipt.phase === g.phase && receipt.elite === pending.elite &&
+ receipt.after === null &&
+ receipt.before && Object.entries(currentPools).every(([key, value]) =>
+ receipt.before[key as keyof typeof currentPools] === value) &&
+ history.at(-1) === receipt &&
+ history.filter((entry) =>
+ entry.version === 2 && entry.stage === 'requested').length === 1,
+ 'The Emperor Nexus request does not match its still-requested physical source.',
+ );
+ try {
+ validateEmperorNexusRevival(g, receipt);
+ const cards = g.nexusCards?.cards;
+ requireResume(cards,
+ 'The Emperor Nexus request has lost its consumed physical card.');
+ validateNexusCards(cards, g.players);
+ requireResume(
+ cards.hands[owner.id] === null && cards.discard.includes('emperor'),
+ 'The Emperor Nexus request has lost its consumed physical card.',
+ );
+ } catch (error) {
+ if (error instanceof RevivalResumeError) throw error;
+ throw new RevivalResumeError(
+ error instanceof Error ? error.message : 'The Emperor Nexus source is invalid.',
+ );
+ }
+}
/** Only the next deterministic continuation is quoted. A genuine stop decision
* or benefit response is a boundary, even when the outer automatic-response
@@ -105,6 +192,14 @@ export function quoteRevivalResume(
: original.payer === undefined,
'The pending revival has an invalid Emperor funding source.',
);
+ requireResume(
+ !g.nexusEmperorSecretHistory?.some((receipt) =>
+ receipt.version === 2 && receipt.stage === 'requested') ||
+ original.nexusEmperorEvent !== undefined,
+ 'The requested Emperor Nexus source is missing from its pending revival.',
+ );
+ if (original.nexusEmperorEvent !== undefined)
+ validateNexusEmperorSource(g, original);
if (original.kind === 'forces') {
requireResume(
count(original.amount) &&
@@ -121,7 +216,8 @@ export function quoteRevivalResume(
// ordinary revival usage. Validate the accepted group's free allocation
// before settlement crosses a population threshold. A later independent
// request gets the rate at its own declaration.
- if (g.homeworlds && !original.emperorExtra)
+ if (g.homeworlds && !original.emperorExtra &&
+ original.nexusEmperorEvent === undefined)
requireResume(
original.free ===
Math.min(original.amount, freeRevivalRemaining(g, p)),
@@ -131,7 +227,7 @@ export function quoteRevivalResume(
const freeCyborgs = paid * 2 + elite - original.normalCost;
requireResume(
count(paid * 2) &&
- (p.faction === 'ixians'
+ (p.faction === 'ixians' && original.nexusEmperorEvent === undefined
? count(freeCyborgs) &&
freeCyborgs >=
Math.max(0, original.free - (original.amount - elite)) &&
@@ -152,6 +248,7 @@ export function quoteRevivalResume(
original.amount === undefined &&
original.elite === undefined &&
!original.emperorExtra &&
+ original.nexusEmperorEvent === undefined &&
(original.kind === 'kwisatz'
? g.advanced &&
p.faction === 'atreides' &&
@@ -189,7 +286,8 @@ export function quoteRevivalResume(
allowed.indexOf(check) >
allowed.indexOf(original.checks[index - 1])),
) &&
- (!original.emperorExtra || original.checks.length === 0) &&
+ (!(original.emperorExtra || original.nexusEmperorEvent !== undefined) ||
+ original.checks.length === 0) &&
original.checks.every((check) => check === 'choamRevival' || !!tleilaxu),
'The pending revival has invalid ordered benefit checks or no benefit owner.',
);
@@ -214,6 +312,7 @@ export function quoteRevivalResume(
if (
pending.kind === 'forces' &&
!pending.emperorExtra &&
+ pending.nexusEmperorEvent === undefined &&
p.faction === 'fremen'
) {
requireResume(
@@ -306,17 +405,14 @@ export function quoteRevivalResume(
}
}
}
- if (stop)
- return {
- ...quote,
- outcome: 'decision',
- nextDecision: {
- kind: 'revivalStop',
- player: tleilaxu.id,
- recipient: p.id,
- revival: pending.kind === 'forces' ? 'forces' : 'leader',
- },
+ if (stop) {
+ const nextDecision: NonNullable = {
+ kind: 'revivalStop', player: tleilaxu.id, recipient: p.id,
+ revival: pending.kind === 'forces' ? 'forces' : 'leader',
};
+ if (pending.nexusEmperorEvent) nextDecision.source = 'nexusEmperor';
+ return { ...quote, outcome: 'decision', nextDecision };
+ }
if (check)
return {
...quote,
diff --git a/game/revival.ts b/game/revival.ts
index 12af109b..a26e95a7 100644
--- a/game/revival.ts
+++ b/game/revival.ts
@@ -265,6 +265,8 @@ export type PendingRevival = {
kind: 'forces' | 'leader' | 'kwisatz' | 'foreignGhola';
payer?: string;
emperorExtra?: boolean;
+ /** Independent fixed-three source; never native Emperor funding or ordinary usage. */
+ nexusEmperorEvent?: string;
amount?: number;
elite?: number;
leader?: string;
diff --git a/tests/classic-nexus-skills-payments-runtime.test.ts b/tests/classic-nexus-skills-payments-runtime.test.ts
index 30695fac..f9d35367 100644
--- a/tests/classic-nexus-skills-payments-runtime.test.ts
+++ b/tests/classic-nexus-skills-payments-runtime.test.ts
@@ -53,6 +53,7 @@ for (const advanced of [false, true]) {
assert.equal(paid.nexusCards!.cards!.discard.filter(card => card === (kind === 'emperor-revival' ? 'emperor' : 'fremen')).length, 1);
if (kind === 'emperor-revival') {
const receipt = paid.nexusEmperorSecretHistory![0];
+ assert.ok(receipt.after, 'The requested Nexus return must settle before physical receipt consumption.');
assert.equal(receipt.owner, f.actor); assert.equal(receipt.turn, 2); assert.equal(receipt.phase, 4);
assert.equal(receipt.before.tanks - receipt.after.tanks, 3);
assert.equal(receipt.before.spice, receipt.after.spice);
diff --git a/tests/discovery-classic-nexus-effects.test.ts b/tests/discovery-classic-nexus-effects.test.ts
index 27a91725..376ab779 100644
--- a/tests/discovery-classic-nexus-effects.test.ts
+++ b/tests/discovery-classic-nexus-effects.test.ts
@@ -3,7 +3,6 @@ import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
-import { emperorNexusModeSupported } from '../game/nexus-emperor-secret-ally';
import { nexusEmperorRevivalAction } from '../game/nexus-emperor-secret-ally-options';
import { nexusFremenRevivalAction } from '../game/nexus-fremen-revival-options';
import { forceRevivalQuote, forceRevivalRemaining } from '../game/revival';
@@ -108,6 +107,7 @@ for (const configuration of configurations) for (const kind of ['emperor-revival
assert.equal(history[0].turn, 2); assert.equal(history[0].phase, 4);
if (kind === 'emperor-revival') {
const receipt = paid.nexusEmperorSecretHistory![0];
+ assert.ok(receipt.after, 'The requested Nexus return must settle before physical receipt consumption.');
assert.equal(receipt.before.tanks - receipt.after.tanks, 3);
assert.equal(receipt.before.spice, receipt.after.spice);
} else {
@@ -289,23 +289,14 @@ for (const configuration of [configurations[0], configurations[1], configuration
});
}
-void test('original Nexus/Discovery Emperor purchase rejects unrelated profiles and preserves completed return integrity', () => {
+void test('completed Discovery Emperor extra return rejects altered ordinary usage', () => {
const initial = initializeDiscoveryClassicNexusEffectsSetup({ advanced: true, tech: true, strongholds: true });
const fixture = createDiscoveryClassicNexusEffectsFixture({ initial });
discoveryClassicNexusEffectsInventory(fixture.game);
- // Explicit adversarial compositions, not histories claimed to be natural play.
- for (const change of [
- (game: Game) => { game.expansions = ['ix']; },
- (game: Game) => { game.players.find(player => player.id === fixture.opponent)!.faction = 'emperor'; },
- (game: Game) => { game.advanced = false; },
- (game: Game) => { game.semutaPreview = true; },
- ]) {
- const invalid = structuredClone(fixture.game); change(invalid);
- assert.equal(emperorNexusModeSupported(invalid), false);
- rejected(invalid, fixture.actor, fixture.paymentAction);
- }
const paid = payDiscoveryClassicNexusEffectsFixture(fixture);
- const tampered = structuredClone(paid); tampered.nexusEmperorSecretHistory![0].after.revived++;
+ const tampered = structuredClone(paid), after = tampered.nexusEmperorSecretHistory![0].after;
+ assert.ok(after);
+ after.revived++;
rejected(tampered, fixture.actor, { type: 'ready' });
discoveryClassicNexusEffectsInventory(paid);
});
diff --git a/tests/discovery-classic-nexus-skills-runtime.test.ts b/tests/discovery-classic-nexus-skills-runtime.test.ts
index f53523e9..b5ebadb6 100644
--- a/tests/discovery-classic-nexus-skills-runtime.test.ts
+++ b/tests/discovery-classic-nexus-skills-runtime.test.ts
@@ -185,6 +185,7 @@ for (const advanced of [false, true]) for (const kind of ['emperor', 'fremen'] a
assert.equal(history.length, 1); assert.equal(history[0].owner, fixture.owner);
if (kind === 'emperor') {
const receipt = paid.nexusEmperorSecretHistory![0];
+ assert.ok(receipt.after, 'The requested Nexus return must settle before physical receipt consumption.');
assert.equal(receipt.before.tanks - receipt.after.tanks, 3);
} else {
const grant = paid.nexusFremenRevivalHistory![0];
diff --git a/tests/fixture-nexus-emperor-extra.ts b/tests/fixture-nexus-emperor-extra.ts
new file mode 100644
index 00000000..3410a26a
--- /dev/null
+++ b/tests/fixture-nexus-emperor-extra.ts
@@ -0,0 +1,206 @@
+import assert from 'node:assert/strict';
+import {
+ applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer, viewGame,
+ type Game,
+} from '../game/engine';
+import { location, territory } from '../game/board';
+import { emperorNexusEvent } from '../game/nexus-emperor-secret-ally';
+import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
+import { frontNativeHomeworldSpice } from './fixture-homeworld-native-e1e2-modules';
+import {
+ createSingleNexusE1Setup, nextSingleNexusE1Step,
+ openSingleNexusE1Battle, revealSingleNexusE1Plans, shipSingleNexusE1,
+ singleNexusE1Clean, singleNexusE1Inventory, singleNexusE1Player,
+ type SingleNexusE1Step,
+} from './fixture-single-nexus-e1';
+import { nexusTleilaxuSecretAllyInventory } from './fixture-nexus-tleilaxu-secret-ally';
+
+export type NexusEmperorExtraOptions = {
+ advanced?: boolean;
+ profile?: NexusEmperorExtraProfile;
+ tech?: boolean;
+ homeworlds?: boolean;
+};
+export type NexusEmperorExtraProfile = 'classic' | 'tleilaxu' | 'ixians' | 'fremen';
+export type NexusEmperorExtraFixture = {
+ initial: Game;
+ afterSetup: Game;
+ beforeShipment: Game;
+ afterShipment: Game;
+ beforeBattle: Game;
+ afterBattle: Game;
+ beforeBlow: Game;
+ beforeDraw: Game;
+ afterDraw: Game;
+ game: Game;
+ owner: string;
+ opponent: string;
+ partner: string;
+ profile: NexusEmperorExtraProfile;
+ actions: SingleNexusE1Step[];
+ event: string;
+};
+export const nexusEmperorExtraPlayer = singleNexusE1Player;
+export const nexusEmperorExtraClean = (game: Game): boolean =>
+ singleNexusE1Clean(game) && !game.pendingRevival;
+export function nexusEmperorExtraInventory(game: Game): void {
+ if (game.expansions.length) singleNexusE1Inventory(game);
+ else nexusTleilaxuSecretAllyInventory(game);
+}
+export function stepNexusEmperorExtra(
+ game: Game, step: SingleNexusE1Step, actions?: SingleNexusE1Step[],
+): Game {
+ actions?.push(structuredClone(step));
+ return withClassicDiscoveryNexusLottery(() => applyAction(game, step.actor, step.action));
+}
+/** A live native stop is never decided by the producer: callers choose Allow or Prevent. */
+export function advanceNexusEmperorExtra(
+ game: Game, until: (game: Game) => boolean, actions?: SingleNexusE1Step[],
+): Game {
+ for (let count = 0; count < 2000; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished');
+ assert.notEqual(game.decision?.kind, 'revivalStop', 'Choose the real Tleilaxu Allow/Prevent control');
+ const next = game.turn === 1 && game.phase === 5 && game.decision?.kind === 'guildTiming' &&
+ game.homeworlds && game.players.some(p => p.faction === 'fremen')
+ ? { actor: game.decision.player, action: { type: 'decision', take: true } }
+ : nextSingleNexusE1Step(game);
+ assert.ok(next, `Original Emperor chronology stops at ${game.turn}/${game.phase}/${game.decision?.kind ?? 'plan'}`);
+ game = stepNexusEmperorExtra(game, next, actions);
+ }
+ throw Error('Original Emperor chronology did not reach its boundary');
+}
+export function settleNexusEmperorExtra(game: Game): Game {
+ return advanceNexusEmperorExtra(game, nexusEmperorExtraClean);
+}
+function classicSetup(advanced: boolean, options: NexusEmperorExtraOptions) {
+ let game = createGame('NEXUSEMPEROREXTRA', newPlayer('atreides', 'Atreides', 'atreides'), advanced);
+ joinGame(game, newPlayer('guild', 'Guild', 'guild'));
+ joinGame(game, newPlayer('harkonnen', 'Harkonnen', 'harkonnen'));
+ game.nexusCards = { cards: null, phase: null };
+ const actions: SingleNexusE1Step[] = [];
+ for (const [type, enabled] of [['homeworlds', options.homeworlds], ['techTokens', options.tech]] as const)
+ if (enabled) game = stepNexusEmperorExtra(game,
+ { actor: game.host, action: { type, enabled: true } }, actions);
+ const initial = structuredClone(game);
+ for (const player of game.players)
+ game = stepNexusEmperorExtra(game, { actor: player.id, action: { type: 'ready' } }, actions);
+ game = withClassicDiscoveryNexusLottery(() => initializeNexusGameForAudit(game));
+ game = advanceNexusEmperorExtra(game, state => state.status === 'playing', actions);
+ return { initial, afterSetup: game, actions };
+}
+
+/** Original Basic/Advanced setup and deals, paid shipment/battle casualties,
+ * turn-two Shai-Hulud/alliance/closing draw, and quiet native Revival. Only
+ * original undealt singleton order and scoped scalar lottery are controlled.
+ * No hand, army, Tanks, wallet, alliance, phase, skill or history is rewritten. */
+export function createNexusEmperorExtraFixture(
+ options: NexusEmperorExtraOptions = {},
+): NexusEmperorExtraFixture {
+ const advanced = options.advanced ?? true, profile = options.profile ?? 'classic';
+ assert.ok(profile !== 'fremen' || options.homeworlds,
+ 'The Fedaykin source uses original Homeworld setup and physical starred reserves.');
+ const source = profile === 'classic' ? classicSetup(advanced, options) : createSingleNexusE1Setup({
+ native: profile === 'fremen' ? 'tleilaxu' : profile, advanced,
+ roster: profile === 'fremen' ? ['tleilaxu', 'guild', 'fremen'] : [profile, 'guild', 'atreides'],
+ skills: false, homeworlds: options.homeworlds ?? false, discovery: false, tech: options.tech ?? false, strongholds: false,
+ startingKinds: ['worthless', 'worthless', 'worthless'],
+ startingEffects: [profile === 'tleilaxu' || profile === 'fremen' ? 'karama' : undefined],
+ });
+ const { initial, afterSetup } = source, actions = [...source.actions];
+ let game = structuredClone(afterSetup);
+ const owner = profile === 'ixians' ? 'ixians' : profile === 'fremen' ? 'fremen' : 'atreides';
+ const opponent = profile === 'ixians' ? 'atreides' : 'guild';
+ const partner = game.players.find(player => player.id !== owner && player.id !== opponent)!.id;
+ assert.ok(!game.players.some(player => player.faction === 'emperor'));
+ assert.ok(game.players.every(player => player.hand.length > 0 && player.tanks === 0));
+ // Printed land blows keep the first full turn free of an earlier Nexus draw.
+ frontNativeHomeworldSpice(game, card => 'territory' in card && card.territory === 'hagga_basin', 0);
+ if (advanced)
+ frontNativeHomeworldSpice(game, card => 'territory' in card && card.territory === 'broken_land', 1);
+ const destination = profile === 'fremen' ? 'sietch_tabr' : 'arrakeen';
+ const shipper = profile === 'ixians' ? owner : opponent;
+ game = advanceNexusEmperorExtra(game,
+ state => state.turn === 1 && state.phase === 5 && state.active === shipper && nexusEmperorExtraClean(state), actions);
+ const beforeShipment = structuredClone(game);
+ const arrival = shipSingleNexusE1(game, shipper, destination, profile === 'ixians' ? 8 : 1,
+ profile === 'ixians' ? 3 : 0, actions);
+ game = arrival.after;
+ if (profile === 'fremen') {
+ game = advanceNexusEmperorExtra(game,
+ state => state.phase === 5 && state.active === owner && nexusEmperorExtraClean(state), actions);
+ game = shipSingleNexusE1(game, owner, destination, 6, 1, actions).after;
+ }
+ const afterShipment = structuredClone(game);
+ assert.ok(nexusEmperorExtraPlayer(game, shipper).spice < nexusEmperorExtraPlayer(beforeShipment, shipper).spice);
+ const target = location(destination, territory(destination).sectors[0]);
+ if (profile !== 'fremen') assert.equal(nexusEmperorExtraPlayer(game, 'atreides').forces[target], 10);
+ game = openSingleNexusE1Battle(game, owner, opponent, destination, actions);
+ const beforeBattle = structuredClone(game);
+ const ownLeader = nexusEmperorExtraPlayer(game, owner).leaders.filter(leader => !leader.dead)
+ .sort((a, b) => profile === 'ixians' ? a.strength - b.strength : b.strength - a.strength)[0];
+ const otherLeader = nexusEmperorExtraPlayer(game, opponent).leaders.filter(leader => !leader.dead)
+ .sort((a, b) => profile === 'ixians' ? b.strength - a.strength : a.strength - b.strength)[0];
+ assert.ok(ownLeader && otherLeader);
+ game = revealSingleNexusE1Plans(game, [
+ { actor: owner, action: { type: 'battlePlan',
+ dial: profile === 'ixians' ? 0 : profile === 'fremen' ? advanced ? 6 : 5 : 8,
+ support: advanced && profile !== 'ixians' && profile !== 'fremen' ? 8 : 0,
+ leader: ownLeader.id, weapon: null, defense: null } },
+ { actor: opponent, action: { type: 'battlePlan', dial: 0, support: 0,
+ leader: otherLeader.id, weapon: null, defense: null } },
+ ], actions);
+ for (let count = 0; game.battle || !nexusEmperorExtraClean(game); count++) {
+ assert.ok(count < 600);
+ let next = nextSingleNexusE1Step(game);
+ if (!next) {
+ const decision = game.decision;
+ assert.ok(decision);
+ let action: Parameters[2];
+ if (decision.kind === 'ixSubstitution') action = { type: 'decision', decline: true };
+ else if (decision.kind === 'faceDance') action = { type: 'decision', reveal: false };
+ else if (decision.kind === 'battleCards') action = { type: 'decision', discard: [] };
+ else if (decision.kind === 'techToken') action = { type: 'decision', token: decision.choices[0] };
+ else throw Error(`Consume the real ${decision.kind} before continuing`);
+ next = { actor: decision.player, action };
+ }
+ game = stepNexusEmperorExtra(game, next, actions);
+ }
+ const afterBattle = structuredClone(game);
+ assert.equal(nexusEmperorExtraPlayer(game, owner).tanks, profile === 'fremen' ? 5 : 8);
+ if (profile === 'ixians') assert.equal(nexusEmperorExtraPlayer(game, owner).elites!.tanks, 3);
+ if (profile === 'fremen') assert.equal(nexusEmperorExtraPlayer(game, owner).elites!.tanks, 1);
+ nexusEmperorExtraInventory(game);
+ game = advanceNexusEmperorExtra(game, state => state.turn === 2 && state.phase === 1 &&
+ nexusEmperorExtraClean(state) && !state.spiceSequence && !state.spiceResolution && !state.spiceWindow &&
+ !state.nexus && state.ready.length === 0, actions);
+ const beforeBlow = structuredClone(game);
+ frontNativeHomeworldSpice(game, card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper, 0);
+ frontNativeHomeworldSpice(game, card => 'territory' in card && card.territory === 'rock_outcroppings', 1);
+ if (advanced)
+ frontNativeHomeworldSpice(game, card => 'territory' in card && card.territory === 'oh_gap', 2);
+ game = advanceNexusEmperorExtra(game, state => !!state.nexus && !state.spiceWindow &&
+ !state.spiceResolution && nexusEmperorExtraClean(state), actions);
+ for (const [actor, ally] of [[opponent, partner], [partner, opponent]])
+ game = stepNexusEmperorExtra(game, { actor, action: { type: 'alliance', target: ally } }, actions);
+ game = advanceNexusEmperorExtra(game, state => state.nexusCards?.phase?.stage === 'drawing' &&
+ nexusEmperorExtraClean(state), actions);
+ assert.deepEqual(game.nexusCards!.phase!.eligible, [owner]);
+ const cards = game.nexusCards!.cards!, index = cards.deck.indexOf('emperor');
+ assert.ok(index >= 0);
+ cards.deck.unshift(cards.deck.splice(index, 1)[0]);
+ const beforeDraw = structuredClone(game);
+ game = stepNexusEmperorExtra(game, { actor: owner, action: { type: 'nexusCardChoice',
+ turn: game.turn, card: cards.hands[owner], choice: 'draw', ownRedraws: 0 } }, actions);
+ const afterDraw = structuredClone(game);
+ game = advanceNexusEmperorExtra(game, state => state.turn === 2 && state.phase === 4 &&
+ nexusEmperorExtraClean(state), actions);
+ assert.equal(game.nexusCards!.cards!.hands[owner], 'emperor');
+ assert.equal(nexusEmperorExtraPlayer(game, owner).ally, null);
+ assert.equal(nexusEmperorExtraPlayer(game, owner).tanks, profile === 'fremen' ? 5 : 8);
+ assert.ok(viewGame(game, owner).nexusEmperorSecretAlly);
+ const event = emperorNexusEvent(game.turn, game.phase, owner, game.nexusEmperorSecretHistory?.length ?? 0);
+ nexusEmperorExtraInventory(game);
+ return { initial, afterSetup, beforeShipment, afterShipment, beforeBattle, afterBattle,
+ beforeBlow, beforeDraw, afterDraw, game, owner, opponent, partner, profile, actions, event };
+}
diff --git a/tests/fixture-single-nexus-e1.ts b/tests/fixture-single-nexus-e1.ts
index 71eefd94..f6130c44 100644
--- a/tests/fixture-single-nexus-e1.ts
+++ b/tests/fixture-single-nexus-e1.ts
@@ -42,6 +42,7 @@ export type SingleNexusE1Options = {
tech?: boolean;
strongholds?: boolean;
startingKinds?: readonly Card['kind'][];
+ startingEffects?: readonly Card['effect'][];
collectorSkill?: LeaderSkillId;
};
export type SingleNexusE1Setup = {
@@ -189,7 +190,9 @@ export function createSingleNexusE1Setup(options: SingleNexusE1Options = {}): Si
const cards = treacheryDeck(g.expansions), remaining = [...cards], prefix: Card[] = [];
for (const [i, p] of g.players.entries()) for (let j = 0; j < (p.faction === 'harkonnen' ? 2 : 1); j++) {
const kind = options.startingKinds?.[i] ?? (i < 5 ? 'worthless' : 'shield');
- const at = remaining.findIndex(c => c.kind === kind); assert.ok(at >= 0); prefix.push(remaining.splice(at, 1)[0]);
+ const effect = options.startingEffects?.[i];
+ const at = remaining.findIndex(c => effect ? c.effect === effect : c.kind === kind);
+ assert.ok(at >= 0); prefix.push(remaining.splice(at, 1)[0]);
}
const rolls = [...(skills ? permutation(skillSource, skillOrder) : []),
...(discovery ? Array(7).fill(0xffffffff) : []), ...Array(NEXUS_FACTIONS.length - 1).fill(0xffffffff),
diff --git a/tests/nexus-emperor-extra-continuation.test.ts b/tests/nexus-emperor-extra-continuation.test.ts
new file mode 100644
index 00000000..f50a4e04
--- /dev/null
+++ b/tests/nexus-emperor-extra-continuation.test.ts
@@ -0,0 +1,502 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ createGame,
+ joinGame,
+ newPlayer,
+ type Game,
+ type Player,
+} from '../game/engine';
+import {
+ createNexusCards,
+ discardNexusCard,
+ drawNexusCard,
+} from '../game/nexus-cards';
+import { createTechTokens } from '../game/tech-tokens';
+import {
+ createEmperorNexusRevival,
+ emperorNexusEvent,
+ emperorNexusSignature,
+ settleEmperorNexusRevival,
+ type NexusEmperorRevival,
+ type NexusEmperorRevivalRequest,
+} from '../game/nexus-emperor-secret-ally';
+import { newRevivalRules, type PendingRevival } from '../game/revival';
+import { quoteRevivalResume, RevivalResumeError } from '../game/revival-resume';
+import {
+ continuationCustody,
+ quoteRevivalCancellation,
+ RevivalCancellationError,
+} from '../game/revival-cancellation';
+import {
+ createHomeworldCustody,
+ type HomeworldCustody,
+} from '../game/homeworld-custody';
+import { homeworldContext } from '../game/homeworld-game';
+import {
+ quoteHomeworldRevivalDeployment,
+ type HomeworldRevivalDeploymentGroup,
+ type HomeworldRevivalDeploymentSource,
+} from '../game/homeworld-revival-deployment';
+import {
+ makeHomeworldRevivalProgress,
+ makeHomeworldRevivalReturn,
+ validateHomeworldRevivalReturn,
+} from '../game/homeworld-revival-return';
+
+const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
+const json = (value: T): T => JSON.parse(JSON.stringify(value));
+
+/** Resolved-facts unit context, not a native gameplay chronology. Nexus custody
+ * still uses the original inventory/draw/discard APIs and the real source receipt. */
+function fixture(
+ options: { advanced?: boolean; tleilaxu?: boolean; consumed?: boolean; declared?: boolean; ordinaryUsed?: number } = {},
+) {
+ const advanced = options.advanced ?? false;
+ const g = createGame(
+ 'NEXUSEMPEROREXTRA',
+ newPlayer('p', 'Fremen', 'fremen'),
+ advanced,
+ options.tleilaxu ? ['ix'] : [],
+ );
+ joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
+ joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen'));
+ if (options.tleilaxu) joinGame(g, newPlayer('t', 'Tleilaxu', 'tleilaxu'));
+ Object.assign(g, {
+ status: 'playing',
+ phase: 4,
+ turn: 2,
+ order: g.players.map((p) => p.id),
+ revivalRules: newRevivalRules(),
+ freeRevival: [],
+ });
+ for (const p of g.players)
+ Object.assign(p, { hand: [], spice: 40, tanks: 10, reserves: 10, forces: {} });
+ player(g, 'p').revived = options.ordinaryUsed ?? 0;
+ player(g, 'p').freeForcesRevived = options.ordinaryUsed ?? 0;
+ let cards = createNexusCards(g.players, () => 0.5);
+ cards.deck = ['emperor', ...cards.deck.filter((card) => card !== 'emperor')];
+ cards = drawNexusCard(cards, 'p', g.players, () => 0);
+ g.nexusCards = { cards, phase: null };
+ const receipt = createEmperorNexusRevival(g, 'p', 0);
+ if (options.declared !== false) {
+ g.nexusEmperorSecretHistory = [receipt];
+ g.nexusEmperorSecretEvents = [receipt.event];
+ if (options.consumed !== false)
+ g.nexusCards.cards = discardNexusCard(g.nexusCards.cards!, 'p', g.players);
+ }
+ return { g, receipt };
+}
+function pending(receipt: NexusEmperorRevivalRequest): PendingRevival {
+ return {
+ player: 'p',
+ kind: 'forces',
+ amount: 3,
+ elite: receipt.elite,
+ free: 3,
+ cost: 0,
+ normalCost: 0,
+ checks: [],
+ nexusEmperorEvent: receipt.event,
+ };
+}
+
+void test('the requested source quotes the original stop and finish continuations without repricing', () => {
+ const advanced = fixture({ advanced: true, tleilaxu: true });
+ advanced.g.techTokens = createTechTokens(advanced.g.players);
+ const stop = json(advanced.g);
+ const stopQuote = quoteRevivalResume(
+ { ...advanced.g, pendingRevival: pending(advanced.receipt) },
+ 'stop',
+ );
+ assert.equal(stopQuote.outcome, 'decision');
+ assert.deepEqual(json(advanced.g), stop, 'quoting changes nothing');
+
+ const finishQuote = quoteRevivalResume(
+ { ...advanced.g, pendingRevival: pending(advanced.receipt) },
+ 'finish',
+ );
+ assert.equal(finishQuote.outcome, 'revive');
+ assert.deepEqual(finishQuote.freeIncome, { player: 'p', turn: 2 });
+ assert.deepEqual(finishQuote.techIncome, {
+ id: 'axlotl',
+ owner: 't',
+ spice: 1,
+ triggeredTurn: 2,
+ });
+ assert.deepEqual(finishQuote.nextResponse, {
+ kind: 'revivalIncome',
+ owner: 't',
+ recipient: 'p',
+ amount: 1,
+ passed: [],
+ });
+ // The fixed grant is exactly three free counters at no ordinary cost.
+ assert.equal(finishQuote.pending.amount, 3);
+ assert.equal(finishQuote.pending.free, 3);
+ assert.equal(finishQuote.pending.cost, 0);
+ assert.equal(finishQuote.pending.normalCost, 0);
+});
+
+void test('ordinary caps, blocks and Recruits state never block or reprice the independent three', () => {
+ const { g, receipt } = fixture({ ordinaryUsed: 20 });
+ const state = structuredClone({
+ p: player(g, 'p'),
+ spice: player(g, 'p').spice,
+ });
+ Object.assign(g, {
+ revivalRules: {
+ ...newRevivalRules(),
+ limitBlocked: true,
+ freeBlocked: ['p'],
+ },
+ freeRevival: ['p'],
+ recruits: { turn: g.turn, player: 'p', card: 'ecaz-recruits' },
+ emperorExtra: { p: 0 },
+ });
+ const before = json(g);
+ const quote = quoteRevivalResume({ ...g, pendingRevival: pending(receipt) }, 'finish');
+ assert.equal(quote.outcome, 'revive');
+ assert.deepEqual(json(g), before, 'no counter, wallet or rule state moves');
+ assert.equal(player(g, 'p').revived, 20);
+ assert.equal(player(g, 'p').freeForcesRevived, 20);
+ assert.equal(g.emperorExtra!.p, 0, 'the native Emperor-extra allowance is untouched');
+ assert.equal(player(g, 'p').spice, state.spice, 'no ordinary price is charged');
+ assert.equal(player(g, 'p').tanks, 10, 'the physical Tanks move only on the real return');
+ continuationCustody({ ...g, pendingRevival: null }, player(g, 'p'), pending(receipt));
+});
+
+void test('native physical and typed caps still apply to the extra return', () => {
+ const { g, receipt } = fixture();
+ const p = player(g, 'p');
+ p.elites = { reserves: 5, tanks: 2, forces: {}, revived: 0 };
+ p.tanks = 4;
+ const extra = { ...pending(receipt), elite: 3 };
+ assert.throws(
+ () => continuationCustody({ ...g, pendingRevival: null }, p, extra),
+ /ordinary and elite revival groups/,
+ );
+ p.tanks = 2;
+ assert.throws(
+ () => continuationCustody({ ...g, pendingRevival: null }, p, pending(receipt)),
+ /no longer in the tanks/,
+ );
+});
+
+void test('malformed, stale, foreign, missing, unconsumed and outside-request sources are rejected immutably', () => {
+ const base = fixture();
+ const state = json(base.g);
+ const cases: ((g: Game, receipt: NexusEmperorRevivalRequest) => PendingRevival)[] = [
+ (g, receipt) => ({ ...pending(receipt), amount: 2 }),
+ (g, receipt) => ({ ...pending(receipt), free: 2 }),
+ (g, receipt) => ({ ...pending(receipt), elite: 1 }),
+ (g, receipt) => ({ ...pending(receipt), cost: 1 }),
+ (g, receipt) => ({ ...pending(receipt), player: 'a' }),
+ (g, receipt) => ({ ...pending(receipt), payer: 'a' }),
+ (g, receipt) => ({ ...pending(receipt), emperorExtra: true }),
+ (g, receipt) => ({ ...pending(receipt), nexusEmperorEvent: receipt.event + 'x' }),
+ (g) => {
+ g.nexusEmperorSecretHistory = undefined;
+ g.nexusEmperorSecretEvents = undefined;
+ return pending(base.receipt);
+ },
+ (g) => {
+ g.nexusEmperorSecretHistory![0].signature += 'x';
+ return pending(base.receipt);
+ },
+ (g) => {
+ g.nexusEmperorSecretHistory![0].sequence = 1;
+ return pending(base.receipt);
+ },
+ (g) => {
+ g.nexusEmperorSecretHistory![0].elite = 1;
+ return pending(base.receipt);
+ },
+ (g) => {
+ player(g, 'a').ally = 'p';
+ player(g, 'p').ally = 'a';
+ return pending(base.receipt);
+ },
+ (g) => {
+ player(g, 'p').tanks = 9;
+ return pending(base.receipt);
+ },
+ (g) => {
+ player(g, 'h').faction = 'emperor';
+ return pending(base.receipt);
+ },
+ ];
+ for (const build of cases) {
+ const g = json(base.g);
+ const request = build(g, json(base.receipt));
+ const snapshot = json(g);
+ assert.throws(
+ () => quoteRevivalResume({ ...g, pendingRevival: request }, 'finish'),
+ RevivalResumeError,
+ );
+ assert.deepEqual(json(g), snapshot, 'a rejected source mutates nothing');
+ }
+ const unconsumed = fixture({ consumed: false });
+ assert.throws(
+ () =>
+ quoteRevivalResume(
+ { ...unconsumed.g, pendingRevival: pending(unconsumed.receipt) },
+ 'finish',
+ ),
+ /consumed physical card/,
+ );
+ const stale: NexusEmperorRevivalRequest = {
+ ...json(base.receipt),
+ turn: 1,
+ event: emperorNexusEvent(1, 4, 'p', 0),
+ signature: '',
+ };
+ stale.signature = emperorNexusSignature(stale);
+ const staleGame = json(base.g);
+ staleGame.nexusEmperorSecretHistory = [stale];
+ staleGame.nexusEmperorSecretEvents = [stale.event];
+ assert.throws(
+ () =>
+ quoteRevivalResume(
+ { ...staleGame, pendingRevival: pending(stale) },
+ 'finish',
+ ),
+ RevivalResumeError,
+ );
+ assert.deepEqual(json(base.g), state, 'the fixture survives every rejected read');
+});
+
+void test('a settled returned or denied source no longer authorizes another pending revival', () => {
+ const returned = fixture();
+ const p = player(returned.g, 'p');
+ p.tanks -= 3;
+ p.reserves += 3;
+ returned.g.nexusEmperorSecretHistory![0] = settleEmperorNexusRevival(
+ returned.g,
+ json(returned.g.nexusEmperorSecretHistory![0]),
+ 'returned',
+ );
+ assert.throws(
+ () =>
+ quoteRevivalResume(
+ { ...returned.g, pendingRevival: pending(returned.receipt) },
+ 'finish',
+ ),
+ RevivalResumeError,
+ );
+ const denied = fixture();
+ denied.g.nexusEmperorSecretHistory![0] = settleEmperorNexusRevival(
+ denied.g,
+ json(denied.g.nexusEmperorSecretHistory![0]),
+ 'denied',
+ );
+ assert.throws(
+ () =>
+ quoteRevivalResume(
+ { ...denied.g, pendingRevival: pending(denied.receipt) },
+ 'finish',
+ ),
+ RevivalResumeError,
+ );
+});
+
+void test('ordinary benefit cancellations do not reach the independent extra source', () => {
+ const { g, receipt } = fixture({ advanced: true, tleilaxu: true });
+ for (const kind of ['revivalLimit', 'revivalDiscount'] as const) {
+ const request = pending(receipt);
+ const snapshot = json(g);
+ assert.throws(
+ () =>
+ quoteRevivalCancellation(
+ { ...g, pendingRevival: request },
+ { kind, owner: 't', recipient: 'p' },
+ ),
+ RevivalCancellationError,
+ );
+ assert.deepEqual(json(g), snapshot);
+ }
+});
+
+void test('ordinary free and paid requests keep their original continuation', () => {
+ const { g } = fixture({ declared: false });
+ const ordinary: PendingRevival = {
+ player: 'p',
+ kind: 'forces',
+ amount: 3,
+ elite: 0,
+ free: 3,
+ cost: 0,
+ normalCost: 0,
+ checks: [],
+ };
+ assert.equal(
+ quoteRevivalResume({ ...g, pendingRevival: ordinary }, 'finish').outcome,
+ 'revive',
+ );
+ player(g, 'p').tanks = 2;
+ assert.equal(
+ quoteRevivalResume({ ...g, pendingRevival: ordinary }, 'finish').outcome,
+ 'fremenLimit',
+ );
+});
+
+type HomeworldFixture = {
+ advanced: boolean;
+ players: Player[];
+ homeworlds: { custody: HomeworldCustody };
+};
+function homeworldFixture(
+ faction: 'fremen' | 'tleilaxu',
+ reserves: number,
+): HomeworldFixture {
+ const players = [
+ newPlayer('p', 'Native', faction),
+ newPlayer('v', 'Visitor', 'harkonnen'),
+ ];
+ players[0].reserves = reserves;
+ if (faction === 'fremen')
+ players[0].elites = { reserves: 0, tanks: 3, forces: {}, revived: 0 };
+ const context = { advanced: true, players };
+ return {
+ ...context,
+ homeworlds: { custody: createHomeworldCustody(homeworldContext(context)) },
+ };
+}
+function deposited(
+ before: HomeworldFixture,
+ group: HomeworldRevivalDeploymentGroup,
+) {
+ const after = structuredClone(before);
+ after.players[0].reserves += group.amount;
+ if (after.players[0].elites) after.players[0].elites.reserves += group.elite;
+ if (after.homeworlds.custody.salusa)
+ after.homeworlds.custody.salusa.elite += group.elite;
+ return after;
+}
+
+void test('the typed Homeworld source accepts the canonical exact-three free group for Fremen', () => {
+ const sources: HomeworldRevivalDeploymentSource[] = [
+ 'normal',
+ 'emperorExtra',
+ 'ghola',
+ 'nexusTleilaxu',
+ 'nexusEmperor',
+ ];
+ for (const source of sources) {
+ const before = homeworldFixture('fremen', 3);
+ before.players[0].elites!.reserves = 1;
+ const group = {
+ amount: 3,
+ elite: 2,
+ free: source === 'normal' || source === 'nexusEmperor' ? 3 : 0,
+ };
+ const after = deposited(before, group);
+ assert.deepEqual(
+ quoteHomeworldRevivalDeployment(before, after, 'p', source, group),
+ {
+ kind: 'fedaykin',
+ normal: 0,
+ elite: 2,
+ beforePopulation: 3,
+ afterPopulation: 6,
+ blocked: null,
+ },
+ source,
+ );
+ }
+ // Tleilax free-revival deployment is still ordinary Free Revival only: the
+ // independent zero-spice source never fabricates a Tleilaxu free return.
+ for (const source of ['nexusEmperor', 'emperorExtra', 'ghola'] as const) {
+ const before = homeworldFixture('tleilaxu', 9);
+ const group = {
+ amount: 3,
+ elite: 0,
+ free: source === 'nexusEmperor' ? 3 : 0,
+ };
+ assert.equal(
+ quoteHomeworldRevivalDeployment(
+ before,
+ deposited(before, group),
+ 'p',
+ source,
+ group,
+ ),
+ null,
+ source,
+ );
+ }
+});
+
+void test('the Homeworld source rejects any non-canonical group and a mismatched frame immutably', () => {
+ const before = homeworldFixture('fremen', 3);
+ before.players[0].elites!.reserves = 1;
+ const group = { amount: 3, elite: 2, free: 3 };
+ const after = deposited(before, group);
+ const snapshot = structuredClone({ before, after });
+ for (const bad of [
+ { ...group, free: 2 },
+ { ...group, free: 0 },
+ { ...group, amount: 2 },
+ ]) {
+ assert.throws(() =>
+ quoteHomeworldRevivalDeployment(
+ before,
+ deposited(before, bad),
+ 'p',
+ 'nexusEmperor',
+ bad,
+ ),
+ );
+ }
+ assert.deepEqual({ before, after }, snapshot);
+
+ const quote = quoteHomeworldRevivalDeployment(
+ before,
+ after,
+ 'p',
+ 'nexusEmperor',
+ group,
+ )!;
+ const original = {
+ event: 'nx-emperor-return',
+ turn: 2,
+ phase: 4,
+ player: 'p',
+ source: 'nexusEmperor' as const,
+ group,
+ quote,
+ };
+ const frame = makeHomeworldRevivalReturn(original);
+ const g = {
+ ...after,
+ turn: 2,
+ phase: 4,
+ homeworldRevivalProgress: makeHomeworldRevivalProgress(frame),
+ };
+ validateHomeworldRevivalReturn(g, frame);
+ validateHomeworldRevivalReturn(g, json(frame));
+ for (const bad of [
+ { ...group, free: 2 },
+ { ...group, amount: 4 },
+ ]) {
+ const tampered = { ...json(frame), group: bad } as typeof frame;
+ assert.throws(() => validateHomeworldRevivalReturn(g, tampered));
+ }
+ assert.throws(() =>
+ makeHomeworldRevivalReturn({
+ ...original,
+ group: { amount: 3, elite: 0, free: 2 },
+ }),
+ );
+});
+
+void test('the stored extra receipt round-trips as strict requested evidence', () => {
+ const { g, receipt } = fixture();
+ const copy = json(receipt) as NexusEmperorRevival;
+ assert.deepEqual(copy, receipt);
+ assert.equal(copy.version, 2);
+ assert.equal(copy.stage, 'requested');
+ assert.equal(copy.after, null);
+ assert.equal(copy.signature, emperorNexusSignature(copy));
+ assert.equal(g.nexusEmperorSecretEvents![0], receipt.event);
+});
diff --git a/tests/nexus-emperor-extra-runtime.test.ts b/tests/nexus-emperor-extra-runtime.test.ts
new file mode 100644
index 00000000..60cfbdfe
--- /dev/null
+++ b/tests/nexus-emperor-extra-runtime.test.ts
@@ -0,0 +1,298 @@
+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 {
+ emperorNexusPools, validateEmperorNexusRevival,
+} from '../game/nexus-emperor-secret-ally';
+import { forceRevivalQuote, forceRevivalRemaining, revivalPrevented } from '../game/revival';
+import { ownedTech } from '../game/tech-tokens';
+import { homeworldRevivalDeploymentChoice } from '../game/homeworld-revival-deployment-options';
+import {
+ advanceNexusEmperorExtra, createNexusEmperorExtraFixture, nexusEmperorExtraClean,
+ nexusEmperorExtraInventory, nexusEmperorExtraPlayer as player, settleNexusEmperorExtra,
+} from './fixture-nexus-emperor-extra';
+
+function grant(game: Game, owner: string, elite = 0): Action {
+ const offer = viewGame(game, owner).nexusEmperorSecretAlly;
+ assert.ok(offer);
+ return { type: 'nexusEmperorRevive', event: offer.event, elite };
+}
+function receipt(game: Game, stage: 'requested' | 'returned' | 'denied') {
+ assert.equal(game.nexusEmperorSecretHistory?.length, 1);
+ const record = game.nexusEmperorSecretHistory![0];
+ assert.ok('version' in record && record.version === 2);
+ assert.equal(record.stage, stage);
+ validateEmperorNexusRevival(game, record);
+ return record;
+}
+function spent(game: Game, owner: string): void {
+ assert.equal(game.nexusCards!.cards!.hands[owner], null);
+ assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
+ assert.equal(viewGame(game, owner).nexusEmperorSecretAlly, null);
+ nexusEmperorExtraInventory(game);
+}
+function rejected(game: Game, actor: string, action: Action): void {
+ const before = structuredClone(game);
+ assert.throws(() => applyAction(game, actor, action));
+ assert.deepEqual(game, before);
+}
+function allow(game: Game, native: string): Game {
+ assert.equal(game.decision?.kind, 'revivalStop');
+ assert.equal(game.decision!.player, native);
+ return settleNexusEmperorExtra(applyAction(game, native, { type: 'decision', decline: true }));
+}
+
+for (const advanced of [false, true]) {
+ const band = advanced ? 'Advanced' : 'Basic';
+ void test(`${band} genuine Emperor grant returns exactly three without a leader, payment or ordinary quota`, () => {
+ const f = createNexusEmperorExtraFixture({ advanced });
+ const before = player(f.game, f.owner), beforePools = emperorNexusPools(before);
+ const action = grant(f.game, f.owner);
+ rejected(f.game, f.opponent, action);
+ rejected(f.game, f.partner, action);
+ rejected(f.game, f.owner, { type: 'nexusEmperorRevive', event: 'stale', elite: 0 });
+ rejected(f.game, f.owner, { type: 'nexusEmperorRevive', event: f.event, elite: 1 });
+ const returned = settleNexusEmperorExtra(applyAction(f.game, f.owner, action));
+ const holder = player(returned, f.owner), record = receipt(returned, 'returned');
+ assert.equal(record.event, f.event);
+ assert.deepEqual(record.before, beforePools);
+ assert.deepEqual(record.after, { ...beforePools, reserves: before.reserves + 3, tanks: before.tanks - 3 });
+ assert.deepEqual(emperorNexusPools(holder), record.after);
+ assert.deepEqual(holder.leaders, before.leaders);
+ assert.equal(holder.leaderRevived, before.leaderRevived);
+ assert.deepEqual(returned.emperorExtra, f.game.emperorExtra);
+ assert.equal(returned.pendingRevival, null);
+ spent(returned, f.owner);
+ rejected(returned, f.owner, action);
+ });
+
+ void test(`${band} extra-first preserves the full ordinary free/paid quote and real phase continuation`, () => {
+ const f = createNexusEmperorExtraFixture({ advanced });
+ const quote = forceRevivalQuote(f.game, player(f.game, f.owner), 3);
+ const extra = settleNexusEmperorExtra(applyAction(f.game, f.owner, grant(f.game, f.owner)));
+ assert.deepEqual(forceRevivalQuote(extra, player(extra, f.owner), 3), quote);
+ assert.equal(forceRevivalRemaining(extra, player(extra, f.owner)), 3);
+ const ordinary = settleNexusEmperorExtra(applyAction(extra, f.owner, { type: 'revive', amount: 3 }));
+ const holder = player(ordinary, f.owner);
+ assert.equal(holder.reserves, player(f.game, f.owner).reserves + 6);
+ assert.equal(holder.tanks, player(f.game, f.owner).tanks - 6);
+ assert.equal(holder.revived, 3);
+ assert.equal(holder.freeForcesRevived, quote.free);
+ assert.equal(holder.spice, player(extra, f.owner).spice - quote.cost);
+ rejected(ordinary, f.owner, { type: 'revive', amount: 1 });
+ receipt(ordinary, 'returned');
+ const next = advanceNexusEmperorExtra(ordinary, state => state.turn === 3 && state.phase === 4 && nexusEmperorExtraClean(state));
+ assert.equal(player(next, f.owner).tanks, holder.tanks);
+ assert.equal(player(next, f.owner).revived, 0);
+ assert.equal(player(next, f.owner).freeForcesRevived, 0);
+ receipt(next, 'returned');
+ const nextQuote = forceRevivalQuote(next, player(next, f.owner), 2);
+ const later = settleNexusEmperorExtra(applyAction(next, f.owner, { type: 'revive', amount: 2 }));
+ assert.equal(player(later, f.owner).tanks, 0);
+ assert.equal(player(later, f.owner).spice, player(next, f.owner).spice - nextQuote.cost);
+ spent(later, f.owner);
+ });
+
+ void test(`${band} three prior ordinary counters do not reduce the independent printed three grant`, () => {
+ const f = createNexusEmperorExtraFixture({ advanced });
+ const ordinary = settleNexusEmperorExtra(applyAction(f.game, f.owner, { type: 'revive', amount: 3 }));
+ assert.equal(forceRevivalRemaining(ordinary, player(ordinary, f.owner)), 0);
+ const before = emperorNexusPools(player(ordinary, f.owner));
+ const extra = settleNexusEmperorExtra(applyAction(ordinary, f.owner, grant(ordinary, f.owner)));
+ assert.deepEqual(emperorNexusPools(player(extra, f.owner)), { ...before, reserves: before.reserves + 3, tanks: before.tanks - 3 });
+ assert.equal(player(extra, f.owner).revived, 3);
+ assert.deepEqual(extra.emperorExtra, ordinary.emperorExtra);
+ receipt(extra, 'returned');
+ rejected(extra, f.owner, { type: 'revive', amount: 1 });
+ spent(extra, f.owner);
+ });
+
+ void test(`${band} genuine single-E1 Ix counters retain native typed custody and available-count caps`, () => {
+ const f = createNexusEmperorExtraFixture({ advanced, profile: 'ixians' });
+ const ordinary = settleNexusEmperorExtra(applyAction(f.game, f.owner, { type: 'revive', amount: 1, elite: 1 }));
+ const before = player(ordinary, f.owner), beforePools = emperorNexusPools(before);
+ assert.equal(before.elites!.tanks, 2);
+ rejected(ordinary, f.owner, grant(ordinary, f.owner, 3));
+ const extra = settleNexusEmperorExtra(applyAction(ordinary, f.owner, grant(ordinary, f.owner, 2)));
+ assert.deepEqual(emperorNexusPools(player(extra, f.owner)), {
+ ...beforePools, reserves: before.reserves + 3, tanks: before.tanks - 3,
+ eliteReserves: before.elites!.reserves + 2, eliteTanks: 0, eliteRevived: before.elites!.revived + 2,
+ });
+ assert.equal(player(extra, f.owner).revived, 1);
+ assert.equal(player(extra, f.owner).elites!.revived, 3, 'Ix follows its native count cap, not the Advanced Fedaykin cap');
+ assert.equal(forceRevivalRemaining(extra, player(extra, f.owner)), 2);
+ const continued = settleNexusEmperorExtra(applyAction(extra, f.owner, { type: 'revive', amount: 1, elite: 0 }));
+ assert.equal(player(continued, f.owner).revived, 2);
+ assert.equal(player(continued, f.owner).tanks, 3);
+ receipt(continued, 'returned');
+ spent(continued, f.owner);
+ });
+}
+
+void test('native Tleilaxu Allow waits for original physical return, awards free income once, and triggers real Axlotl custody', () => {
+ const f = createNexusEmperorExtraFixture({ advanced: true, profile: 'tleilaxu', tech: true });
+ const native = f.partner, beforePools = emperorNexusPools(player(f.game, f.owner));
+ const nativeSpice = player(f.game, native).spice;
+ const declared = applyAction(f.game, f.owner, grant(f.game, f.owner));
+ const requested = receipt(declared, 'requested');
+ assert.deepEqual(requested.before, beforePools);
+ assert.equal(requested.after, null);
+ assert.deepEqual(emperorNexusPools(player(declared, f.owner)), beforePools);
+ assert.equal(player(declared, native).spice, nativeSpice);
+ assert.deepEqual(declared.techTokens, f.game.techTokens);
+ spent(declared, f.owner);
+ const returned = allow(declared, native);
+ receipt(returned, 'returned');
+ assert.equal(player(returned, f.owner).reserves, beforePools.reserves + 3);
+ assert.equal(player(returned, f.owner).tanks, beforePools.tanks - 3);
+ assert.equal(player(returned, native).spice, nativeSpice + 1);
+ assert.equal(returned.revivalFreeIncome![f.owner], returned.turn);
+ assert.equal(returned.techTokens!.axlotl.triggeredTurn, returned.turn);
+ assert.equal(returned.techTokens!.axlotl.spice, ownedTech(returned.techTokens!, returned.techTokens!.axlotl.owner!).length);
+ const tokens = structuredClone(returned.techTokens);
+ const ordinary = allow(applyAction(returned, f.owner, { type: 'revive', amount: 2 }), native);
+ assert.equal(player(ordinary, native).spice, nativeSpice + 1, 'Later ordinary free returns cannot award a second bank income');
+ assert.deepEqual(ordinary.techTokens, tokens, 'The same physical Axlotl token does not accrue twice this turn');
+ assert.equal(player(ordinary, f.owner).revived, 2);
+ assert.equal(player(ordinary, f.owner).freeForcesRevived, 2);
+ assert.equal(player(ordinary, f.owner).tanks, 3);
+ receipt(ordinary, 'returned');
+ spent(ordinary, f.owner);
+});
+
+void test('native Tleilaxu prior free return earns once; the later extra source does not earn or consume ordinary quota again', () => {
+ const f = createNexusEmperorExtraFixture({ advanced: true, profile: 'tleilaxu' });
+ const ordinary = allow(applyAction(f.game, f.owner, { type: 'revive', amount: 2 }), f.partner);
+ const nativeSpice = player(ordinary, f.partner).spice, before = emperorNexusPools(player(ordinary, f.owner));
+ const declared = applyAction(ordinary, f.owner, grant(ordinary, f.owner));
+ receipt(declared, 'requested');
+ const extra = allow(declared, f.partner);
+ assert.deepEqual(emperorNexusPools(player(extra, f.owner)), { ...before, reserves: before.reserves + 3, tanks: before.tanks - 3 });
+ assert.equal(player(extra, f.partner).spice, nativeSpice);
+ assert.equal(forceRevivalRemaining(extra, player(extra, f.owner)), 1);
+ const price = forceRevivalQuote(extra, player(extra, f.owner), 1);
+ assert.equal(price.free, 0);
+ const paid = allow(applyAction(extra, f.owner, { type: 'revive', amount: 1 }), f.partner);
+ assert.equal(player(paid, f.owner).spice, player(extra, f.owner).spice - price.cost);
+ assert.equal(player(paid, f.partner).spice, nativeSpice + price.cost);
+ assert.equal(player(paid, f.owner).revived, 3);
+ assert.equal(player(paid, f.owner).freeForcesRevived, 2);
+ receipt(paid, 'returned');
+ spent(paid, f.owner);
+});
+
+void test('native Tleilaxu Prevent spends the actual two cards with no Nexus refund, force return, Tech accrual or income', () => {
+ const f = createNexusEmperorExtraFixture({ advanced: true, profile: 'tleilaxu', tech: true });
+ const before = emperorNexusPools(player(f.game, f.owner)), nativeSpice = player(f.game, f.partner).spice;
+ const karama = player(f.game, f.partner).hand.find(card => card.effect === 'karama');
+ assert.ok(karama);
+ const declared = applyAction(f.game, f.owner, grant(f.game, f.owner));
+ receipt(declared, 'requested');
+ const denied = settleNexusEmperorExtra(applyAction(declared, f.partner, { type: 'card', card: karama.id, mode: 'special' }));
+ const record = receipt(denied, 'denied');
+ assert.deepEqual(record.after, before);
+ assert.deepEqual(emperorNexusPools(player(denied, f.owner)), before);
+ assert.equal(player(denied, f.partner).spice, nativeSpice);
+ assert.equal(player(denied, f.partner).specialKaramaUsed, true);
+ assert.equal(denied.discard.filter(card => card.id === karama.id).length, 1);
+ assert.ok(!player(denied, f.partner).hand.some(card => card.id === karama.id));
+ assert.deepEqual(denied.techTokens, f.game.techTokens);
+ assert.deepEqual(denied.revivalFreeIncome, f.game.revivalFreeIncome);
+ assert.deepEqual(denied.emperorExtra, f.game.emperorExtra);
+ assert.equal(denied.pendingRevival, null);
+ assert.equal(denied.decision, null);
+ assert.equal(denied.response, null);
+ assert.ok(revivalPrevented(denied, f.owner));
+ rejected(denied, f.owner, { type: 'revive', amount: 1 });
+ spent(denied, f.owner);
+ const next = advanceNexusEmperorExtra(denied, state => state.turn === 3 && state.phase === 4 && nexusEmperorExtraClean(state));
+ assert.equal(player(next, f.owner).tanks, before.tanks);
+ assert.equal(revivalPrevented(next, f.owner), false);
+ receipt(next, 'denied');
+ spent(next, f.owner);
+});
+
+void test('Basic native Tleilaxu returns the extra source without an Advanced stop but keeps real once-only free income', () => {
+ const f = createNexusEmperorExtraFixture({ advanced: false, profile: 'tleilaxu' });
+ const nativeSpice = player(f.game, f.partner).spice;
+ const declared = applyAction(f.game, f.owner, grant(f.game, f.owner));
+ assert.notEqual(declared.decision?.kind, 'revivalStop');
+ const extra = settleNexusEmperorExtra(declared);
+ receipt(extra, 'returned');
+ assert.equal(player(extra, f.owner).tanks, 5);
+ assert.equal(player(extra, f.owner).revived, 0);
+ assert.equal(player(extra, f.owner).freeForcesRevived, 0);
+ assert.equal(player(extra, f.partner).spice, nativeSpice + 1);
+ const ordinary = settleNexusEmperorExtra(applyAction(extra, f.owner, { type: 'revive', amount: 2 }));
+ assert.equal(player(ordinary, f.partner).spice, nativeSpice + 1);
+ assert.equal(player(ordinary, f.owner).revived, 2);
+ assert.equal(player(ordinary, f.owner).freeForcesRevived, 2);
+ receipt(ordinary, 'returned');
+ spent(ordinary, f.owner);
+});
+
+void test('the saved native stop cannot replay a different or repriced independent source', () => {
+ const f = createNexusEmperorExtraFixture({ advanced: true, profile: 'tleilaxu' });
+ const requested = applyAction(f.game, f.owner, grant(f.game, f.owner));
+ for (const mutate of [
+ (game: Game) => { game.pendingRevival!.amount = 4; },
+ (game: Game) => { game.pendingRevival!.cost = 1; },
+ (game: Game) => { game.pendingRevival!.nexusEmperorEvent = 'different-source'; },
+ (game: Game) => { game.nexusEmperorSecretHistory![0].signature = 'unsigned'; },
+ ]) {
+ const changed = structuredClone(requested);
+ mutate(changed);
+ rejected(changed, f.partner, { type: 'decision', decline: true });
+ }
+ assert.equal(player(requested, f.owner).tanks, 8);
+ receipt(requested, 'requested');
+ spent(requested, f.owner);
+});
+
+void test('original bot policy applies a legal independent Emperor grant on the genuine native chronology', () => {
+ const f = createNexusEmperorExtraFixture({ advanced: true });
+ const view = viewGame(f.game, f.owner);
+ view.players.find(seat => seat.id === f.owner)!.bot = 'Easy';
+ const [action] = botActions(view);
+ assert.ok(action);
+ const returned = settleNexusEmperorExtra(applyAction(f.game, f.owner, action));
+ receipt(returned, 'returned');
+ assert.equal(player(returned, f.owner).tanks, player(f.game, f.owner).tanks - 3);
+ assert.equal(player(returned, f.owner).revived, 0);
+ spent(returned, f.owner);
+});
+
+for (const advanced of [false, true]) {
+ void test(`${advanced ? 'Advanced' : 'Basic'} actual Nexus-returned Fedaykin deploys once through its original Homeworld continuation`, () => {
+ const f = createNexusEmperorExtraFixture({ advanced, profile: 'fremen', homeworlds: true });
+ const before = structuredClone(player(f.game, f.owner));
+ const nativeBefore = player(f.game, f.partner).spice;
+ let game = applyAction(f.game, f.owner, grant(f.game, f.owner, 1));
+ if (game.decision?.kind === 'revivalStop')
+ game = applyAction(game, game.decision.player, { type: 'decision', decline: true });
+ assert.equal(player(game, f.owner).tanks, before.tanks - 3);
+ assert.equal(player(game, f.owner).reserves, before.reserves + 3);
+ assert.equal(player(game, f.owner).elites!.tanks, 0);
+ assert.equal(player(game, f.owner).elites!.revived, 1);
+ assert.equal(player(game, f.owner).revived, 0);
+ assert.equal(player(game, f.owner).freeForcesRevived, 0);
+ receipt(game, 'returned');
+ const view = viewGame(game, f.owner);
+ const target = view.homeworldRevivalDeployment?.destinations.find(option => !option.blocked);
+ assert.ok(target);
+ const action = homeworldRevivalDeploymentChoice(view, target.id).action;
+ assert.ok(action);
+ const originalStars = player(game, f.owner).elites!.forces[target.id] ?? 0;
+ const placed = settleNexusEmperorExtra(applyAction(game, f.owner, action));
+ assert.equal(player(placed, f.owner).elites!.forces[target.id], originalStars + 1);
+ assert.equal(player(placed, f.owner).reserves, before.reserves + 2);
+ assert.equal(player(placed, f.owner).spice, before.spice);
+ assert.equal(player(placed, f.owner).revived, 0);
+ assert.equal(player(placed, f.owner).freeForcesRevived, 0);
+ assert.equal(player(placed, f.partner).spice, nativeBefore + 1);
+ receipt(placed, 'returned');
+ rejected(placed, f.owner, action);
+ spent(placed, f.owner);
+ });
+}
diff --git a/tests/nexus-emperor-extra-source.test.ts b/tests/nexus-emperor-extra-source.test.ts
new file mode 100644
index 00000000..ac018bd9
--- /dev/null
+++ b/tests/nexus-emperor-extra-source.test.ts
@@ -0,0 +1,304 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createGame, newPlayer, type Game } from '../game/engine';
+import { FACTIONS, type FactionId } from '../game/catalog';
+import { createNexusCards, discardNexusCard } from '../game/nexus-cards';
+import { createTechTokens } from '../game/tech-tokens';
+import { createStrongholdCards } from '../game/stronghold-cards';
+import { createLeaderSkills } from '../game/leader-skills';
+import { createHomeworldCustody } from '../game/homeworld-custody';
+import {
+ createEmperorNexusRevival,
+ emperorNexusEvent,
+ emperorNexusModeSupported,
+ emperorNexusPools,
+ emperorNexusSignature,
+ quoteEmperorNexusRevival,
+ settleEmperorNexusRevival,
+ validateEmperorNexusRevival,
+ type NexusEmperorRevival,
+ type NexusEmperorRevivalRequest,
+} from '../game/nexus-emperor-secret-ally';
+import { newRevivalRules, normalRevivalAllowance } from '../game/revival';
+
+/** Focused unit state, not a genuine game trace or runtime admission fixture. */
+function sample(faction: FactionId = 'atreides', advanced = true, nativeTleilaxu = false) {
+ const g = createGame('EXTRASOURCE', newPlayer('owner', 'Holder', faction), advanced);
+ const rival: FactionId = faction === 'atreides' ? 'harkonnen' : 'atreides';
+ g.players.push(newPlayer('rival', 'Rival', rival));
+ g.players.push(newPlayer('third', 'Third', nativeTleilaxu ? 'tleilaxu' : 'guild'));
+ g.expansions = [...new Set(g.players.map(p => FACTIONS.find(f => f.id === p.faction)!.expansion)
+ .filter(expansion => expansion !== 'base'))];
+ g.status = 'playing';
+ g.phase = 4;
+ g.turn = 3;
+ g.revivalRules = newRevivalRules();
+ const p = g.players[0];
+ p.tanks = 6;
+ p.reserves -= 6;
+ if (faction === 'fremen' && advanced)
+ p.elites = { reserves: 1, tanks: 2, forces: {}, revived: 0 };
+ if (faction === 'ixians') {
+ p.elites!.reserves = 4;
+ p.elites!.tanks = 3;
+ }
+ const cards = createNexusCards(g.players, () => 0.5);
+ cards.deck = cards.deck.filter(card => card !== 'emperor');
+ cards.hands.owner = 'emperor';
+ g.nexusCards = { cards, phase: null };
+ return g;
+}
+
+function commit(g: Game, receipt: NexusEmperorRevivalRequest) {
+ g.nexusCards!.cards = discardNexusCard(g.nexusCards!.cards!, receipt.owner, g.players);
+ (g.nexusEmperorSecretHistory ??= []).push(receipt);
+ (g.nexusEmperorSecretEvents ??= []).push(receipt.event);
+}
+
+/** Model the native physical producer before income and optional deployment. */
+function physicalReturn(g: Game, receipt: NexusEmperorRevivalRequest) {
+ const p = g.players.find(p => p.id === receipt.owner)!;
+ p.reserves += 3;
+ p.tanks -= 3;
+ if (p.elites) {
+ p.elites.reserves += receipt.elite;
+ p.elites.tanks -= receipt.elite;
+ p.elites.revived += receipt.elite;
+ }
+}
+
+function signed(receipt: T): T {
+ receipt.signature = emperorNexusSignature(receipt);
+ return receipt;
+}
+
+void test('declaration is a pure signed exact-three source and cannot settle before custody is committed', () => {
+ const g = sample();
+ const before = structuredClone(g);
+ const quote = quoteEmperorNexusRevival(g, 'owner');
+ assert.equal(quote.blocked, null);
+ assert.deepEqual(quote.eliteOptions, [0]);
+ const receipt = createEmperorNexusRevival(g, 'owner', 0);
+ assert.equal(receipt.version, 2);
+ assert.equal(receipt.stage, 'requested');
+ assert.equal(receipt.after, null);
+ assert.equal(receipt.event, quote.event);
+ assert.equal(receipt.sequence, 0);
+ assert.equal(receipt.signature, emperorNexusSignature(receipt));
+ assert.deepEqual(receipt.before, emperorNexusPools(g.players[0]));
+ validateEmperorNexusRevival(g, receipt);
+ assert.deepEqual(g, before);
+ assert.throws(() => settleEmperorNexusRevival(g, receipt, 'returned'));
+ assert.throws(() => createEmperorNexusRevival(g, 'owner', 1));
+ assert.deepEqual(g, before);
+});
+
+void test('native typed Tanks determine exact-three choices and Ix Cyborgs are not capped at one', () => {
+ for (const advanced of [false, true]) {
+ const ix = sample('ixians', advanced);
+ ix.players[0].elites!.revived = 4;
+ assert.equal(emperorNexusModeSupported(ix), false);
+ assert.equal(quoteEmperorNexusRevival(ix, 'owner').blocked, null);
+ assert.deepEqual(quoteEmperorNexusRevival(ix, 'owner').eliteOptions, [0, 1, 2, 3]);
+ assert.equal(createEmperorNexusRevival(ix, 'owner', 3).elite, 3);
+ }
+ const basicFremen = sample('fremen', false);
+ basicFremen.players[0].elites = { reserves: 0, tanks: 3, forces: {}, revived: 2 };
+ assert.deepEqual(quoteEmperorNexusRevival(basicFremen, 'owner').eliteOptions, [0, 1, 2, 3]);
+ assert.equal(createEmperorNexusRevival(basicFremen, 'owner', 3).elite, 3);
+ const fremen = sample('fremen');
+ assert.deepEqual(quoteEmperorNexusRevival(fremen, 'owner').eliteOptions, [0, 1]);
+ fremen.players[0].elites!.revived = 1;
+ assert.deepEqual(quoteEmperorNexusRevival(fremen, 'owner').eliteOptions, [0]);
+ assert.throws(() => createEmperorNexusRevival(fremen, 'owner', 1));
+ fremen.players[0].tanks = 3;
+ assert.deepEqual(quoteEmperorNexusRevival(fremen, 'owner').eliteOptions, []);
+ assert.notEqual(quoteEmperorNexusRevival(fremen, 'owner').blocked, null);
+ const short = sample();
+ short.players[0].tanks = 2;
+ assert.deepEqual(quoteEmperorNexusRevival(short, 'owner').eliteOptions, []);
+ assert.throws(() => createEmperorNexusRevival(short, 'owner', 0));
+});
+
+void test('original native modules allow revival independently of classic purchase admission', () => {
+ for (const faction of ['atreides', 'ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'] as const) {
+ const g = sample(faction);
+ g.techTokens = createTechTokens();
+ g.strongholdCards = createStrongholdCards();
+ assert.equal(quoteEmperorNexusRevival(g, 'owner').blocked, null, faction);
+ const before = structuredClone(g);
+ createEmperorNexusRevival(g, 'owner', 0);
+ assert.deepEqual(g, before);
+ if (faction !== 'atreides') assert.equal(emperorNexusModeSupported(g), false);
+ g.homeworlds = { custody: createHomeworldCustody({
+ advanced: g.advanced,
+ players: g.players.map(p => ({
+ id: p.id, faction: p.faction, reserves: p.reserves,
+ eliteReserves: p.elites?.reserves ?? 0,
+ })),
+ }) };
+ assert.equal(quoteEmperorNexusRevival(g, 'owner').blocked, null, `${faction} with Homeworlds`);
+ g.discoveryEnabled = true;
+ assert.equal(quoteEmperorNexusRevival(g, 'owner').blocked, null, `${faction} with Discovery`);
+ g.leaderSkills = createLeaderSkills(() => 0.5);
+ assert.equal(quoteEmperorNexusRevival(g, 'owner').blocked, null, `${faction} with Skills`);
+ }
+});
+
+void test('ordinary high rates, expanded ceilings, Recruits and ordinary free blocking do not reprice the grant', () => {
+ for (const faction of ['atreides', 'fremen', 'choam'] as const) {
+ for (const blocked of [false, true]) {
+ const g = sample(faction);
+ g.revivalRules!.expanded.push('owner');
+ g.freeRevival.push('owner');
+ g.recruits = { turn: g.turn, player: 'owner', card: 'ecaz-recruits' };
+ if (blocked) g.revivalRules!.freeBlocked = ['owner'];
+ g.players[0].revived = 5;
+ g.players[0].freeForcesRevived = 3;
+ const allowance = normalRevivalAllowance(g, g.players[0]);
+ const rules = structuredClone(g.revivalRules);
+ const receipt = createEmperorNexusRevival(g, 'owner', 0);
+ commit(g, receipt);
+ physicalReturn(g, receipt);
+ const returned = settleEmperorNexusRevival(g, receipt, 'returned');
+ assert.equal(returned.stage, 'returned');
+ assert.deepEqual(returned.after, { ...receipt.before,
+ reserves: receipt.before.reserves + 3, tanks: receipt.before.tanks - 3 });
+ assert.deepEqual(normalRevivalAllowance(g, g.players[0]), allowance);
+ assert.deepEqual(g.revivalRules, rules);
+ }
+ }
+});
+
+void test('native prevention remains distinct from ordinary free blocking and source timing/custody stay strict', () => {
+ const original = sample('atreides', true, true);
+ original.revivalRules!.freeBlocked = ['owner'];
+ assert.equal(quoteEmperorNexusRevival(original, 'owner').blocked, null);
+ original.revivalPrevention = { player: 'owner', turn: original.turn };
+ assert.notEqual(quoteEmperorNexusRevival(original, 'owner').blocked, null);
+ assert.throws(() => createEmperorNexusRevival(original, 'owner', 0));
+ for (const change of [
+ (g: Game) => { g.nexusCards!.cards!.hands.owner = null; },
+ (g: Game) => { g.players[0].ally = 'rival'; },
+ (g: Game) => { g.players.push(newPlayer('emperor', 'Native', 'emperor')); },
+ (g: Game) => { g.phase = 5; },
+ (g: Game) => { g.status = 'setup'; },
+ (g: Game) => { g.pendingCapture = { player: 'owner', loser: 'rival', territory: 'arrakeen' }; },
+ ]) {
+ const g = sample();
+ change(g);
+ assert.notEqual(quoteEmperorNexusRevival(g, 'owner').blocked, null);
+ assert.throws(() => createEmperorNexusRevival(g, 'owner', 0));
+ }
+ assert.notEqual(quoteEmperorNexusRevival(sample(), 'unknown').blocked, null);
+});
+
+void test('returned and denied receipts capture actual typed physical facts without moving or refunding anything', () => {
+ for (const outcome of ['returned', 'denied'] as const) {
+ const g = sample('ixians', true, true);
+ const receipt = createEmperorNexusRevival(g, 'owner', 3);
+ commit(g, receipt);
+ if (outcome === 'returned') physicalReturn(g, receipt);
+ const beforeSettlement = structuredClone(g);
+ const result = settleEmperorNexusRevival(g, receipt, outcome);
+ assert.equal(result.stage, outcome);
+ assert.equal(result.signature, emperorNexusSignature(result));
+ assert.deepEqual(result.after, emperorNexusPools(g.players[0]));
+ assert.deepEqual(g, beforeSettlement);
+ assert.equal(receipt.stage, 'requested');
+ assert.equal(receipt.after, null);
+ assert.equal(g.nexusCards!.cards!.hands.owner, null);
+ assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
+ if (outcome === 'denied') assert.deepEqual(result.after, receipt.before);
+ else assert.deepEqual(result.after, { ...receipt.before,
+ reserves: receipt.before.reserves + 3, tanks: receipt.before.tanks - 3,
+ eliteReserves: receipt.before.eliteReserves + 3, eliteTanks: receipt.before.eliteTanks - 3,
+ eliteRevived: receipt.before.eliteRevived + 3 });
+ assert.throws(() => settleEmperorNexusRevival(g, result, outcome));
+ // Historical evidence survives later ordinary ledger, wallet and custody changes.
+ g.turn++;
+ g.phase = 5;
+ g.players[0].spice += 10;
+ g.players[0].revived += 2;
+ g.players[0].freeForcesRevived = 2;
+ g.players[0].reserves--;
+ validateEmperorNexusRevival(g, result);
+ }
+});
+
+void test('settlement rejects wrong actual amount, typed composition, ordinary ledgers and premature income', () => {
+ for (const change of [
+ (g: Game) => { g.players[0].reserves--; g.players[0].tanks++; },
+ (g: Game) => { g.players[0].elites!.reserves--; g.players[0].elites!.tanks++; },
+ (g: Game) => { g.players[0].elites!.revived--; },
+ (g: Game) => { g.players[0].revived++; },
+ (g: Game) => { g.players[0].freeForcesRevived = 1; },
+ (g: Game) => { g.players[0].spice++; },
+ ]) {
+ const g = sample('ixians');
+ const receipt = createEmperorNexusRevival(g, 'owner', 3);
+ commit(g, receipt);
+ physicalReturn(g, receipt);
+ change(g);
+ const before = structuredClone(g);
+ assert.throws(() => settleEmperorNexusRevival(g, receipt, 'returned'));
+ assert.deepEqual(g, before);
+ }
+ const g = sample();
+ const receipt = createEmperorNexusRevival(g, 'owner', 0);
+ commit(g, receipt);
+ physicalReturn(g, receipt);
+ assert.throws(() => settleEmperorNexusRevival(g, receipt, 'denied'));
+});
+
+void test('receipt stage, signature, independent sequence and event markers cannot be fabricated or silently repaired', () => {
+ const g = sample();
+ const receipt = createEmperorNexusRevival(g, 'owner', 0);
+ commit(g, receipt);
+ for (const change of [
+ (r: NexusEmperorRevivalRequest) => { r.signature = 'unsigned'; },
+ (r: NexusEmperorRevivalRequest) => { r.sequence++; r.signature = emperorNexusSignature(r); },
+ (r: NexusEmperorRevivalRequest) => { r.event = emperorNexusEvent(r.turn, 4, 'rival', r.sequence); r.signature = emperorNexusSignature(r); },
+ ]) {
+ const bad = structuredClone(receipt);
+ change(bad);
+ assert.throws(() => validateEmperorNexusRevival(g, bad));
+ }
+ const withAfter = signed({ ...receipt, after: receipt.before } as unknown as NexusEmperorRevivalRequest);
+ assert.throws(() => validateEmperorNexusRevival(g, withAfter));
+ const finalWithoutAfter = signed({ ...receipt, stage: 'returned', after: null } as unknown as NexusEmperorRevival);
+ assert.throws(() => validateEmperorNexusRevival(g, finalWithoutAfter));
+ for (const change of [
+ (state: Game) => { state.nexusEmperorSecretEvents![0] = 'wrong'; },
+ (state: Game) => { state.nexusEmperorSecretHistory![0] = signed({ ...receipt, before: { ...receipt.before, spice: 999 } }); },
+ (state: Game) => { state.nexusCards!.cards!.discard = []; },
+ ]) {
+ const bad = structuredClone(g);
+ change(bad);
+ assert.throws(() => settleEmperorNexusRevival(bad, receipt, 'denied'));
+ }
+});
+
+void test('strict old physical receipts stay readable as history, never become expansion source permissions', () => {
+ const g = sample();
+ const receipt = createEmperorNexusRevival(g, 'owner', 0);
+ const { version: _version, stage: _stage, ...identity } = receipt;
+ const old: NexusEmperorRevival = signed({ ...identity, after: {
+ ...receipt.before, reserves: receipt.before.reserves + 3, tanks: receipt.before.tanks - 3,
+ } });
+ const before = structuredClone(g);
+ validateEmperorNexusRevival(g, old);
+ assert.deepEqual(g, before);
+ assert.throws(() => settleEmperorNexusRevival(g, old, 'returned'));
+ g.players[0].spice += 5;
+ g.players[0].revived = 3;
+ g.turn++;
+ validateEmperorNexusRevival(g, old);
+ const badLedger = signed({ ...old, after: { ...old.after!, revived: old.before.revived + 3 } });
+ assert.throws(() => validateEmperorNexusRevival(g, badLedger));
+ const expanded = sample('ixians');
+ const fabricated = signed({ ...old, faction: 'ixians' as const });
+ assert.throws(() => validateEmperorNexusRevival(expanded, fabricated));
+ const metadata = signed({ ...old, version: 1 } as unknown as NexusEmperorRevival);
+ assert.throws(() => validateEmperorNexusRevival(g, metadata));
+});
diff --git a/tests/nexus-emperor-secret-ally-controls.test.tsx b/tests/nexus-emperor-secret-ally-controls.test.tsx
deleted file mode 100644
index 119a0d0f..00000000
--- a/tests/nexus-emperor-secret-ally-controls.test.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import test from 'node:test';
-import assert from 'node:assert/strict';
-import { registerHooks } from 'node:module';
-import { createElement } from 'react';
-import { renderToStaticMarkup } from 'react-dom/server';
-import { NexusEmperorSecretAlly } from '../components/nexus-emperor-secret-ally';
-import type { GameView } from '../game/engine';
-
-const aliases = registerHooks({
- resolve(specifier, context, next) {
- return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier, context);
- },
-});
-
-function view(overrides: Record = {}) {
- return {
- me: 'a',
- players: [
- { id: 'a', name: 'House A', faction: 'atreides', ally: null },
- { id: 'b', name: 'House B', faction: 'harkonnen', ally: null },
- ],
- status: 'playing',
- phase: 4,
- response: null,
- truthtrance: null,
- phaseOpening: null,
- automaticContinuationPending: false,
- nexusCards: { card: 'emperor', waiting: [] },
- nexusTraitors: { pending: null },
- nexusEmperorSecretAlly: {
- event: 'emperor-1',
- revival: { blocked: null, eliteOptions: [0, 1] },
- purchase: null,
- },
- ...overrides,
- } as unknown as GameView;
-}
-
-function markup(game: GameView, busy = false) {
- return renderToStaticMarkup(createElement(NexusEmperorSecretAlly, { game, act() {}, busy }));
-}
-
-void test('eligible private Emperor card shows revival explanation and legal elite selector', () => {
- const html = markup(view());
- assert.match(html, /revive 3 additional forces for free/);
- assert.match(html, /Elite forces to revive/);
- assert.match(html, /value="0"/);
- assert.match(html, /value="1"/);
- assert.match(html, /Revive 3 additional forces/);
-});
-
-void test('stale, nonowner, blocked and busy states stay guarded', () => {
- assert.equal(markup(view({ phase: 5 })), '');
- assert.equal(markup(view({ me: 'b', nexusEmperorSecretAlly: null })), '');
- assert.equal(markup(view({ nexusCards: { card: 'emperor', waiting: ['b'] } })), '');
- const blocked = markup(view({ nexusEmperorSecretAlly: {
- event: 'emperor-1', revival: { blocked: 'Elite revival is unavailable.', eliteOptions: [] }, purchase: null,
- } }));
- assert.match(blocked, /Elite revival is unavailable/);
- assert.match(blocked, /disabled=""/);
- assert.match(markup(view({ nexusEmperorSecretAlly: {
- event: 'emperor-1', revival: { blocked: null, eliteOptions: [1] }, purchase: null,
- } }), true), /disabled=""/);
-});
-
-aliases.deregister();
diff --git a/tests/nexus-emperor-secret-ally.test.ts b/tests/nexus-emperor-secret-ally.test.ts
index 73cc19e8..9af0565d 100644
--- a/tests/nexus-emperor-secret-ally.test.ts
+++ b/tests/nexus-emperor-secret-ally.test.ts
@@ -16,7 +16,6 @@ import {
emperorNexusPhysical,
} from './nexus-emperor-secret-ally-fixture';
import { nexusReject, nexusReload, nexusReady } from './fixture-nexus-cards';
-import { nexusTraitorFixture } from './fixture-nexus-traitors';
void test('a later real Ghola return and phase advance preserve historical Nexus proof without replaying its physical grant', () => {
const f = nexusEmperorSecretAllyFixture();
@@ -226,43 +225,6 @@ void test('ordinary Fremen aid to the other allied seat does not suppress this u
assert.deepEqual(done.freeRevival, granted.freeRevival);
});
-void test('combined modules and applicable revival suppression stay explicit, while native and Betrayal modes never expose Secret Ally', () => {
- const f = nexusEmperorSecretAllyFixture();
- for (const damage of [
- (g: Game) => {
- g.expansions = ['ix'];
- },
- (g: Game) => {
- g.discoveryEnabled = true;
- },
- (g: Game) => {
- g.revivalRules!.expanded = [f.owner];
- },
- (g: Game) => {
- g.revivalRules!.freeBlocked = [f.owner];
- },
- (g: Game) => {
- g.revivalPrevention = { player: f.owner, turn: g.turn };
- },
- ]) {
- const g = nexusReload(f.g);
- damage(g);
- nexusReject(g, f.owner, f.action, /Nexus|Discover|revival/);
- }
- for (const native of [false, true]) {
- const g = nexusTraitorFixture({
- ownerFaction: native ? 'emperor' : 'atreides',
- opponentFaction: native ? 'guild' : 'emperor',
- phase: 4,
- });
- const cards = g.nexusCards!.cards!,
- old = cards.hands.p!;
- cards.deck[cards.deck.indexOf('emperor')] = old;
- cards.hands.p = 'emperor';
- assert.equal(viewGame(g, 'p').nexusEmperorSecretAlly, null);
- nexusReject(g, 'p', f.action);
- }
-});
void test('JSON restoration keeps signed source history private and rejects missing markers, duplicate uses and changed physical quotes', () => {
const f = nexusEmperorSecretAllyFixture(),
@@ -286,7 +248,9 @@ void test('JSON restoration keeps signed source history private and rejects miss
g.nexusEmperorSecretEvents!.push(g.nexusEmperorSecretEvents![0]);
},
(g: Game) => {
- g.nexusEmperorSecretHistory![0].after.reserves++;
+ const after = g.nexusEmperorSecretHistory![0].after;
+ assert.ok(after);
+ after.reserves++;
},
(g: Game) => {
const r = g.nexusEmperorSecretHistory![0];
diff --git a/tests/nexus-skills-modules-payments-runtime.test.ts b/tests/nexus-skills-modules-payments-runtime.test.ts
index 69e9978b..ffdbd6a0 100644
--- a/tests/nexus-skills-modules-payments-runtime.test.ts
+++ b/tests/nexus-skills-modules-payments-runtime.test.ts
@@ -87,6 +87,7 @@ for (const config of configurations) {
assert.equal(paid.nexusCards!.cards!.discard.filter(card => card === (kind === 'emperor-revival' ? 'emperor' : 'fremen')).length, 1);
if (kind === 'emperor-revival') {
const receipt = paid.nexusEmperorSecretHistory![0];
+ assert.ok(receipt.after, 'The requested Nexus return must settle before physical receipt consumption.');
assert.equal(receipt.owner, f.actor); assert.equal(receipt.turn, 2); assert.equal(receipt.phase, 4);
assert.equal(receipt.before.tanks - receipt.after.tanks, 3);
assert.equal(receipt.before.spice, receipt.after.spice);