From 8793e23d6bd659733f28179e2393954118f97d93 Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Sun, 18 Oct 2026 10:59:57 +1100
Subject: [PATCH] Compose selected E3 independent cards with Nexus rules
Selected Ecaz/Moritani decks with independent cards and Nexus now retain native Occupy, Advanced assassination, arrival ordering and shared Duke consumers, including original optional modules. Keep the trained E3/Harkonnen capture boundary and remaining individual Nexus effect limits explicit.
Validation: frozen 17-file 168/168, types/lint/build, seven finite original-action and table-SSR programmes, plus four minimal legal Cunning/expiry policies. Fixture corrections preserve the printed alliance draw gate and real paid placement affordability. No stored-game or production change; comprehensive and deployed acceptance remain open.
---
components/game-table.tsx | 2 +-
docs/CURRENT_STATUS.md | 24 ++
docs/IMPLEMENTATION_STATUS.md | 17 ++
docs/NEXUS_CARD_RULES.md | 62 +++++
game/ecaz-duke-acquisition.ts | 4 +-
game/ecaz-duke-revival.ts | 4 +-
game/engine.ts | 9 +-
game/faction-module-profile.ts | 14 +-
game/nexus-ecaz-duke.ts | 3 +
game/nexus-ixian-betrayal.ts | 9 +-
game/reference.ts | 12 +-
tests/e3-independent-nexus-arrival.test.ts | 33 +++
tests/e3-independent-nexus-cards.test.ts | 146 ++++++++++
tests/e3-independent-nexus-duke.test.ts | 114 ++++++++
tests/e3-independent-nexus-occupy.test.ts | 109 ++++++++
tests/fixture-e3-independent-nexus-arrival.ts | 70 +++++
tests/fixture-e3-independent-nexus-cards.ts | 250 +++++++++++++++++
tests/fixture-e3-independent-nexus-duke.ts | 219 +++++++++++++++
tests/fixture-e3-independent-nexus-occupy.ts | 251 ++++++++++++++++++
...elected-independent-ixian-betrayal.test.ts | 22 +-
.../selected-independent-nexus-entry.test.ts | 24 +-
21 files changed, 1374 insertions(+), 24 deletions(-)
create mode 100644 tests/e3-independent-nexus-arrival.test.ts
create mode 100644 tests/e3-independent-nexus-cards.test.ts
create mode 100644 tests/e3-independent-nexus-duke.test.ts
create mode 100644 tests/e3-independent-nexus-occupy.test.ts
create mode 100644 tests/fixture-e3-independent-nexus-arrival.ts
create mode 100644 tests/fixture-e3-independent-nexus-cards.ts
create mode 100644 tests/fixture-e3-independent-nexus-duke.ts
create mode 100644 tests/fixture-e3-independent-nexus-occupy.ts
diff --git a/components/game-table.tsx b/components/game-table.tsx
index ab358658..8691ac4f 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -1293,7 +1293,7 @@ export function GameTable({
{coreNexusPreview && (
Nexus core reactions preview · Basic/Advanced classic factions with base33 or
- independent36, or selected Ixian/CHOAM decks with the independent three-card
+ independent36, or selected expansion decks with the independent three-card
variant, permitting registered classic and selected native seats. Optional all14
Leader Skills and Homeworlds compose.
Own-World Reinforcements fees provisionally spend ordinary non-role counters first,
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index c6296b2f..e69660b5 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -16,6 +16,30 @@ open mode gates.
## Current checkpoint and work
+**Selected E3 independent Nexus — 18 October. Prototyped / Partial.**
+Fresh selected E3, optionally Ix/CHOAM/both, now composes independent cards,
+Nexus and original World/phase/all14 modules. Native Occupy, Advanced
+Assassinate, Ambassador/Terror arrival order and shared-Duke Cunning continue
+through the same public capability. Selected stock36/38/50 and actual cache10
+remain. Existing Advanced actual-E3/Harkonnen/Skills exclusion stays.
+
+Three disjoint helper/fixture lanes plus owner entry/arrival integration are
+frozen. **17 files168/168**, types/lint/build and seven finite original-action
+programmes pass: six-native host entry27; Ecaz/ally coalition leads157/158;
+assassination/replacement/Recruits210; Duke battle142 (separate unused expiry42);
+Basic/Advanced overlapping arrivals68/127 actions. Actual table SSR shows
+lead/plan, assassination, revival, Duke and arrival controls; framework aliases/
+CSS stubs mean no visual/browser acceptance. Four legal policies each complete
+43 owned Cunning/expiry choices; no strength calibration.
+
+Fixture corrections retain the real at-least-one-alliance draw gate and decline
+unwanted paid Ambassador placement before the Duke invasion. Initial lint caught
+a reserved loop binding. No printed numeric rule changed. Bounded shared-profile
+review found no material defect. No saved room/database, reset, restart or
+deployment. **Next required rules:** Ecaz Betrayal and Moritani Atomics envelopes,
+then remaining optional combinations; comprehensive assurance stays deferred.
+[Contract, evidence and boundaries](NEXUS_CARD_RULES.md#selected-e3-independent-nexus--18-october-2026).
+
**Selected-deck independent Nexus — 18 October. Prototyped / Partial.**
Original selected Ixian/CHOAM/combined decks now compose independent three-card
selection and Nexus12, optional all14/World/Tech3+/Advanced Strongholds2+.
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index aa98a767..bad33ee5 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,22 @@
# Dune implementation status
+## 18 October 2026 — Selected E3 independent Nexus
+
+Selected E3, optionally Ix/CHOAM/both, now composes independent cards/Nexus12
+and original all14/World/phase modules. Shared native consumers retain Occupy,
+Advanced Assassinate, Ambassador/Terror queue order, Duke Cunning and selected
+Ixian catalogues; no native-per-selected-family requirement or played-save
+conversion. Existing trained E3/Harkonnen capture boundary remains.
+
+Three exclusive helper/fixture lanes and owner entry/arrival integration pass
+frozen17-file168/168, types/lint/build, seven finite original-action/table-SSR
+programmes and four legal Cunning/expiry policies. Fixtures corrected impossible
+two-seat draw and unwanted optional Ambassador spending; lint corrected a loop
+binding. No printed numerical rule changed. Bounded shared-profile review found
+no material defect. [Source-bound proof and remaining work](NEXUS_CARD_RULES.md#selected-e3-independent-nexus--18-october-2026).
+Ecaz Betrayal, Moritani Atomics and wider envelopes remain required; no saved
+room/reset/restart/deployment or complete-mode assurance claim.
+
## 18 October 2026 — Selected E1/E2 independent Nexus
Selected Ixian/CHOAM/combined38/50 composes independent cards and Nexus12 with
diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md
index 6611c9f4..5ef5b462 100644
--- a/docs/NEXUS_CARD_RULES.md
+++ b/docs/NEXUS_CARD_RULES.md
@@ -2,6 +2,68 @@
Original source audit, 10 September 2026, with dated bounded runtime follow-ups below. This document establishes the printed component inventory, source protocol and implementation boundaries; none lifts expansion release gates. The ordinary worm-triggered alliance Nexus already in the engine is distinct from this optional card module.
+## Selected E3 independent Nexus — 18 October 2026
+
+**Prototyped / Partial.** Fresh selected Ecaz/Moritani
+cards, optionally selected Ixian/CHOAM or both, compose the independent three-card
+variant and Nexus12 with registered classic/selected native seats2–6. Original
+all14 Skills, Homeworlds, Tech3+ and Advanced Strongholds2+ retain their own
+prerequisites. Selecting a deck does not require seating its native faction.
+Ordinary selected stock remains36/38/50, plus an actual Richese cache10.
+The existing Advanced actual-Ecaz/Moritani + Harkonnen + Skills exclusion stays;
+merely selecting unused E3 cards does not introduce that capture/training case.
+Discovery, Semuta and other independent reaction envelopes remain separate.
+
+The shared public capability now continues through native Ecaz Occupy, native
+Advanced Moritani Assassinate, simultaneous Ambassador/Terror arrival, Duke
+acquisition/revival table permission, Ecaz Nexus Cunning and selected native
+Ixian cancellation catalogues. Fresh setup installs the original native powers;
+their own generated stamps remain valid afterward. No played game is reflagged,
+and no new card, force, payment or disc is supplied by the capability check.
+
+Original paid auctions/shipments, actual worm/alliance/closing draws and native
+six-counter starting armies supply the focused programmes. Occupy retains the
+[own-card fee, fixed Ecaz commitment and own-only typed return](ECAZ_OCCUPY_RULES.md#independent-cards-with-combined-armies--2-october-2026)
+contract; no new rounding or fee timing is invented. A real losing Moritani
+plan reaches its held unused opposing Traitor, original bounty/training removal
+and Mentat replacement; Recruits subsequently revives actual physical losses.
+Ecaz Cunning acquires the one shared Duke, then original battle use or unused
+expiry sets him aside without adding a sixth native disc.
+
+Two seats may select the module but cannot earn an unallied Nexus hand while
+retaining the printed at-least-one-alliance requirement. The initial two-seat
+fixture incorrectly waited for an impossible closing draw; repair the fixture,
+not that rule. Separate original no-alliance programmes retain the empty hands.
+
+Evidence paths: `tests/selected-independent-nexus-entry.test.ts`,
+`tests/selected-independent-ixian-betrayal.test.ts`,
+`tests/e3-independent-nexus-occupy.test.ts`,
+`tests/e3-independent-nexus-cards.test.ts`,
+`tests/e3-independent-nexus-duke.test.ts` and
+`tests/e3-independent-nexus-arrival.test.ts`, with their original-action fixtures.
+Ecaz Betrayal and Moritani Atomics remain distinct required effects with
+substantive unimplemented envelopes; this delivery does not certify all Nexus
+effects, complete factions, combinations or final release. Comprehensive
+assurance, full AI and deployed acceptance remain open.
+
+Owner frozen17-file **168/168**, types/lint/build pass. Seven finite original-
+action programmes complete six-native host entry27, Ecaz/ally Occupy leads157/
+158, actual card loss/Assassinate/replacement/Recruits210, Duke battle142 plus
+separate unused expiry42, and Basic/Advanced original overlapping arrival68/
+127 actions. Actual table SSR retains lead/plan, assassination/revival, Duke
+and arrival choices. Next imports are aliased to Vinext and CSS stubbed; this
+is not visual/browser or deployed acceptance. Four legal policies each finish
+43 Cunning/expiry choices, with no tuning/calibration claim.
+
+The initial fixture wrongly assumed a two-seat draw and paid an unwanted
+Ambassador placement before its supported Duke battle, exhausting actual spice.
+Fixtures now retain the printed alliance gate and decline that optional spend.
+Initial lint also caught a reserved loop binding. Earlier diagnostic reports
+remain outside Git; the final source-bound report is the passed four-step
+checkpoint. No engine numeric rule changed for fixtures. Bounded shared-profile
+review found no material defect. No saved room/database, reset, restart or
+deployment was involved.
+
## Selected E1/E2 independent Nexus — 18 October 2026
**Prototyped / Partial.** Fresh selected Ixian, CHOAM or combined decks now
diff --git a/game/ecaz-duke-acquisition.ts b/game/ecaz-duke-acquisition.ts
index e2da5cc7..3694ccbb 100644
--- a/game/ecaz-duke-acquisition.ts
+++ b/game/ecaz-duke-acquisition.ts
@@ -1,7 +1,7 @@
import type { Game } from './engine';
import type { FactionId } from './catalog';
import { acquireDuke, DUKE_VIDAL_ID, type DukeState } from './duke-vidal';
-import type { FactionModuleProfile } from './faction-module-profile';
+import { nexusCoreProfile, type FactionModuleProfile } from './faction-module-profile';
import { mixedE3NexusProfile, pairedE3NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
export type EcazDukeContext = Partial & {
@@ -57,7 +57,7 @@ export function quoteEcazDukeAcquisition(
);
requireDuke(
!(g.advanced && g.players.some((p) => p.faction === 'harkonnen')) ||
- (!!g.expansions && (standaloneE3NexusProfile(g as FactionModuleProfile) ||
+ (!!g.expansions && (nexusCoreProfile(g as FactionModuleProfile) || standaloneE3NexusProfile(g as FactionModuleProfile) ||
pairedE3NexusProfile(g as FactionModuleProfile) || mixedE3NexusProfile(g as FactionModuleProfile))),
tableBlock,
'table',
diff --git a/game/ecaz-duke-revival.ts b/game/ecaz-duke-revival.ts
index 7ea11680..f587416e 100644
--- a/game/ecaz-duke-revival.ts
+++ b/game/ecaz-duke-revival.ts
@@ -1,7 +1,7 @@
import type { Game, Player } from './engine';
import type { Leader } from './cards';
import { DUKE_VIDAL_ID, returnedDukeCapture, type DukeState } from './duke-vidal';
-import type { FactionModuleProfile } from './faction-module-profile';
+import { nexusCoreProfile, type FactionModuleProfile } from './faction-module-profile';
import { mixedE3NexusProfile, pairedE3NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
export const DUKE_REVIVAL_NORMAL_COST = 5 as const;
@@ -85,7 +85,7 @@ export function resolveEcazDukeRevival(
);
requireDuke(
!(g.advanced && g.players.some((p) => p.faction === 'harkonnen')) ||
- (!!g.expansions && (standaloneE3NexusProfile(g as FactionModuleProfile) ||
+ (!!g.expansions && (nexusCoreProfile(g as FactionModuleProfile) || standaloneE3NexusProfile(g as FactionModuleProfile) ||
pairedE3NexusProfile(g as FactionModuleProfile) || mixedE3NexusProfile(g as FactionModuleProfile))),
'table',
);
diff --git a/game/engine.ts b/game/engine.ts
index 421af52b..f3a2d2c4 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -8591,6 +8591,7 @@ function e3StrongholdFactionProfile(g: Game): boolean {
return native !== null && (native !== 'moritani' || !byFaction(g, 'harkonnen'));
}
function moritaniAssassinateModeSupported(g: Game) {
+ if (independentNexusProfile(g)) return g.advanced && !!byFaction(g, 'moritani');
if (mixedE3NexusProfile(g) || mixedE3NexusLeaderSkillsProfile(g) ||
standaloneE3NexusProfile(g) || pairedE3NexusProfile(g) || pairedE3NexusLeaderSkillsProfile(g))
return g.advanced && g.players.some(player => player.faction === 'moritani');
@@ -10607,6 +10608,7 @@ export function initializeFactionExpansionsGameForAudit(state: Game): Game {
return initialized;
}
function ecazOccupyCompositionSupported(g: Game): boolean {
+ if (independentNexusProfile(g) && byFaction(g, 'ecaz')) return true;
if (g.leaderSkills && nativeEcazLeaderSkillsProfile(g)) return true;
if (g.ecazOccupyPreview === true && (mixedE3NexusProfile(g) || standaloneE3NexusProfile(g) || pairedE3NexusProfile(g)) &&
g.players.some(player => player.faction === 'ecaz')) return true;
@@ -10820,9 +10822,9 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
mixedE3 || standaloneE3NexusLeaderSkillsProfile(g) || pairedE3 || guildBetrayalNexusLeaderSkillsProfile(g), false,
classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g) || (g.discoveryEnabled === true && classicNexus), true,
g.expansions.length === 1 && g.expansions[0] === 'choam', nativeExpansionLeaderSkillsProfile(g), independent);
- if (advancedMoritaniLeaderSkillsProfile(initialized) || ((mixedE3 || pairedE3) && initialized.advanced &&
+ if (advancedMoritaniLeaderSkillsProfile(initialized) || ((independentNexusProfile(initialized) || mixedE3 || pairedE3) && initialized.advanced &&
byFaction(initialized, 'moritani'))) initializeMoritaniAssassinateState(initialized);
- if ((nativeEcazLeaderSkillsProfile(initialized) || mixedE3 || homeworlds || nativeDiscoveryLeaderSkillsProfile(initialized) || pairedE3) && byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true;
+ if ((independentNexusProfile(initialized) || nativeEcazLeaderSkillsProfile(initialized) || mixedE3 || homeworlds || nativeDiscoveryLeaderSkillsProfile(initialized) || pairedE3) && byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true;
return initialized;
}
/** Independent cards keep every selected native family's physical counters. */
@@ -16421,6 +16423,7 @@ function ambassadorEffectBlock(
return null;
}
function arrivalOverlapModeSupported(g: Game): boolean {
+ if (independentNexusProfile(g) && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true;
if ((mixedE3NexusLeaderSkillsProfile(g) || mixedE3NexusProfile(g)) &&
byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true;
if (g.ecazTreachery && byFaction(g, 'ecaz') && byFaction(g, 'moritani') &&
@@ -18149,7 +18152,7 @@ function ecazAllianceQuote(g: Game, owner: string, entrant: string) {
}
function ecazAmbassadorLoanBlock(g: Game, owner: Player, entrant: Player) {
if (g.advanced && byFaction(g, 'harkonnen') &&
- !(standaloneE3NexusProfile(g) || pairedE3NexusProfile(g) || mixedE3NexusProfile(g)))
+ !(independentNexusProfile(g) || standaloneE3NexusProfile(g) || pairedE3NexusProfile(g) || mixedE3NexusProfile(g)))
return 'Advanced Duke loans with Harkonnen capture remain guarded in this profile.';
if (owner.ally !== entrant.id || entrant.ally !== owner.id)
return 'The new Ecaz alliance is no longer current.';
diff --git a/game/faction-module-profile.ts b/game/faction-module-profile.ts
index 93188507..313a0fdb 100644
--- a/game/faction-module-profile.ts
+++ b/game/faction-module-profile.ts
@@ -36,7 +36,7 @@ export function classicNexusCoreProfile(game: NexusCoreConfiguration): boolean {
return nexusCoreRosterProfile(game, false);
}
-/** Selected E1/E2 decks with the independent three-card variant. A deck selection
+/** Selected expansion decks with the independent three-card variant. A deck selection
* does not require seating its native faction. Original optional modules compose. */
export function selectedIndependentNexusProfile(game: NexusCoreConfiguration): boolean {
return game.ecazTreachery === true && game.expansions.length > 0 && nexusCoreRosterProfile(game, true);
@@ -53,15 +53,21 @@ function nexusCoreRosterProfile(game: NexusCoreConfiguration, selectedDecks: boo
!players || players.length < (game.techTokens ? 3 : 2) || players.length > 6 ||
(game.strongholdCards && !game.advanced) || game.sandtrout ||
game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker ||
- game.semutaPreview || game.moritaniAssassinatePreview || game.advancedPreview || game.kullPreview || game.nexusKullPreview ||
+ game.semutaPreview || game.advancedPreview || game.kullPreview || game.nexusKullPreview ||
game.richeseBetrayalPreview ||
+ (game.moritaniAssassinatePreview && (!selectedDecks || !game.advanced ||
+ !players.some(player => player.faction === 'moritani'))) ||
(game.nexusIxianBetrayalPreview && (!selectedDecks || !players.some(player => player.faction === 'ixians')))) return false;
if (selectedDecks) {
- if (game.expansions.length > 2) return false;
+ if (game.expansions.length > 3) return false;
for (let i = 0; i < game.expansions.length; i++) {
- if (game.expansions[i] !== 'ix' && game.expansions[i] !== 'choam') return false;
+ if (game.expansions[i] !== 'ix' && game.expansions[i] !== 'choam' && game.expansions[i] !== 'ecaz') return false;
for (let j = 0; j < i; j++) if (game.expansions[j] === game.expansions[i]) return false;
}
+ // Public training and hidden Harkonnen capture still need their own native
+ // E3 custody composition. Selecting unused E3 cards does not add that case.
+ if (game.advanced && game.leaderSkills && players.some(player => player.faction === 'harkonnen') &&
+ players.some(player => player.faction === 'ecaz' || player.faction === 'moritani')) return false;
} else if (game.expansions.length !== 0) return false;
for (let i = 0; i < players.length; i++) {
for (let j = 0; j < i; j++) if (players[j].faction === players[i].faction) return false;
diff --git a/game/nexus-ecaz-duke.ts b/game/nexus-ecaz-duke.ts
index 76fbed35..d5ff621f 100644
--- a/game/nexus-ecaz-duke.ts
+++ b/game/nexus-ecaz-duke.ts
@@ -5,6 +5,7 @@ import { nexusCardMode } from './nexus-cards';
import { nexusCleanPlayBlocked } from './nexus-play-boundary';
import { mixedE3NexusLeaderSkillsProfile, pairedE3NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile } from './leader-skill-profile';
import { mixedE3NexusProfile, pairedE3NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
+import { nexusCoreProfile } from './faction-module-profile';
const SUPPORTED_FACTIONS: Partial> = {
ecaz: true, moritani: true, atreides: true, harkonnen: true,
@@ -157,6 +158,8 @@ export function quoteNexusEcazDuke(g: Game, owner: string, automaticPending = fa
}
export function nexusEcazDukeModeSupported(g: Game): boolean {
+ if (nexusCoreProfile(g) && g.players.some(player => player.faction === 'ecaz'))
+ return !!g.nexusCards?.cards;
if ((mixedE3NexusProfile(g) || standaloneE3NexusProfile(g) || pairedE3NexusProfile(g)) &&
g.players.some(player => player.faction === 'ecaz') &&
!g.sandtrout)
diff --git a/game/nexus-ixian-betrayal.ts b/game/nexus-ixian-betrayal.ts
index 5bafe6c6..57e7b102 100644
--- a/game/nexus-ixian-betrayal.ts
+++ b/game/nexus-ixian-betrayal.ts
@@ -89,11 +89,12 @@ function sameMembers(one: readonly string[], two: readonly string[]): boolean {
}
function selectedExpansions(selection: IxianBetrayalSelection): readonly string[] {
const expansions = selection.expansions ?? ['ix'];
- requireBetrayal(Array.isArray(expansions) && expansions.length >= 1 && expansions.length <= 2 &&
+ requireBetrayal(Array.isArray(expansions) && expansions.length >= 1 && expansions.length <= 3 &&
expansions.includes('ix') && expansions.every((expansion, index) =>
- (expansion === 'ix' || expansion === 'choam') && expansions.indexOf(expansion) === index) &&
+ (expansion === 'ix' || expansion === 'choam' ||
+ (expansion === 'ecaz' && selection.ecazTreachery === true)) && expansions.indexOf(expansion) === index) &&
(selection.ecazTreachery === undefined || typeof selection.ecazTreachery === 'boolean'),
- 'Ixian Betrayal requires an original selected Ixian deck, optionally CHOAM and independent cards.');
+ 'Ixian Betrayal requires an original selected Ixian deck, optionally CHOAM or independent Ecaz cards and factions.');
return expansions;
}
function canonicalCard(value: unknown, canonical: ReadonlyMap): value is Card {
@@ -124,7 +125,7 @@ function validateRoster(players: readonly NexusPlayer[], selection: IxianBetraya
(faction.expansion === 'base' || expansions.includes(faction.expansion)))) &&
players.filter(player => player.faction === 'ixians').length === 1 &&
(selection.richese === undefined || selection.richese === players.some(player => player.faction === 'richese')),
- 'Ixian Betrayal needs its native Ixian and registered classic or selected E1/E2 seats.');
+ 'Ixian Betrayal needs its native Ixian and registered classic or selected family seats.');
}
/** A neutral acknowledgment depends on public held-card presence, never its face. */
diff --git a/game/reference.ts b/game/reference.ts
index e8082105..744b0c0e 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -4682,7 +4682,7 @@ export const RULE_TOPICS: RuleTopic[] = [
summary: 'A separate twelve-card deck rewards remaining unallied. Private draws and selected Atreides, Harkonnen, Tleilaxu, Ixian, Bene Gesserit, Emperor, Fremen, CHOAM, Moritani, Richese and Guild effects are connected. Other effects and complete module play remain in development.',
steps: [
'This optional module can be combined with Basic or Advanced rules independently of the factions and other expansion modules selected. Include one Nexus Card for each of the twelve factions.',
- 'Fresh classic2–6 Nexus core admits base33/independent36. Selected Ixian, CHOAM or combined decks also compose the independent three-card variant with Nexus12, optionally all14, original Homeworlds, Tech3+ and Advanced Strongholds2+. A selected deck need not seat its native faction. Ordinary stock is38/50 plus any native Richese cache10. Actual native Ixians get Betrayal; only their absence permits purchased-card Secret Ally replacement. Signed Richese two-marker shipments and actual Guild fees retain original continuation. Other E3, Discovery/Semuta and reaction envelopes remain separate; this is not complete Nexus acceptance.',
+ 'Fresh classic2–6 Nexus core admits base33/independent36. Any selected E1/E2/E3 decks compose the independent three-card variant with Nexus12, optionally all14, original Homeworlds, Tech3+ and Advanced Strongholds2+. A selected deck need not seat its native faction. Ordinary stock is36/38/50 plus any native Richese cache10. Native Ecaz Occupy, Advanced Moritani Assassinate, Ambassador/Terror arrival and shared-Duke Cunning retain their original physical owners and continuation. Advanced actual E3 with Harkonnen and Skills remains guarded. Native Ixians get Betrayal; only their absence permits purchased-card Secret Ally replacement. Two seats can select Nexus but cannot earn a hand without the printed alliance condition. Ecaz Betrayal, Moritani Atomics, Discovery/Semuta and other reaction envelopes remain separate required work; this is not complete Nexus acceptance.',
'Recruits changes native Revival rates and the ordinary seven ceiling, not the fixed Tleilaxu Secret Ally group or its one-spice price. Five paid card counters may leave two ordinary free returns, using the one leader slot once. Emperor Cunning still makes five ordinary board counters temporarily Sardaukar: independent reserve fees use separate physical reserves, Harass returns actual undialed counters, and native Karama changes only the original Nexus effect. Native winner/inspection and Collection continue without extra cleanup prompts.',
'At the end of the entire Spice Blow and Nexus phase, if a Nexus occurred and at least one alliance exists, each unallied player may draw one card or discard their held card and draw a replacement. You may decline. The two Advanced spice piles do not grant two closing draws.',
'Keep your card secret until it is used. You may hold at most one Nexus Card. It is separate from your Treachery hand and does not occupy a Treachery hand slot.',
@@ -4752,11 +4752,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:"Selected E1/E2 independent/Nexus now shares fresh setup and original native card/reaction consumers, including typed Ix fees/Harass/substitution, CHOAM fuel and Richese paired invoices. Native cross/return permission and rate remain separate; Cunning preserves its opportunity. Independent typed purchases and explicit immune Junction source choice retain their original tariffs/payees. Richese Secret Ally now keeps one billed counter and all five physical forces through native Guild rate allowance/cancellation; signed pending/settled sources stay bound. Unfunded release retains spent Nexus without debt/refund/reusable coupon and preserves ordinary shipment. Marker approvals still cannot silently increase; full acceptance remains open.",evidence:['game/faction-module-profile.ts','tests/selected-independent-nexus-entry.test.ts','tests/fixture-selected-independent-nexus-entry.ts','tests/selected-independent-nexus-battles.test.ts','tests/fixture-selected-independent-nexus-battles.ts','tests/selected-independent-nexus-cards.test.ts','tests/fixture-selected-independent-nexus-cards.ts','tests/selected-independent-ixian-betrayal.test.ts','game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally.ts', 'game/revival-resume.ts', 'game/revival-cancellation.ts', 'game/homeworld-revival-deployment.ts', 'game/homeworld-revival-return.ts', "game/nexus-emperor-purchase.ts", "game/auction-continuation-quote.ts", "game/nexus-emperor-purchase.ts", "game/ambassador-phase.ts", "game/ambassador-resume.ts", "game/battle-aftermath-quote.ts", "game/ecaz-duke-acquisition.ts", "game/ecaz-duke-revival.ts", "game/nexus-ecaz-duke-options.ts", "game/nexus-play-boundary.ts", "game/junction-transport-options.ts"]},
- {area:"Player controls",status:'Partial',detail:"Four current original-action390px journeys prove native Junction5/full2, poor1/full2 release plus ordinary one-force replacement, still-held source sand2 disabled then five-force stronghold1, and independent purchased1. The response explains the spent-card consequence. No overflow, browser error or application exception; two tool-only stale-node errors were resolved by fresh evidence without replay. Earlier Junction/loan/refund surfaces remain scoped evidence.",evidence:['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx','components/nexus-emperor-payment.tsx', 'components/nexus-emperor-secret-ally.tsx', "components/nexus-emperor-seller-purchase.tsx", "components/nexus-emperor-ambassador-purchase.tsx", "components/richese-special-karama.tsx", "components/junction-transport.tsx"]},
- {area:"AI",status:'Partial',detail:"All four original policies execute a legal still-held Richese full-rate one-counter alternative after native cancellation, with actual physical delivery and card cost. Shared human/AI quotes no longer promise a canceled discount. Native permission alternatives, recipient-specific lender prices and immune Junction source choice remain. Optional non-reoffer is not forced. No strategic first-candidate pin, tuning or calibration.",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:"Richese printed one-counter pricing and native Guild eligibility stay distinct from absent-faction cancellation, physical force count and independent purchased price. Unfunded spent-card/no-refund release is selected existing no-debt composition, not a new publisher worked example. Immune Junction and earlier permission/refund/loan interpretations remain explicit. Full rules and public gates stay open.",evidence:['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/GUILD_RATE_KARAMA_RULES.md', 'docs/RICHESE_ALLIED_SHIPMENT_AUDIT.md', 'docs/GUILD_HOMEWORLD_TRANSPORT_RULES.md', 'docs/NEXUS_GUILD_RULES.md', "docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md", "docs/RULE_DECISIONS.md", "docs/RICHESE_AMBASSADOR_RULES.md", "docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md", "docs/RICHESE_ACQUISITION_RULES.md", "docs/DUKE_VIDAL_RULES.md", "docs/ECAZ_DUKE_ACQUISITION.md", "docs/ECAZ_REVIVAL_RULES.md", "docs/JUNCTION_TRANSPORT_RULES.md", "docs/JUNCTION_TRANSPORT_RUNTIME.md"]},
- {area:"Verification",status:'Partial',detail:"Selected independent entry, native battle/card and canonical native-Ix cancellation cases now join original action paths; frozen proof is recorded in the canonical Nexus contract. Classic core retains original World/Tech/Stronghold modules with base33/selected36 and optional14. Independent World cases use native setup, population-sensitive Recruits, typed foreign Harass and actual own-Kaitain Reinforcements/Cunning. The local fee reduces only its paid temporary roles; original count5 and real stars remain. Earlier phase/card proofs are dated in the canonical contract. Native-family/Discovery/Semuta envelopes, full-mode and comprehensive assurance remain open.",evidence:['tests/nexus-world-independent-cards.test.ts','tests/nexus-world-independent-battles.test.ts','tests/reinforcements-temporary-role.test.ts','tests/nexus-core-tech-runtime.test.ts','tests/fixture-nexus-core-tech.ts','tests/nexus-core-strongholds-runtime.test.ts','tests/fixture-nexus-core-strongholds.ts','tests/classic-nexus-core.test.ts','tests/fixture-classic-nexus-core.ts','game/faction-module-profile.ts','tests/classic-independent-nexus-utility.test.ts', 'tests/classic-independent-nexus-battle.test.ts', 'tests/fixture-classic-independent-nexus.ts', '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-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/native-guild-rate-karama.test.ts', 'tests/native-guild-rate-modules.test.ts', 'tests/allied-marker-guild-rate.test.ts', 'tests/purchased-homeworld-rate.test.ts', 'tests/native-guild-transport-permission.test.ts', 'tests/native-guild-homeworld-rate.test.ts', 'tests/native-guild-rate-betrayal.test.ts', 'tests/nexus-guild-cunning-engine.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'tests/nexus-fremen-free-quota.test.ts', 'tests/fixture-nexus-fremen-free-quota.ts', 'tests/nexus-fremen-free-quota-runtime.test.ts', 'tests/nexus-fremen-revival.test.ts','tests/nexus-emperor-payment.test.ts','tests/fixture-nexus-emperor-payment.ts','tests/nexus-emperor-payment-runtime.test.ts','docs/NEXUS_EMPEROR_RULES.md', 'tests/nexus-emperor-extra-source.test.ts', 'tests/nexus-emperor-extra-continuation.test.ts', 'tests/fixture-nexus-emperor-extra.ts', 'tests/nexus-emperor-extra-runtime.test.ts', 'tests/fixture-single-nexus-e1.ts', 'docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md', "tests/fixture-nexus-emperor-seller.ts", "tests/nexus-emperor-seller-purchase.test.ts", "tests/nexus-emperor-seller-runtime.test.ts", "tests/fixture-nexus-emperor-ambassador.ts", "tests/nexus-emperor-ambassador-purchase.test.ts", "tests/nexus-emperor-ambassador-runtime.test.ts", "tests/fixture-nexus-emperor-special.ts", "tests/nexus-emperor-special-purchase.test.ts", "tests/nexus-emperor-special-runtime.test.ts", "tests/karama-intents.test.ts", "tests/fixture-nexus-emperor-bank.ts", "tests/nexus-emperor-bank-profile.test.ts", "tests/nexus-emperor-bank-runtime.test.ts", "tests/duke-captured-custody.test.ts", "tests/nexus-ecaz-duke-captured.test.ts", "tests/fixture-nexus-duke-capture.ts", "tests/nexus-duke-capture-runtime.test.ts", "tests/nexus-duke-timing-runtime.test.ts", "tests/junction-purchased-price.test.ts", "tests/richese-native-guild-rate.test.ts"]},
+ {area:"Implementation",status:'Partial',detail:"Selected E1/E2/E3 independent/Nexus now shares fresh setup and original native consumers: typed Ix fees/Harass/substitution, CHOAM fuel, Richese paired invoices, native Occupy/Assassinate, shared arrival order and Duke Cunning. Existing trained-capture and unfinished individual-effect envelopes remain. Native cross/return permission and rate remain separate; Cunning preserves its opportunity. Independent typed purchases and explicit immune Junction source choice retain their original tariffs/payees. Richese Secret Ally now keeps one billed counter and all five physical forces through native Guild rate allowance/cancellation; signed pending/settled sources stay bound. Unfunded release retains spent Nexus without debt/refund/reusable coupon and preserves ordinary shipment. Marker approvals still cannot silently increase; full acceptance remains open.",evidence:['tests/e3-independent-nexus-occupy.test.ts','tests/fixture-e3-independent-nexus-occupy.ts','tests/e3-independent-nexus-cards.test.ts','tests/fixture-e3-independent-nexus-cards.ts','tests/e3-independent-nexus-duke.test.ts','tests/fixture-e3-independent-nexus-duke.ts','tests/e3-independent-nexus-arrival.test.ts','tests/fixture-e3-independent-nexus-arrival.ts','game/faction-module-profile.ts','tests/selected-independent-nexus-entry.test.ts','tests/fixture-selected-independent-nexus-entry.ts','tests/selected-independent-nexus-battles.test.ts','tests/fixture-selected-independent-nexus-battles.ts','tests/selected-independent-nexus-cards.test.ts','tests/fixture-selected-independent-nexus-cards.ts','tests/selected-independent-ixian-betrayal.test.ts','game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally.ts', 'game/revival-resume.ts', 'game/revival-cancellation.ts', 'game/homeworld-revival-deployment.ts', 'game/homeworld-revival-return.ts', "game/nexus-emperor-purchase.ts", "game/auction-continuation-quote.ts", "game/nexus-emperor-purchase.ts", "game/ambassador-phase.ts", "game/ambassador-resume.ts", "game/battle-aftermath-quote.ts", "game/ecaz-duke-acquisition.ts", "game/ecaz-duke-revival.ts", "game/nexus-ecaz-duke-options.ts", "game/nexus-play-boundary.ts", "game/junction-transport-options.ts"]},
+ {area:"Player controls",status:'Partial',detail:"Selected E3 table SSR retains original lead/plan, assassination/revival, Duke and arrival controls; CSS stubs mean no visual/browser acceptance. Four earlier original-action390px journeys prove native Junction5/full2, poor1/full2 release plus ordinary one-force replacement, still-held source sand2 disabled then five-force stronghold1, and independent purchased1. The response explains the spent-card consequence. No overflow, browser error or application exception; two tool-only stale-node errors were resolved by fresh evidence without replay. Earlier Junction/loan/refund surfaces remain scoped evidence.",evidence:['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx','components/nexus-emperor-payment.tsx', 'components/nexus-emperor-secret-ally.tsx', "components/nexus-emperor-seller-purchase.tsx", "components/nexus-emperor-ambassador-purchase.tsx", "components/richese-special-karama.tsx", "components/junction-transport.tsx"]},
+ {area:"AI",status:'Partial',detail:"All four policies execute original selected E3 Duke Cunning and unused expiry, and legal Occupy plans with real World sources. All four original policies also execute a legal still-held Richese full-rate one-counter alternative after native cancellation, with actual physical delivery and card cost. Shared human/AI quotes no longer promise a canceled discount. Native permission alternatives, recipient-specific lender prices and immune Junction source choice remain. Optional non-reoffer is not forced. No strategic first-candidate pin, tuning or calibration.",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:"Selected E3 scope, printed two-seat no-alliance draw limit and remaining individual-effect envelopes are explicit in the canonical Nexus contract. Richese printed one-counter pricing and native Guild eligibility stay distinct from absent-faction cancellation, physical force count and independent purchased price. Unfunded spent-card/no-refund release is selected existing no-debt composition, not a new publisher worked example. Immune Junction and earlier permission/refund/loan interpretations remain explicit. Full rules and public gates stay open.",evidence:['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/GUILD_RATE_KARAMA_RULES.md', 'docs/RICHESE_ALLIED_SHIPMENT_AUDIT.md', 'docs/GUILD_HOMEWORLD_TRANSPORT_RULES.md', 'docs/NEXUS_GUILD_RULES.md', "docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md", "docs/RULE_DECISIONS.md", "docs/RICHESE_AMBASSADOR_RULES.md", "docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md", "docs/RICHESE_ACQUISITION_RULES.md", "docs/DUKE_VIDAL_RULES.md", "docs/ECAZ_DUKE_ACQUISITION.md", "docs/ECAZ_REVIVAL_RULES.md", "docs/JUNCTION_TRANSPORT_RULES.md", "docs/JUNCTION_TRANSPORT_RUNTIME.md"]},
+ {area:"Verification",status:'Partial',detail:"Selected E3 independent entry, Occupy, Assassinate/Recruits, Duke and arrival paths pass frozen17-file168/168, types/lint/build and seven finite original-action programmes. Earlier selected E1/E2 native card/cancellation proof remains dated in the canonical Nexus contract. Classic core retains original World/Tech/Stronghold modules with base33/selected36 and optional14. Independent World cases use native setup, population-sensitive Recruits, typed foreign Harass and actual own-Kaitain Reinforcements/Cunning. The local fee reduces only its paid temporary roles; original count5 and real stars remain. Earlier phase/card proofs are dated in the canonical contract. Native-family/Discovery/Semuta envelopes, full-mode and comprehensive assurance remain open.",evidence:['tests/nexus-world-independent-cards.test.ts','tests/nexus-world-independent-battles.test.ts','tests/reinforcements-temporary-role.test.ts','tests/nexus-core-tech-runtime.test.ts','tests/fixture-nexus-core-tech.ts','tests/nexus-core-strongholds-runtime.test.ts','tests/fixture-nexus-core-strongholds.ts','tests/classic-nexus-core.test.ts','tests/fixture-classic-nexus-core.ts','game/faction-module-profile.ts','tests/classic-independent-nexus-utility.test.ts', 'tests/classic-independent-nexus-battle.test.ts', 'tests/fixture-classic-independent-nexus.ts', '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-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/native-guild-rate-karama.test.ts', 'tests/native-guild-rate-modules.test.ts', 'tests/allied-marker-guild-rate.test.ts', 'tests/purchased-homeworld-rate.test.ts', 'tests/native-guild-transport-permission.test.ts', 'tests/native-guild-homeworld-rate.test.ts', 'tests/native-guild-rate-betrayal.test.ts', 'tests/nexus-guild-cunning-engine.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'tests/nexus-fremen-free-quota.test.ts', 'tests/fixture-nexus-fremen-free-quota.ts', 'tests/nexus-fremen-free-quota-runtime.test.ts', 'tests/nexus-fremen-revival.test.ts','tests/nexus-emperor-payment.test.ts','tests/fixture-nexus-emperor-payment.ts','tests/nexus-emperor-payment-runtime.test.ts','docs/NEXUS_EMPEROR_RULES.md', 'tests/nexus-emperor-extra-source.test.ts', 'tests/nexus-emperor-extra-continuation.test.ts', 'tests/fixture-nexus-emperor-extra.ts', 'tests/nexus-emperor-extra-runtime.test.ts', 'tests/fixture-single-nexus-e1.ts', 'docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md', "tests/fixture-nexus-emperor-seller.ts", "tests/nexus-emperor-seller-purchase.test.ts", "tests/nexus-emperor-seller-runtime.test.ts", "tests/fixture-nexus-emperor-ambassador.ts", "tests/nexus-emperor-ambassador-purchase.test.ts", "tests/nexus-emperor-ambassador-runtime.test.ts", "tests/fixture-nexus-emperor-special.ts", "tests/nexus-emperor-special-purchase.test.ts", "tests/nexus-emperor-special-runtime.test.ts", "tests/karama-intents.test.ts", "tests/fixture-nexus-emperor-bank.ts", "tests/nexus-emperor-bank-profile.test.ts", "tests/nexus-emperor-bank-runtime.test.ts", "tests/duke-captured-custody.test.ts", "tests/nexus-ecaz-duke-captured.test.ts", "tests/fixture-nexus-duke-capture.ts", "tests/nexus-duke-capture-runtime.test.ts", "tests/nexus-duke-timing-runtime.test.ts", "tests/junction-purchased-price.test.ts", "tests/richese-native-guild-rate.test.ts"]},
],
},
{
diff --git a/tests/e3-independent-nexus-arrival.test.ts b/tests/e3-independent-nexus-arrival.test.ts
new file mode 100644
index 00000000..d6b74b9a
--- /dev/null
+++ b/tests/e3-independent-nexus-arrival.test.ts
@@ -0,0 +1,33 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { viewGame } from '../game/engine';
+import { applyClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus';
+import { createE3IndependentArrival, advanceE3IndependentArrival } from './fixture-e3-independent-nexus-arrival';
+
+for (const modules of [false,true]) void test(`original selected E3 independent Nexus ${modules ? 'all14/World/phase' : 'Basic'} entry preserves both native arrival offers in Storm order`, () => {
+ const fixture = createE3IndependentArrival({advanced:modules, expansions:modules ? ['ecaz','ix','choam'] : ['ecaz'],
+ skills:modules, homeworlds:modules, tech:modules, strongholds:modules});
+ let game = fixture.game;
+ const original = structuredClone(game.pendingArrivalOverlap!);
+ assert.equal(original.entrant, 'guild');
+ assert.equal(original.amount, 2);
+ assert.equal(original.stage, 'first');
+ assert.equal(original.first, game.order.indexOf('ecaz') < game.order.indexOf('moritani') ? 'ambassador' : 'terror');
+ assert.equal(game.players.find(player=>player.id==='guild')!.forces['arrakeen:10'], 2);
+ assert.equal(game.ecazAmbassadors!.tokens.find(token=>token.id===fixture.ambassador)!.location, 'arrakeen');
+ assert.equal(game.moritaniTerror!.tokens.find(token=>token.id===fixture.terror)!.location, 'arrakeen');
+ for (const stage of ['first','second'] as const) {
+ assert.equal(game.pendingArrivalOverlap!.stage, stage);
+ const decision = game.decision!;
+ assert.ok(decision.kind === 'ecazAmbassador' || decision.kind === 'moritaniTerror');
+ assert.ok(viewGame(game, decision.player).arrivalOverlap);
+ game = applyClassicDiscoveryNexusStep(game, {actor:decision.player, action:{type:'decision',
+ ...(decision.kind === 'ecazAmbassador' ? {event:game.pendingAmbassador!.event} : {}), decline:true}}, fixture.actions);
+ }
+ assert.ok(!game.pendingArrivalOverlap && !game.pendingAmbassador && !game.pendingTerrorEntry);
+ game = advanceE3IndependentArrival(game, state=>!state.phaseOpening && !state.response && !state.decision && !state.pendingGuildBetrayal);
+ assert.equal(game.phase, 5);
+ assert.equal(game.players.find(player=>player.id==='guild')!.shipped, true);
+ assert.equal(game.players.find(player=>player.id==='guild')!.forces['arrakeen:10'], 2);
+ assert.equal(game.nexusCards!.cards!.deck.length, 12, 'Without a real alliance there was no invented Nexus draw');
+});
diff --git a/tests/e3-independent-nexus-cards.test.ts b/tests/e3-independent-nexus-cards.test.ts
new file mode 100644
index 00000000..3f2d40d1
--- /dev/null
+++ b/tests/e3-independent-nexus-cards.test.ts
@@ -0,0 +1,146 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, viewGame, type Game } from '../game/engine';
+import { treacheryDeck } from '../game/cards';
+import { ecazTreacheryCards } from '../game/ecaz-cards';
+import { NEXUS_FACTIONS } from '../game/nexus-cards';
+import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
+import { REINFORCEMENTS_CARD } from '../game/reinforcements';
+import { RECRUITS_CARD_ID, recruitsPlayAction } from '../game/recruits';
+import {
+ actE3IndependentNexusCard, advanceE3IndependentNexusCards, chooseE3IndependentAssassinate,
+ createE3IndependentNexusCardBattle, e3IndependentNexusCardPlayer as player,
+ e3IndependentNexusCardsClean as clean, revealE3IndependentNexusCardPlans,
+ type E3IndependentNexusCardOptions,
+} from './fixture-e3-independent-nexus-cards';
+
+/** Printed selected stocks only; final save/seat assurance is a later gate. */
+function originalStocks(game: Game): void {
+ assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
+ ...(game.auction?.cards.slice(game.auction.index + Number(!!game.currentAuctionSale)) ?? [])].map(card => card.id).sort(),
+ [...treacheryDeck(game.expansions), ...ecazTreacheryCards()].map(card => card.id).sort());
+ const cards = game.nexusCards!.cards!;
+ assert.deepEqual([...cards.deck, ...cards.discard, ...Object.values(cards.hands).filter(card => card !== null)].sort(), [...NEXUS_FACTIONS].sort());
+ if (game.leaderSkills) assert.deepEqual([...game.leaderSkills.deck,
+ ...Object.values(game.leaderSkills.offers).flatMap(offer => offer.cards),
+ ...game.leaderSkills.assignments.map(assignment => assignment.skill)].sort(), LEADER_SKILL_CARDS.map(card => card.id).sort());
+}
+function originalLoss(game: Game): void {
+ assert.equal(game.lastBattleContext!.territory, 'pasty_mesa');
+ assert.equal(game.lastBattleContext!.winner, 'guild');
+ assert.equal(game.lastBattleContext!.result, 'normal');
+ assert.equal(player(game, 'moritani').reserves, 11);
+ assert.equal(player(game, 'moritani').tanks, 9, 'The real reserve fee and the six original committed counters reached the Tanks');
+ assert.deepEqual(player(game, 'moritani').forces, {});
+ assert.equal(game.discard.filter(card => card.id === REINFORCEMENTS_CARD).length, 1);
+ assert.ok(player(game, 'moritani').hand.some(card => card.id === RECRUITS_CARD_ID));
+ assert.equal(game.nexusCards!.cards!.hands.moritani, 'tleilaxu');
+}
+function reviveOriginalE3Loss(game: Game): Game {
+ game = advanceE3IndependentNexusCards(game, state => state.turn === 3 && state.phase === 4 && clean(state));
+ const spice = player(game, 'moritani').spice;
+ assert.equal(player(game, 'moritani').tanks, 9);
+ const play = recruitsPlayAction(viewGame(game, 'moritani').recruitsPreview); assert.ok(play);
+ game = actE3IndependentNexusCard(game, 'moritani', play);
+ assert.equal(viewGame(game, 'moritani').revival.freeRemaining, 4);
+ assert.equal(viewGame(game, 'moritani').revival.limit, 7);
+ game = actE3IndependentNexusCard(game, 'moritani', { type: 'revive', amount: 4, elite: 0 });
+ game = advanceE3IndependentNexusCards(game, clean);
+ assert.equal(player(game, 'moritani').reserves, 15);
+ assert.equal(player(game, 'moritani').tanks, 5);
+ assert.equal(player(game, 'moritani').revived, 4);
+ assert.equal(player(game, 'moritani').freeForcesRevived, 4);
+ assert.equal(player(game, 'moritani').spice, spice);
+ assert.equal(game.discard.filter(card => card.id === RECRUITS_CARD_ID).length, 1);
+ assert.equal(game.nexusCards!.cards!.hands.moritani, 'tleilaxu', 'Ordinary Recruits returns do not spend a held Secret Ally');
+ if (game.homeworlds) {
+ const world = viewGame(game, 'moritani').homeworlds!.worlds!.find(world => world.id === 'homeworld:moritani'); assert.ok(world);
+ assert.deepEqual(world.forces.moritani, { normal: 15, elite: 0 });
+ }
+ originalStocks(game);
+ return advanceE3IndependentNexusCards(game, state => state.phase === 5 && clean(state));
+}
+
+const advancedCases: Array = [
+ { label: 'original E3' },
+ { label: 'all14/World/phase', skills: true, homeworlds: true, tech: true, strongholds: true },
+ { label: 'selected E3+Ix+CHOAM all14/World/phase', expansions: ['ecaz', 'ix', 'choam'], skills: true, homeworlds: true, tech: true, strongholds: true },
+];
+for (const sample of advancedCases) void test(`Advanced ${sample.label} native Moritani loss spends genuine Reinforcements, assassinates its original unused Traitor and revives real casualties with Recruits`, () => {
+ const fixture = createE3IndependentNexusCardBattle({ ...sample, advanced: true });
+ assert.equal(fixture.afterSetup.ecazTreachery, true);
+ assert.equal(fixture.afterSetup.moritaniAssassinatePreview, true);
+ assert.equal(fixture.afterSetup.moritaniAssassinate!.owner, 'moritani');
+ assert.equal(fixture.afterSetup.moritaniAssassinate!.normalTraitorCall, false);
+ assert.deepEqual(fixture.afterSetup.moritaniAssassinate!.opportunities, []);
+ assert.deepEqual(player(fixture.afterSetup, 'moritani').traitors, [fixture.target]);
+ assert.deepEqual(player(fixture.afterSetup, 'moritani').forces, { 'pasty_mesa:5': 6 });
+ assert.ok(fixture.actions.some(step => step.actor === 'moritani' && step.action.type === 'traitor' && step.action.leader === fixture.target));
+ assert.equal(fixture.actions.filter(step => step.actor === 'moritani' && step.action.type === 'bid' && step.action.amount === 1).length, 2);
+ assert.ok(player(fixture.afterPurchases, 'moritani').hand.some(card => card.id === REINFORCEMENTS_CARD));
+ assert.ok(player(fixture.afterPurchases, 'moritani').hand.some(card => card.id === RECRUITS_CARD_ID));
+ assert.equal(fixture.draw.before.nexusCards!.cards!.hands.moritani, null);
+ assert.equal(fixture.draw.after.nexusCards!.cards!.hands.moritani, 'tleilaxu');
+ assert.equal(player(fixture.draw.after, 'guild').ally, 'emperor');
+ assert.equal(player(fixture.draw.after, 'emperor').ally, 'guild');
+ assert.equal(player(fixture.beforeMove, 'guild').forces['tueks_sietch:5'], 5);
+ assert.equal(fixture.move.action.type, 'move');
+ assert.equal(fixture.game.battle!.plans.moritani, undefined);
+ assert.equal(viewGame(fixture.game, 'moritani').battle!.reinforcements!.blocked, null);
+ originalStocks(fixture.game);
+
+ let game = revealE3IndependentNexusCardPlans(fixture.game, fixture.plans);
+ assert.equal(game.battle!.plans.moritani.weapon, REINFORCEMENTS_CARD);
+ assert.equal(game.battle!.plans.moritani.leader, 'moritani-4');
+ assert.equal(game.battle!.plans.guild.leader, 'guild-0');
+ game = advanceE3IndependentNexusCards(game, state => !state.phaseOpening && !state.response && state.decision?.kind === 'moritaniAssassinate');
+ originalLoss(game);
+ const choice = viewGame(game, 'moritani').moritaniAssassinate!.pending!;
+ assert.equal(choice.blocked, null);
+ assert.equal(choice.opponent, 'guild');
+ assert.ok(choice.cards.some(card => card.card === fixture.target));
+ assert.equal(choice.cards.some(card => card.card === 'guild-0'), false, 'The committed winning disc stays outside the unused-Traitor choice');
+ const beforeSpice = player(game, 'moritani').spice, target = player(game, 'guild').leaders.find(leader => leader.id === fixture.target)!;
+ const bounty = target.strength;
+ assert.equal(target.dead, false);
+ if (sample.skills) assert.ok(game.leaderSkills!.assignments.some(assignment => assignment.owner === 'guild' && assignment.leader === fixture.target));
+ assert.ok(game.decision?.kind === 'moritaniAssassinate');
+ const event = game.decision.event;
+ assert.throws(() => applyAction(game, 'guild', { type: 'decision', event, card: fixture.target }));
+ game = chooseE3IndependentAssassinate(game, fixture.target);
+ assert.equal(player(game, 'guild').leaders.find(leader => leader.id === fixture.target)!.dead, true);
+ assert.equal(player(game, 'guild').leaders.find(leader => leader.id === 'guild-0')!.dead, false);
+ assert.equal(player(game, 'moritani').spice, beforeSpice + bounty);
+ assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.card, fixture.target);
+ assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.opposingLeader, 'guild-0');
+ assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'revealed');
+ if (sample.skills) {
+ assert.equal(game.leaderSkills!.assignments.some(assignment => assignment.leader === fixture.target), false);
+ assert.equal(game.leaderSkills!.deck.filter(skill => skill === 'killer-medic').length, 1);
+ }
+ game = reviveOriginalE3Loss(game);
+ const receipt = game.moritaniAssassinate!.opportunities.at(-1)!;
+ assert.equal(receipt.stage, 'replaced');
+ assert.ok(receipt.replacement && receipt.replacement !== fixture.target);
+ assert.equal(player(game, 'moritani').traitors.includes(fixture.target), false);
+ assert.ok(player(game, 'moritani').traitors.includes(receipt.replacement));
+ assert.equal(game.moritaniAssassinate!.normalTraitorCall, false);
+ assert.equal(game.turn, 3);
+ originalStocks(game);
+});
+
+void test('Basic all14/World Moritani with independent cards and genuine Nexus draw keeps Assassinate absent while Recruits returns its actual battle losses', () => {
+ const fixture = createE3IndependentNexusCardBattle({ advanced: false, skills: true, homeworlds: true, tech: true });
+ assert.ok(!fixture.afterSetup.moritaniAssassinatePreview && !fixture.afterSetup.moritaniAssassinate);
+ assert.equal(fixture.afterSetup.leaderSkills!.assignments.length, 3);
+ assert.equal(fixture.draw.after.nexusCards!.cards!.hands.moritani, 'tleilaxu');
+ let game = revealE3IndependentNexusCardPlans(fixture.game, fixture.plans);
+ game = advanceE3IndependentNexusCards(game, state => !state.battle && clean(state));
+ originalLoss(game);
+ assert.ok(!game.moritaniAssassinate);
+ assert.equal(player(game, 'guild').leaders.find(leader => leader.id === fixture.target)!.dead, false);
+ assert.deepEqual(player(game, 'moritani').traitors, [fixture.target]);
+ game = reviveOriginalE3Loss(game);
+ assert.equal(game.turn, 3);
+ assert.ok(!game.moritaniAssassinate);
+});
diff --git a/tests/e3-independent-nexus-duke.test.ts b/tests/e3-independent-nexus-duke.test.ts
new file mode 100644
index 00000000..da570e76
--- /dev/null
+++ b/tests/e3-independent-nexus-duke.test.ts
@@ -0,0 +1,114 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, viewGame } from '../game/engine';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import { DUKE_VIDAL_ID } from '../game/duke-vidal';
+import { quoteEcazDukeAcquisition } from '../game/ecaz-duke-acquisition';
+import { nexusEcazDukeModeSupported } from '../game/nexus-ecaz-duke';
+import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options';
+import { initialIxianBetrayalCursor, validateIxianBetrayalHistory } from '../game/nexus-ixian-betrayal';
+import { createIndependentCardModulesFixture } from './fixture-independent-card-modules';
+import {
+ createE3IndependentNexusDuke, createE3IndependentTwoSeatNexus, e3IndependentDukePlayer as player,
+ expireE3IndependentNexusDuke, useE3IndependentNexusDuke,
+} from './fixture-e3-independent-nexus-duke';
+
+for (const advanced of [false, true]) {
+ void test(`${advanced ? 'Advanced all14 World/phase' : 'Basic independent36'} Ecaz draws through a real Nexus, acquires the actual Duke and consumes him in one funded battle`, () => {
+ const f = createE3IndependentNexusDuke({ advanced, homeworlds: advanced, skills: advanced,
+ tech: advanced, strongholds: advanced });
+ assert.equal(f.afterSetup.ecazTreachery, true);
+ assert.equal(f.afterSetup.dukeVidal!.controller, null);
+ assert.equal(f.afterSetup.dukeVidal!.leader.id, DUKE_VIDAL_ID);
+ assert.equal(player(f.alliance, f.owner).ally, null);
+ assert.equal(player(f.alliance, f.others[0]).ally, f.others[1]);
+ assert.equal(player(f.alliance, f.others[1]).ally, f.others[0]);
+ assert.equal(f.beforeDraw.nexusCards!.cards!.hands[f.owner], null);
+ assert.equal(f.afterDraw.nexusCards!.cards!.hands[f.owner], 'ecaz');
+ assert.equal(f.game.nexusCards!.cards!.hands[f.owner], null);
+ assert.equal(f.game.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
+ assert.equal(f.game.dukeVidal!.controller, f.owner);
+ assert.equal(f.game.dukeVidal!.source, 'ecazNexus');
+ assert.equal(f.game.dukeVidal!.acquiredTurn, f.game.turn);
+ assert.deepEqual(f.game.players, f.beforeCunning.players,
+ 'The shared-disc acquisition does not pay spice, move forces or add a sixth native disc.');
+ assert.ok(viewGame(f.game, f.owner).players.find(seat => seat.id === f.owner)!.leaders
+ .some(leader => leader.id === DUKE_VIDAL_ID));
+
+ for (const difficulty of DIFFICULTIES) {
+ const game = structuredClone(f.beforeCunning), own = viewGame(game, f.owner);
+ own.players.find(seat => seat.id === f.owner)!.bot = difficulty;
+ const action = botActions(own).find(candidate => candidate.type === 'nexusEcazDuke'); assert.ok(action, difficulty);
+ assert.equal(applyAction(game, f.owner, action).dukeVidal!.controller, f.owner);
+ }
+
+ const used = useE3IndependentNexusDuke(f);
+ assert.equal(player(used.beforePlans, f.owner).spice, player(used.beforeShipment, f.owner).spice - 1);
+ assert.equal(used.revealed.battle!.plans[f.owner].leader, DUKE_VIDAL_ID);
+ assert.equal(used.game.lastBattleContext!.winner, f.owner);
+ assert.equal(used.game.dukeVidal!.controller, null);
+ assert.equal(used.game.dukeVidal!.source, null);
+ assert.equal(used.game.dukeVidal!.leader.dead, false);
+ assert.equal(used.game.dukeVidal!.leader.usedAt, used.territory);
+ assert.equal(player(used.game, f.owner).tanks, player(used.beforePlans, f.owner).tanks + 1);
+ assert.equal(used.game.nexusEcazDukeHistory!.length, 1);
+ assert.equal(used.game.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
+
+ const unused = expireE3IndependentNexusDuke(f);
+ assert.equal(unused.game.turn, f.game.turn + 1);
+ assert.equal(unused.game.dukeVidal!.controller, null);
+ assert.equal(unused.game.dukeVidal!.source, null);
+ assert.equal(unused.game.dukeVidal!.leader.dead, false);
+ assert.equal(unused.game.dukeVidal!.leader.usedAt, undefined);
+ assert.equal(unused.game.nexusEcazDukeHistory!.length, 1);
+ assert.equal(unused.game.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
+ });
+
+ void test(`${advanced ? 'Advanced' : 'Basic'} two-seat original Nexus setup closes a real worm without inventing an alliance or closing draw`, () => {
+ const f = createE3IndependentTwoSeatNexus(advanced);
+ assert.equal(f.game.players.length, 2);
+ assert.equal(f.opened.nexus, true);
+ assert.ok(f.opened.players.every(seat => seat.ally === null));
+ assert.ok(f.game.players.every(seat => seat.ally === null));
+ assert.equal(f.game.nexus, false);
+ assert.equal(f.game.phase, 2);
+ assert.equal(f.game.nexusCards!.cards!.deck.length, 12);
+ assert.ok(Object.values(f.game.nexusCards!.cards!.hands).every(card => card === null));
+ assert.deepEqual(f.game.nexusCards!.cards, f.afterSetup.nexusCards!.cards);
+ assert.equal(f.actions.some(step => step.action.type === 'nexusCardChoice'), false);
+ assert.equal(nexusEcazDukeModeSupported(f.game), true, 'The selected module still accepts a normal two-seat table.');
+ assert.equal(nexusEcazDukeAction(viewGame(f.game, 'ecaz')), null);
+ assert.equal(f.game.dukeVidal!.controller, null);
+ });
+}
+
+void test('Advanced independent Ecaz/Harkonnen keeps the shared Duke acquisition table permission without Skills', () => {
+ const f = createE3IndependentNexusDuke({ advanced: true, factions: ['ecaz', 'guild', 'harkonnen'],
+ tech: true, homeworlds: true, strongholds: true });
+ const before = structuredClone(f.beforeCunning);
+ const acquisition = quoteEcazDukeAcquisition(f.beforeCunning, f.owner);
+ assert.equal(acquisition.controller, f.owner);
+ assert.deepEqual(f.beforeCunning, before, 'An ordinary acquisition quote does not itself commit an Ambassador or a second Duke.');
+ assert.equal(f.game.dukeVidal!.controller, f.owner);
+ assert.equal(f.game.nexusEcazDukeHistory!.length, 1);
+});
+
+for (const richese of [false, true]) {
+ void test(`original E3/Ix${richese ? '/CHOAM with Richese cache' : ''} stock feeds the native Ixian cancellation catalogue without changing legacy defaults`, () => {
+ const expansions = richese ? ['ecaz', 'ix', 'choam'] : ['ecaz', 'ix'];
+ const f = createIndependentCardModulesFixture({ advanced: false, expansions,
+ factions: richese ? ['ecaz', 'moritani', 'ixians', 'richese'] : ['ecaz', 'moritani', 'ixians', 'guild'],
+ independent: true, nexus: true, homeworlds: true, tech: true });
+ const game = f.game;
+ const physical = [...game.deck, ...game.discard, ...game.players.flatMap(seat => seat.hand),
+ ...(game.ixSetupCards ?? []), ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? [])];
+ assert.equal(physical.length, richese ? 60 : 50);
+ const cursor = initialIxianBetrayalCursor();
+ validateIxianBetrayalHistory([], cursor, physical, { expansions, ecazTreachery: true, richese });
+ assert.throws(() => validateIxianBetrayalHistory([], cursor, physical),
+ 'An original Ix47 caller cannot silently gain the independent cards or an unselected cache.');
+ assert.throws(() => validateIxianBetrayalHistory([], cursor, physical,
+ { expansions, ecazTreachery: false, richese }), 'Selected E3 admission here requires the explicit independent variant.');
+ });
+}
diff --git a/tests/e3-independent-nexus-occupy.test.ts b/tests/e3-independent-nexus-occupy.test.ts
new file mode 100644
index 00000000..74c4013f
--- /dev/null
+++ b/tests/e3-independent-nexus-occupy.test.ts
@@ -0,0 +1,109 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { applyAction, viewGame } from '../game/engine';
+import { botActions } from '../game/bots';
+import { REINFORCEMENTS_CARD } from '../game/reinforcements';
+import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
+import {
+ createE3IndependentNexusOccupyFixture, e3IndependentNexusOccupyClean,
+ e3IndependentNexusOccupyPlayer as player, quoteE3IndependentNexusOccupy,
+ revealE3IndependentNexusOccupy, settleE3IndependentNexusOccupy,
+} from './fixture-e3-independent-nexus-occupy';
+
+for (const modules of [false, true]) for (const lead of ['ecaz', 'ally'] as const) {
+ void test(`original ${modules ? 'all-family World/all14/phase' : 'Ecaz'} independent Nexus ${lead} Occupy lead pays only its own fee and typed undialed return`, () => {
+ const f = createE3IndependentNexusOccupyFixture({ lead,
+ expansions: modules ? ['ix', 'choam', 'ecaz'] : ['ecaz'],
+ homeworlds: modules, skills: modules, tech: modules, strongholds: modules });
+ const beforeEcaz = player(f.game, 'ecaz'), beforeAlly = player(f.game, 'emperor'), beforeEnemy = player(f.game, 'guild');
+ const originalEcaz = Object.fromEntries(Object.entries(player(f.afterSetup, 'ecaz').forces)
+ .filter(([key]) => key.startsWith('imperial_basin:')));
+ assert.equal(Object.values(originalEcaz).reduce((sum, count) => sum + count, 0), 6);
+ assert.equal(f.shipments.length, 3);
+ assert.deepEqual(f.shipments.map(shipment => [shipment.step.actor, shipment.step.action.amount, shipment.step.action.elite])
+ .sort(([one], [two]) => String(one).localeCompare(String(two))),
+ [['ecaz', 5, 0], ['emperor', 5, 2], ['guild', 3, 0]]);
+ assert.equal(f.purchases.length, 2);
+ assert.ok(f.purchases.every(purchase => purchase.step.actor === f.cardUser && purchase.step.action.amount === 1));
+ assert.equal(f.beforeAlliance.turn, 2);
+ assert.equal(f.afterNexusDraw.nexusCards!.cards!.hands.guild, 'guild');
+ assert.equal(f.drawStep.action.type, 'nexusCardChoice');
+ assert.equal(player(f.afterNexusDraw, 'ecaz').ally, 'emperor');
+ assert.equal(player(f.afterNexusDraw, 'emperor').ally, 'ecaz');
+ assert.equal(player(f.afterNexusDraw, 'guild').ally, null);
+ assert.equal(f.beforeLead.decision?.kind, 'ecazBattleLead');
+ assert.equal(f.leadStep.action.lead, f.cardUser);
+ assert.deepEqual([f.profile.planOwner, f.profile.payer, f.profile.forceOwner], [f.cardUser, f.cardUser, 'emperor']);
+ assert.deepEqual([f.profile.ecazForces.normal, f.profile.ecazForces.elite], [5, 0]);
+ assert.deepEqual([f.profile.forces.normal, f.profile.forces.elite], [3, 2]);
+ assert.equal(f.profile.fixedEcazDial, 3);
+ assert.equal(f.plans[0].action.dial, 5);
+ assert.equal(f.support, modules ? 0 : 1, 'Actual high Salusa supports the real stars; ordinary Advanced uses one paid star');
+ assert.ok(player(f.game, f.cardUser).hand.some(card => card.id === REINFORCEMENTS_CARD));
+ assert.ok(player(f.game, f.cardUser).hand.some(card => card.id === HARASS_WITHDRAW_CARD));
+ if (modules) {
+ assert.ok(f.plans[0].action.reinforcementsSources);
+ assert.equal(f.afterFirstMentat.strongholdCards!.owners.arrakeen, 'ecaz');
+ }
+
+ const revealed = revealE3IndependentNexusOccupy(f.game, f.plans, f.actions);
+ const quote = quoteE3IndependentNexusOccupy(revealed, f.cardUser, f.returns);
+ assert.equal(quote.result, 'normal');
+ assert.equal(quote.winner, f.cardUser);
+ assert.deepEqual(quote.reinforcements, [{ player: f.cardUser, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
+ assert.deepEqual(quote.fixedLosses, [{ owner: 'ecaz', normal: 3, elite: 0 }]);
+ assert.equal(quote.casualties!.owner, 'emperor');
+ assert.equal(quote.casualties!.dial, 2, 'The original fixed contribution and score bonus do not enter the variable physical dial');
+ assert.ok(quote.casualties!.options.some(loss => loss.normal === 0 && loss.elite === 1));
+ const withdrawal = quote.harassWithdraw!.find(entry => entry.player === f.cardUser)!;
+ assert.deepEqual(withdrawal.returned, lead === 'ecaz' ? { normal: 2, elite: 0 } : { normal: 3, elite: 1 });
+ assert.deepEqual(withdrawal.locations, f.returns);
+ assert.deepEqual([withdrawal.remaining.normal, withdrawal.remaining.elite], lead === 'ecaz' ? [3, 0] : [0, 1]);
+ const plan = revealed.battle!.plans[f.cardUser];
+ const leader = player(revealed, f.cardUser).leaders.find(leader => leader.id === plan.leader)!;
+ const side = revealed.battle!.attacker === f.cardUser ? 'attacker' : 'defender';
+ assert.equal(quote.scores![side], leader.strength + 5 + 2, 'Reinforcements contributes exactly two normal-score points');
+
+ const game = settleE3IndependentNexusOccupy(revealed, f.returns, f.actions);
+ assert.equal(game.phase, 7);
+ assert.equal(game.battle, null);
+ assert.ok(e3IndependentNexusOccupyClean(game));
+ assert.equal(game.lastBattleContext!.winner, f.cardUser);
+ assert.equal(game.lastBattleContext!.result, 'normal');
+ const ecaz = player(game, 'ecaz'), ally = player(game, 'emperor'), enemy = player(game, 'guild');
+ assert.deepEqual(Object.fromEntries(Object.entries(ecaz.forces).filter(([key]) => key.startsWith('imperial_basin:'))), originalEcaz);
+ assert.equal(ecaz.forces[f.location] ?? 0, lead === 'ecaz' ? 0 : 2);
+ assert.equal(ecaz.reserves, beforeEcaz.reserves + (lead === 'ecaz' ? -1 : 0));
+ assert.equal(ecaz.tanks, beforeEcaz.tanks + (lead === 'ecaz' ? 6 : 3));
+ assert.equal(ally.forces[f.location] ?? 0, lead === 'ecaz' ? 4 : 0);
+ assert.equal(ally.elites!.forces[f.location] ?? 0, lead === 'ecaz' ? 1 : 0);
+ assert.equal(ally.reserves, beforeAlly.reserves + (lead === 'ecaz' ? 0 : 1));
+ assert.equal(ally.elites!.reserves, beforeAlly.elites!.reserves + (lead === 'ecaz' ? 0 : 1));
+ assert.equal(ally.tanks, beforeAlly.tanks + (lead === 'ecaz' ? 1 : 4));
+ assert.equal(ally.elites!.tanks, beforeAlly.elites!.tanks + 1);
+ assert.equal(enemy.forces[f.location] ?? 0, 0);
+ assert.equal(enemy.tanks, beforeEnemy.tanks + 3);
+ assert.equal(enemy.reserves, beforeEnemy.reserves);
+ assert.equal(game.nexusCards!.cards!.hands.guild, 'guild', 'The original unspent closing-draw card remains held');
+ for (const card of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD]) {
+ assert.equal(game.discard.filter(physical => physical.id === card).length, 1);
+ assert.ok(!player(game, f.cardUser).hand.some(physical => physical.id === card));
+ }
+ });
+}
+
+void test('four minimal legal policies use original independent cards through the selected Occupy owner with real World sources', () => {
+ for (const lead of ['ecaz', 'ally'] as const) {
+ const f = createE3IndependentNexusOccupyFixture({ lead, homeworlds: true, expansions: ['ix', 'choam', 'ecaz'] });
+ for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
+ const view = viewGame(f.game, f.cardUser);
+ view.players.find(player => player.id === f.cardUser)!.bot = difficulty;
+ const action = botActions(view).find(action => action.type === 'battlePlan' &&
+ Number(action.dial) > f.profile.fixedEcazDial &&
+ (action.weapon === REINFORCEMENTS_CARD || action.defense === REINFORCEMENTS_CARD));
+ assert.ok(action, `${lead} ${difficulty} must expose its own card with a positive variable commitment`);
+ assert.ok(action.reinforcementsSources, 'The selected payer declares an actual own native reserve source');
+ assert.ok(applyAction(f.game, f.cardUser, action).battle!.plans[f.cardUser]);
+ }
+ }
+});
diff --git a/tests/fixture-e3-independent-nexus-arrival.ts b/tests/fixture-e3-independent-nexus-arrival.ts
new file mode 100644
index 00000000..7f1e7d53
--- /dev/null
+++ b/tests/fixture-e3-independent-nexus-arrival.ts
@@ -0,0 +1,70 @@
+import assert from 'node:assert/strict';
+import { viewGame, type Action, type Game } from '../game/engine';
+import { nativeShipmentSources } from '../game/homeworld-options';
+import { createSelectedIndependentEntry, type SelectedIndependentEntryOptions } from './fixture-selected-independent-nexus-entry';
+import { applyClassicDiscoveryNexusStep, nextClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus';
+
+export type E3IndependentArrivalStep = {actor:string; action:Action};
+export function nextE3IndependentArrivalStep(game: Game): E3IndependentArrivalStep | null {
+ if (game.pendingGuildBetrayal) {
+ const frame = game.pendingGuildBetrayal;
+ const actor = frame.required.find(id => !frame.passed.includes(id)); assert.ok(actor);
+ return {actor, action:{type:'guildBetrayalPass', event:frame.invoice.event}};
+ }
+ if (!game.response && !game.phaseOpening && game.decision &&
+ ['ecazPlacement','moritaniPlacement'].includes(game.decision.kind))
+ return {actor:game.decision.player, action:{type:'decision', decline:true}};
+ const step = nextClassicDiscoveryNexusStep(game);
+ if (step?.action.type === 'stormDial') step.action.amount = game.turn === 1 ? 0 : 1;
+ return step;
+}
+export function advanceE3IndependentArrival(game: Game, until: (game:Game) => boolean,
+ actions: E3IndependentArrivalStep[] = []): Game {
+ for (let count = 0; count < 900; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished');
+ const step = nextE3IndependentArrivalStep(game); assert.ok(step, `Choose the original ${game.decision?.kind} window`);
+ game = applyClassicDiscoveryNexusStep(game, step, actions);
+ }
+ throw Error('Original E3 entry did not reach its requested continuation');
+}
+const clean = (game:Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingGuildBetrayal;
+/** Native tokens are placed through the original paid/response opportunities.
+ * Only unopened land-card positions are chosen; no token, army or wallet is moved by a fixture. */
+export function createE3IndependentArrival(options: Omit) {
+ const source = createSelectedIndependentEntry({...options, factions:['ecaz','moritani','guild']});
+ let game = source.game;
+ const actions = [...source.actions];
+ const act = (actor:string, action:Action) => { game = applyClassicDiscoveryNexusStep(game, {actor,action}, actions); };
+ const reach = (until:(game:Game)=>boolean) => { game = advanceE3IndependentArrival(game, until, actions); };
+ const land = () => {
+ const chosen: Game['spiceDeck'] = [];
+ for (let count = 0; count < (game.advanced ? 2 : 1); count++) {
+ const index = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery);
+ assert.ok(index >= 0); chosen.push(game.spiceDeck.splice(index,1)[0]);
+ }
+ game.spiceDeck.unshift(...chosen);
+ };
+ land();
+ reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
+ const ambassador = game.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz' && token.zone === 'supply');
+ assert.ok(ambassador);
+ const beforePlacement = structuredClone(game);
+ act('ecaz', {type:'decision', token:ambassador.id, territory:'arrakeen'});
+ reach(state => state.ecazAmbassadors!.tokens.some(token => token.id === ambassador.id && token.location === 'arrakeen') &&
+ !state.response && !state.phaseOpening);
+ reach(state => state.phase === 8 && !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniPlacement');
+ const terror = game.moritaniTerror!.tokens.find(token => token.kind === 'robbery' && token.location === null);
+ assert.ok(terror);
+ act('moritani', {type:'decision', token:terror.id, territory:'arrakeen'});
+ reach(state => state.turn === 2 && state.phase === 0);
+ land();
+ reach(state => state.phase === 5 && state.active === 'guild' && clean(state));
+ const homeworldSources = game.homeworlds ? nativeShipmentSources(viewGame(game,'guild'),2,0) : undefined;
+ assert.ok(!game.homeworlds || homeworldSources);
+ const beforeShipment = structuredClone(game);
+ act('guild', {type:'ship', territory:'arrakeen', sector:10, amount:2, elite:0,
+ ...(homeworldSources ? {homeworldSources} : {})});
+ reach(state => !!state.pendingArrivalOverlap && !state.response && !state.phaseOpening);
+ return {source, beforePlacement, beforeShipment, game, actions, ambassador:ambassador.id, terror:terror.id};
+}
diff --git a/tests/fixture-e3-independent-nexus-cards.ts b/tests/fixture-e3-independent-nexus-cards.ts
new file mode 100644
index 00000000..58703a69
--- /dev/null
+++ b/tests/fixture-e3-independent-nexus-cards.ts
@@ -0,0 +1,250 @@
+import assert from 'node:assert/strict';
+import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
+import { leaders, spiceDeck, treacheryDeck } from '../game/cards';
+import { ecazTreacheryCards } from '../game/ecaz-cards';
+import { NEXUS_FACTIONS } from '../game/nexus-cards';
+import { CHEAP_HERO_TRAITOR } from '../game/traitors';
+import { REINFORCEMENTS_CARD } from '../game/reinforcements';
+import { RECRUITS_CARD_ID } from '../game/recruits';
+import { skillOfferRolls, type IndependentCardModulesStep } from './fixture-independent-card-modules';
+import { nextMoritaniNexusStep } from './fixture-e3-nexus-moritani';
+
+export type E3IndependentNexusCardStep = IndependentCardModulesStep;
+export type E3IndependentNexusCardOptions = {
+ advanced?: boolean;
+ skills?: boolean;
+ homeworlds?: boolean;
+ tech?: boolean;
+ strongholds?: boolean;
+ expansions?: Game['expansions'];
+};
+export const e3IndependentNexusCardPlayer = (game: Game, actor: string) => {
+ const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
+};
+export const e3IndependentNexusCardsClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision &&
+ !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingGuildBetrayal &&
+ !game.pendingNexusIxianBetrayal && !game.pendingNexusHarkonnenBetrayal;
+
+function originalLottery(rolls: readonly number[], operation: () => T): T {
+ const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'), native = crypto.getRandomValues.bind(crypto);
+ let cursor = 0;
+ crypto.getRandomValues = (array: V): V => {
+ if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 0xffffffff;
+ else Reflect.apply(native, crypto, [array]);
+ return array;
+ };
+ try { return operation(); }
+ finally {
+ if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
+ else Reflect.deleteProperty(crypto, 'getRandomValues');
+ }
+}
+const unchangedShuffle = (count: number) => Array(count).fill(0xffffffff);
+/** Order only the original undealt Traitor lottery. The card is genuinely
+ * offered and chosen; no played Traitor hand or reserve is exchanged. */
+function originalGuildTraitorRolls(game: Game, target: string): number[] {
+ const source = [...game.players.flatMap(player => leaders(player.faction).map(leader => leader.id)),
+ ...(game.expansions.includes('ix') ? [CHEAP_HERO_TRAITOR] : [])];
+ const desired = [...source], at = desired.indexOf(target); assert.ok(at >= 0);
+ [desired[0], desired[at]] = [desired[at], desired[0]];
+ const working = [...source], rolls: number[] = [];
+ for (let index = working.length - 1; index > 0; index--) {
+ const from = working.indexOf(desired[index]); assert.ok(from >= 0 && from <= index);
+ rolls.push(Math.floor((from + 0.5) * 0x100000000 / (index + 1)));
+ [working[index], working[from]] = [working[from], working[index]];
+ }
+ return rolls;
+}
+export function actE3IndependentNexusCard(game: Game, actor: string, action: Action,
+ actions?: E3IndependentNexusCardStep[]): Game {
+ actions?.push({ actor, action: structuredClone(action) });
+ return originalLottery([], () => applyAction(game, actor, action));
+}
+function nextE3IndependentNexusCard(game: Game): E3IndependentNexusCardStep | null {
+ if (game.pendingGuildBetrayal) {
+ const pending = game.pendingGuildBetrayal, actor = pending.required.find(actor => !pending.passed.includes(actor)); assert.ok(actor);
+ return { actor, action: { type: 'guildBetrayalPass', event: pending.invoice.event } };
+ }
+ if (game.pendingNexusIxianReplacement) return { actor: game.pendingNexusIxianReplacement.buyer,
+ action: { type: 'nexusIxianReplacementPass', event: game.pendingNexusIxianReplacement.event } };
+ if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
+ const decision = game.decision;
+ if (decision?.kind === 'moritaniAssassinate') return null;
+ if (decision?.kind === 'techToken') return { actor: decision.player,
+ action: { type: 'decision', token: decision.choices[0] } };
+ if (decision?.kind === 'moritaniPlacement') return { actor: decision.player,
+ action: { type: 'decision', decline: true } };
+ if (decision?.kind === 'leaderSkillVisibility') return { actor: decision.player,
+ action: { type: 'leaderSkillVisibility', event: decision.event, hide: false } };
+ if (decision?.kind === 'mentatQuestion') return { actor: decision.player,
+ action: { type: 'decision', event: decision.event, decline: true } };
+ if (decision?.kind === 'battleCards') return { actor: decision.player,
+ action: { type: 'decision', discard: [...decision.cards] } };
+ }
+ return nextMoritaniNexusStep(game);
+}
+export function advanceE3IndependentNexusCards(game: Game, until: (state: Game) => boolean,
+ actions: E3IndependentNexusCardStep[] = []): Game {
+ for (let count = 0; count < 2500; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished', 'The original Moritani programme ended before its card control');
+ const step = nextE3IndependentNexusCard(game);
+ assert.ok(step, `Choose the original human ${game.decision?.kind ?? 'Battle Plan'} at ${game.turn}/${game.phase}`);
+ game = actE3IndependentNexusCard(game, step.actor, step.action, actions);
+ }
+ throw Error('The original E3 independent/Nexus programme did not reach its requested boundary');
+}
+function orderOriginalBlow(game: Game, worm = false): void {
+ const front: Game['spiceDeck'] = [];
+ if (worm) {
+ const at = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
+ assert.ok(at >= 0); front.push(game.spiceDeck.splice(at, 1)[0]);
+ }
+ for (let count = 0; count < (game.advanced ? 2 : 1); count++) {
+ const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery &&
+ !['pasty_mesa', 'tueks_sietch'].includes(card.territory));
+ assert.ok(at >= 0); front.push(game.spiceDeck.splice(at, 1)[0]);
+ }
+ game.spiceDeck.unshift(...front);
+}
+
+/** Ordinary fresh lobby selections and original skill/Nexus/card/Traitor
+ * shuffles. The real six-counter Pasty Mesa start remains unchanged. */
+export function createE3IndependentNexusCardSetup(options: E3IndependentNexusCardOptions = {}) {
+ let game = createGame('E3INDEPENDENTNEXUSCARDS', newPlayer('moritani', 'Moritani', 'moritani'),
+ options.advanced ?? true, options.expansions ?? ['ecaz']);
+ joinGame(game, newPlayer('guild', 'Guild', 'guild'));
+ joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
+ const actions: E3IndependentNexusCardStep[] = [], staging: string[] = [];
+ const act = (actor: string, action: Action) => { game = actE3IndependentNexusCard(game, actor, action, actions); };
+ for (const selection of ['ecazTreachery', 'nexusCards', ...(options.skills ? ['leaderSkills'] : [])])
+ act(game.host, { type: 'moduleOption', module: selection, enabled: true });
+ for (const [type, enabled] of [['homeworlds', options.homeworlds], ['techTokens', options.tech], ['strongholdCards', options.strongholds]] as const)
+ if (enabled) act(game.host, { type, enabled: true });
+ for (const player of game.players) act(player.id, { type: 'ready' });
+ const initial = structuredClone(game), target = 'guild-1';
+ const training = { moritani: 'warmaster', guild: 'killer-medic', emperor: 'master-of-assassins' } as const;
+ const rolls = options.skills ? skillOfferRolls(game, training) : [];
+ rolls.push(...unchangedShuffle(NEXUS_FACTIONS.length - 1));
+ rolls.push(...unchangedShuffle(treacheryDeck(game.expansions).length + ecazTreacheryCards().length - 1));
+ rolls.push(...unchangedShuffle(spiceDeck(game.expansions.includes('ix')).length - 1));
+ if (!options.skills) rolls.push(...originalGuildTraitorRolls(game, target));
+ const start: E3IndependentNexusCardStep = { actor: game.host,
+ action: game.advanced ? { type: 'start', advancedPreview: true } : { type: 'start' } };
+ actions.push(structuredClone(start));
+ game = originalLottery(rolls, () => applyAction(game, start.actor, start.action));
+ const offered = structuredClone(game);
+ for (let count = 0; game.status === 'setup' && count < 300; count++) {
+ if (game.setupStage === 'leaderSkills' && !game.decision) {
+ const actor = Object.keys(game.leaderSkills!.offers)[0], own = viewGame(game, actor).leaderSkills!;
+ const skill = training[e3IndependentNexusCardPlayer(game, actor).faction as keyof typeof training];
+ const leader = own.eligibleLeaders.find(leader => leader.id === (actor === 'guild' ? target : `${actor}-0`));
+ assert.ok(leader && own.offer!.cards.includes(skill) && !own.unavailableSkills?.[skill]);
+ const step: E3IndependentNexusCardStep = { actor,
+ action: { type: 'leaderSkill', event: own.offer!.event, skill, leader: leader.id } };
+ actions.push(structuredClone(step));
+ const finalOffer = Object.keys(game.leaderSkills!.offers).length === 1;
+ game = originalLottery(finalOffer ? [...unchangedShuffle(game.leaderSkills!.deck.length), ...originalGuildTraitorRolls(game, target)] : [],
+ () => applyAction(game, step.actor, step.action));
+ continue;
+ }
+ if (game.setupStage === 'traitors' || game.players.some(player => player.traitorChoices.length)) {
+ const player = game.players.find(player => player.traitorChoices.length); assert.ok(player);
+ const card = player.id === 'moritani' ? player.traitorChoices.find(card => card === target) : player.traitorChoices[0];
+ assert.ok(card, 'The original shuffled Traitor offer must contain the selected target');
+ act(player.id, { type: 'traitor', leader: card });
+ continue;
+ }
+ if (game.decision?.kind === 'moritaniSetup') {
+ act(game.decision.player, { type: 'decision', territory: 'pasty_mesa', sector: 5 });
+ continue;
+ }
+ const step = nextE3IndependentNexusCard(game); assert.ok(step); act(step.actor, step.action);
+ }
+ assert.equal(game.status, 'playing');
+ assert.deepEqual(e3IndependentNexusCardPlayer(game, 'moritani').traitors, [target]);
+ assert.deepEqual(e3IndependentNexusCardPlayer(game, 'moritani').forces, { 'pasty_mesa:5': 6 });
+ staging.push('Select original unused skill/Nexus/Treachery/Spice/Traitor lotteries before their real deals; Moritani actually chooses the offered unused Guild Traitor.');
+ return { initial, offered, afterSetup: structuredClone(game), game, actions, staging, target };
+}
+function buyOriginalE3Independent(game: Game, card: string, actions: E3IndependentNexusCardStep[]): Game {
+ for (let count = 0; count < 500; count++) {
+ if (e3IndependentNexusCardPlayer(game, 'moritani').hand.some(held => held.id === card)) return game;
+ let step: E3IndependentNexusCardStep | null;
+ if (e3IndependentNexusCardsClean(game) && game.auction && game.auction.cards[game.auction.index]?.id === card) {
+ const auction = game.auction;
+ step = { actor: auction.active, action: auction.active === 'moritani' && auction.bidder !== 'moritani'
+ ? { type: 'bid', amount: 1, allyPayment: 0 } : { type: 'passBid' } };
+ } else step = nextE3IndependentNexusCard(game);
+ assert.ok(step); game = actE3IndependentNexusCard(game, step.actor, step.action, actions);
+ }
+ throw Error('The original E3 auction did not deliver its physical independent card');
+}
+
+/** Genuine bank purchases and first Mentat, then original worm/alliance/closing
+ * draw. The held Tleilaxu Nexus remains a separate unused physical card. */
+export function createE3IndependentNexusCardProgramme(options: E3IndependentNexusCardOptions = {}) {
+ const source = createE3IndependentNexusCardSetup(options), actions = source.actions;
+ let game = structuredClone(source.game);
+ for (const card of [RECRUITS_CARD_ID, REINFORCEMENTS_CARD]) {
+ const at = game.deck.findIndex(face => face.id === card); assert.ok(at >= 0);
+ game.deck.unshift(game.deck.splice(at, 1)[0]);
+ }
+ orderOriginalBlow(game);
+ game = buyOriginalE3Independent(game, REINFORCEMENTS_CARD, actions);
+ game = buyOriginalE3Independent(game, RECRUITS_CARD_ID, actions);
+ const afterPurchases = structuredClone(game);
+ game = advanceE3IndependentNexusCards(game, state => state.turn === 2 && state.phase === 1 && e3IndependentNexusCardsClean(state), actions);
+ orderOriginalBlow(game, true);
+ game = advanceE3IndependentNexusCards(game, state => state.nexus && !state.spiceWindow && !state.spiceResolution && e3IndependentNexusCardsClean(state), actions);
+ const beforeAlliance = structuredClone(game);
+ for (const [actor, target] of [['guild', 'emperor'], ['emperor', 'guild']])
+ game = actE3IndependentNexusCard(game, actor, { type: 'alliance', target }, actions);
+ game = advanceE3IndependentNexusCards(game, state => state.nexusCards?.phase?.stage === 'drawing' && e3IndependentNexusCardsClean(state), actions);
+ const deck = game.nexusCards!.cards!.deck, at = deck.indexOf('tleilaxu'); assert.ok(at >= 0);
+ deck.unshift(deck.splice(at, 1)[0]);
+ const beforeDraw = structuredClone(game), drawStep: E3IndependentNexusCardStep = { actor: 'moritani',
+ action: { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } };
+ game = actE3IndependentNexusCard(game, drawStep.actor, drawStep.action, actions);
+ const afterDraw = structuredClone(game);
+ game = advanceE3IndependentNexusCards(game, state => state.phase === 5 && state.active === 'guild' && e3IndependentNexusCardsClean(state), actions);
+ source.staging.push('Buy original Reinforcements and Recruits for one spice each, then draw the original unopened Tleilaxu Nexus after an actual worm and reciprocal Guild/Emperor alliance.');
+ return { ...source, game, actions, afterPurchases, beforeAlliance, draw: { before: beforeDraw, step: drawStep, after: afterDraw } };
+}
+/** Actual Guild starting-army movement into Moritani's original six-counter
+ * position. No staged army, invented seal, held-card swap or copied receipt. */
+export function createE3IndependentNexusCardBattle(options: E3IndependentNexusCardOptions = {}) {
+ const source = createE3IndependentNexusCardProgramme(options), actions = source.actions;
+ let game = source.game;
+ const beforeMove = structuredClone(game), move: E3IndependentNexusCardStep = { actor: 'guild',
+ action: { type: 'move', from: 'tueks_sietch:5', amount: 5, territory: 'pasty_mesa', sector: 6 } };
+ game = actE3IndependentNexusCard(game, move.actor, move.action, actions);
+ game = advanceE3IndependentNexusCards(game, state => state.phase === 6 && !!state.active && e3IndependentNexusCardsClean(state), actions);
+ const choice = viewGame(game, game.active!).battleChoices.find(choice => choice.territory === 'pasty_mesa'); assert.ok(choice);
+ const choose: E3IndependentNexusCardStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory: choice.territory,
+ target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
+ game = actE3IndependentNexusCard(game, choose.actor, choose.action, actions);
+ game = advanceE3IndependentNexusCards(game, state => !!state.battle && !state.battle.preparation &&
+ state.battle.preLeader?.closed !== false && e3IndependentNexusCardsClean(state), actions);
+ const sources = game.homeworlds ? viewGame(game, 'moritani').battle!.reinforcements!.homeworldSources?.[0] : undefined;
+ assert.ok(!game.homeworlds || sources);
+ const plans: E3IndependentNexusCardStep[] = [
+ { actor: 'moritani', action: { type: 'battlePlan', dial: 0, support: 0, leader: 'moritani-4',
+ weapon: REINFORCEMENTS_CARD, ...(sources ? { reinforcementsSources: sources } : {}) } },
+ { actor: 'guild', action: { type: 'battlePlan', dial: 1, support: 0, leader: 'guild-0' } },
+ ];
+ return { ...source, game, actions, beforeMove, move, choose, plans };
+}
+export function revealE3IndependentNexusCardPlans(game: Game, plans: readonly E3IndependentNexusCardStep[], actions: E3IndependentNexusCardStep[] = []): Game {
+ for (const plan of plans) {
+ game = actE3IndependentNexusCard(game, plan.actor, plan.action, actions);
+ game = advanceE3IndependentNexusCards(game, state => !!state.battle && !state.battle.preparation &&
+ state.battle.preLeader?.closed !== false && e3IndependentNexusCardsClean(state), actions);
+ }
+ assert.ok(game.battle?.revealed); return game;
+}
+export function chooseE3IndependentAssassinate(game: Game, target: string | null, actions?: E3IndependentNexusCardStep[]): Game {
+ const decision = game.decision; assert.ok(decision?.kind === 'moritaniAssassinate');
+ return actE3IndependentNexusCard(game, decision.player, { type: 'decision', event: decision.event,
+ ...(target === null ? { decline: true } : { card: target }) }, actions);
+}
diff --git a/tests/fixture-e3-independent-nexus-duke.ts b/tests/fixture-e3-independent-nexus-duke.ts
new file mode 100644
index 00000000..e1cba53c
--- /dev/null
+++ b/tests/fixture-e3-independent-nexus-duke.ts
@@ -0,0 +1,219 @@
+import assert from 'node:assert/strict';
+import { applyAction, viewGame, type Action, type Game } from '../game/engine';
+import { territory } from '../game/board';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import { DUKE_VIDAL_ID } from '../game/duke-vidal';
+import { nativeShipmentSources } from '../game/homeworld-options';
+import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options';
+import {
+ createIndependentCardModulesFixture, type IndependentCardModulesOptions,
+ type IndependentCardModulesStep,
+} from './fixture-independent-card-modules';
+import { nextClassicDiscoveryNexusStep, withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
+
+export type E3IndependentDukeOptions = Pick;
+export type E3IndependentDukeStep = IndependentCardModulesStep;
+export const e3IndependentDukePlayer = (game: Game, actor: string) => {
+ const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
+};
+export const e3IndependentDukeClean = (game: Game) => !game.response && !game.decision &&
+ !game.phaseOpening && !game.pendingTreacheryDiscard && !game.truthtrance &&
+ !game.pendingGuildBetrayal && !game.pendingNexusIxianBetrayal && !game.pendingNexusHarkonnenBetrayal;
+
+export function actE3IndependentDuke(game: Game, actor: string, action: Action,
+ actions?: E3IndependentDukeStep[]): Game {
+ actions?.push({ actor, action: structuredClone(action) });
+ return withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
+}
+export function nextE3IndependentDukeStep(game: Game): E3IndependentDukeStep | null {
+ if (game.pendingGuildBetrayal) {
+ const frame = game.pendingGuildBetrayal, actor = frame.required.find(id => !frame.passed.includes(id)); assert.ok(actor);
+ return { actor, action: { type: 'guildBetrayalPass', event: frame.invoice.event } };
+ }
+ if (game.pendingNexusIxianBetrayal && !game.response && !game.phaseOpening) {
+ const frame = game.pendingNexusIxianBetrayal, actor = frame.required.find(id => !frame.passed.includes(id)); assert.ok(actor);
+ return { actor, action: { type: 'nexusIxianBetrayalPass', event: frame.source.event } };
+ }
+ if (game.pendingNexusHarkonnenBetrayal) {
+ const frame = game.pendingNexusHarkonnenBetrayal, actor = frame.required.find(id => !frame.passed.includes(id)); assert.ok(actor);
+ return { actor, action: { type: 'nexusHarkonnenBetrayalPass', event: frame.source.event } };
+ }
+ if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
+ const decision = game.decision;
+ if (decision?.kind === 'nexusIxianReplacement') return { actor: decision.player,
+ action: { type: 'nexusIxianReplacementPass', event: decision.event } };
+ if (decision?.kind === 'homeworldShipmentGuild') return { actor: decision.player,
+ action: { type: 'decision', event: decision.event, allow: true } };
+ if (decision?.kind === 'leaderSkillVisibility') return { actor: decision.player,
+ action: { type: 'leaderSkillVisibility', event: decision.event, hide: false } };
+ if (decision?.kind === 'mentatQuestion' || decision?.kind === 'moritaniSpecialKarama')
+ return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
+ if (decision?.kind === 'moritaniPlacement' || decision?.kind === 'ecazPlacement') return { actor: decision.player,
+ action: { type: 'decision', decline: true } };
+ if (decision?.kind === 'techToken') return { actor: decision.player,
+ action: { type: 'decision', token: decision.choices[0] } };
+ if (decision?.kind === 'advisor' || decision?.kind === 'intrusion' || decision?.kind === 'advisorBattle')
+ return { actor: decision.player, action: { type: 'decision', accept: false } };
+ }
+ return nextClassicDiscoveryNexusStep(game);
+}
+export function advanceE3IndependentDuke(game: Game, until: (state: Game) => boolean,
+ actions: E3IndependentDukeStep[] = []): Game {
+ for (let count = 0; count < 2200; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished', 'Original Duke programme ended before the requested native boundary');
+ const step = nextE3IndependentDukeStep(game);
+ assert.ok(step, `Supply the original human plan at ${game.turn}/${game.phase}/${game.decision?.kind}`);
+ game = actE3IndependentDuke(game, step.actor, step.action, actions);
+ }
+ throw Error('Independent E3 Duke programme did not reach its finite original boundary');
+}
+
+/** Only still-unopened original Spice Cards are ordered; every draw remains native. */
+function orderSpice(game: Game, worm = false): void {
+ const kinds = [...(worm ? ['worm'] : []), ...Array(game.advanced ? 2 : 1).fill('land')];
+ for (const [position, kind] of kinds.entries()) {
+ const index = game.spiceDeck.findIndex((card, at) => at >= position && (kind === 'worm'
+ ? 'worm' in card && !card.greatMaker && !card.suppressed : 'territory' in card && !card.discovery));
+ assert.ok(index >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
+ }
+}
+
+/** Actual setup, first turn, ordinary worm and closing draw. The printed draw
+ * requires an alliance, so this acquisition programme needs at least three
+ * seats: the other two real players ally, leaving Ecaz unallied.
+ * Only unopened original lotteries are selected; dealt hands, wallets, force
+ * locations, controller fields and historical records are never assigned. */
+export function createE3IndependentNexusDuke(options: E3IndependentDukeOptions = {}) {
+ const factions = options.factions ?? ['ecaz', 'guild', 'emperor'];
+ assert.ok(factions.length >= 3, 'A closing draw needs an actual alliance and an unallied recipient.');
+ const source = withClassicDiscoveryNexusLottery(() => createIndependentCardModulesFixture({
+ ...options, factions, advanced: options.advanced ?? true, expansions: options.expansions ?? ['ecaz'],
+ independent: true, nexus: true, tech: options.tech ?? factions.length >= 3,
+ training: { ecaz: 'swordmaster-of-ginaz', guild: 'smuggler', emperor: 'sandmaster',
+ ixians: 'planetologist', richese: 'warmaster', moritani: 'suk-graduate', atreides: 'bureaucrat' },
+ }));
+ const owner = source.game.players.find(player => player.faction === 'ecaz')?.id; assert.ok(owner);
+ const others = source.game.players.filter(player => player.id !== owner).map(player => player.id);
+ const actions = [...source.steps], staging: string[] = [];
+ const afterSetup = structuredClone(source.game);
+ let game = structuredClone(afterSetup);
+ orderSpice(game);
+ staging.push('Order only unopened first-turn land Spice Cards and the original second-turn worm; no played resources or phase clocks change.');
+ game = advanceE3IndependentDuke(game, state => state.turn === 2 && state.phase === 1 && e3IndependentDukeClean(state), actions);
+ const afterFirstTurn = structuredClone(game);
+ orderSpice(game, true);
+ game = advanceE3IndependentDuke(game, state => state.nexus && !state.spiceWindow &&
+ !state.spiceResolution && e3IndependentDukeClean(state), actions);
+ game = actE3IndependentDuke(game, others[0], { type: 'alliance', target: others[1] }, actions);
+ game = actE3IndependentDuke(game, others[1], { type: 'alliance', target: others[0] }, actions);
+ const alliance = structuredClone(game);
+ game = advanceE3IndependentDuke(game, state => state.nexusCards?.phase?.stage === 'drawing' &&
+ e3IndependentDukeClean(state), actions);
+ assert.ok(game.nexusCards!.phase!.eligible.includes(owner));
+ const cards = game.nexusCards!.cards!, at = cards.deck.indexOf('ecaz'); assert.ok(at >= 0);
+ cards.deck.unshift(cards.deck.splice(at, 1)[0]);
+ staging.push('Order the still-undrawn original Ecaz Nexus singleton before the real unallied closing draw.');
+ const beforeDraw = structuredClone(game);
+ const draw: E3IndependentDukeStep = { actor: owner, action: { type: 'nexusCardChoice', turn: game.turn,
+ card: cards.hands[owner], choice: 'draw', ownRedraws: 0 } };
+ game = actE3IndependentDuke(game, draw.actor, draw.action, actions);
+ const afterDraw = structuredClone(game);
+ game = advanceE3IndependentDuke(game, state => state.phase === 2 && e3IndependentDukeClean(state), actions);
+ const beforeCunning = structuredClone(game), action = nexusEcazDukeAction(viewGame(game, owner)); assert.ok(action);
+ const cunning: E3IndependentDukeStep = { actor: owner, action };
+ game = actE3IndependentDuke(game, owner, action, actions);
+ return { initial: source.initial, offered: source.offered, afterSetup, afterFirstTurn,
+ owner, others, actions, staging, alliance, beforeDraw, draw, afterDraw, beforeCunning, cunning, game };
+}
+export type E3IndependentDukeFixture = ReturnType;
+
+/** Two seats may select the module but cannot leave an unallied recipient
+ * after forming an alliance. This actual worm closes without an alliance,
+ * so its twelve original Nexus cards stay undrawn. */
+export function createE3IndependentTwoSeatNexus(advanced = false) {
+ const source = withClassicDiscoveryNexusLottery(() => createIndependentCardModulesFixture({
+ advanced, factions: ['ecaz', 'guild'], expansions: ['ecaz'], independent: true,
+ nexus: true, tech: false,
+ }));
+ const afterSetup = structuredClone(source.game), actions = [...source.steps];
+ let game = structuredClone(afterSetup);
+ orderSpice(game);
+ game = advanceE3IndependentDuke(game, state => state.turn === 2 && state.phase === 1 &&
+ e3IndependentDukeClean(state), actions);
+ orderSpice(game, true);
+ game = advanceE3IndependentDuke(game, state => state.nexus && !state.spiceWindow &&
+ !state.spiceResolution && e3IndependentDukeClean(state), actions);
+ const opened = structuredClone(game);
+ game = advanceE3IndependentDuke(game, state => state.turn === 2 && state.phase === 2 &&
+ !state.nexus && e3IndependentDukeClean(state), actions);
+ return { initial: source.initial, offered: source.offered, afterSetup, opened, game, actions };
+}
+
+/** Use the acquired original shared disc in a genuinely funded invasion of the
+ * Guild's original Tuek garrison; no battle position or defending hand is staged. */
+export function useE3IndependentNexusDuke(fixture: E3IndependentDukeFixture) {
+ const actions: E3IndependentDukeStep[] = [], owner = fixture.owner;
+ let game = structuredClone(fixture.game);
+ const opponent = game.players.find(player => player.faction === 'guild')?.id; assert.ok(opponent);
+ game = advanceE3IndependentDuke(game, state => state.phase === 5 && state.active === owner &&
+ e3IndependentDukeClean(state), actions);
+ const place = territory('tueks_sietch'), sector = place.sectors[0];
+ assert.ok(!place.sectors.includes(game.storm));
+ const beforeShipment = structuredClone(game), sources = game.homeworlds
+ ? nativeShipmentSources(viewGame(game, owner), 1, 0) : undefined;
+ assert.ok(!game.homeworlds || sources);
+ const shipment: E3IndependentDukeStep = { actor: owner, action: { type: 'ship', territory: place.id,
+ sector, amount: 1, elite: 0, allyPayment: 0, ...(sources ? { homeworldSources: sources } : {}) } };
+ game = actE3IndependentDuke(game, owner, shipment.action, actions);
+ game = advanceE3IndependentDuke(game, state => state.phase === 6 && !!state.active &&
+ e3IndependentDukeClean(state), actions);
+ const beforeBattle = structuredClone(game), choice = viewGame(game, game.active!).battleChoices.find(candidate =>
+ candidate.territory === place.id && [candidate.attacker, candidate.defender].includes(owner) &&
+ [candidate.attacker, candidate.defender].includes(opponent)); assert.ok(choice);
+ game = actE3IndependentDuke(game, choice.chooser, { type: 'chooseBattle', territory: place.id,
+ target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }, actions);
+ game = advanceE3IndependentDuke(game, state => !!state.battle && e3IndependentDukeClean(state) &&
+ !state.battle.preparation && state.battle.preLeader?.closed !== false, actions);
+ const beforePlans = structuredClone(game), trained = new Set(game.leaderSkills?.assignments.map(assignment => assignment.leader));
+ const enemy = e3IndependentDukePlayer(game, opponent).leaders.filter(leader => !leader.dead &&
+ !leader.usedAt && !trained.has(leader.id)).sort((a, b) => a.strength - b.strength)[0]; assert.ok(enemy);
+ for (const step of [
+ { actor: owner, action: { type: 'battlePlan', leader: DUKE_VIDAL_ID, dial: 1, support: game.advanced ? 1 : 0 } },
+ { actor: opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 0, support: 0 } },
+ ] satisfies E3IndependentDukeStep[]) {
+ game = actE3IndependentDuke(game, step.actor, step.action, actions);
+ game = advanceE3IndependentDuke(game, state => !!state.battle && e3IndependentDukeClean(state) &&
+ !state.battle.preparation && state.battle.preLeader?.closed !== false, actions);
+ }
+ const revealed = structuredClone(game);
+ assert.equal(revealed.battle!.plans[owner].leader, DUKE_VIDAL_ID);
+ game = advanceE3IndependentDuke(game, state => !state.battle && e3IndependentDukeClean(state), actions);
+ return { game, actions, opponent, territory: place.id, beforeShipment, shipment, beforeBattle, beforePlans, revealed };
+}
+
+export function expireE3IndependentNexusDuke(fixture: E3IndependentDukeFixture) {
+ const actions: E3IndependentDukeStep[] = [];
+ const game = advanceE3IndependentDuke(structuredClone(fixture.game), state =>
+ state.turn > fixture.game.turn && state.phase === 0 && e3IndependentDukeClean(state), actions);
+ return { game, actions };
+}
+
+/** Callable finite legality smoke for owner verification; this is not AI calibration. */
+export function smokeE3IndependentNexusDuke(options: E3IndependentDukeOptions = {}) {
+ const fixture = createE3IndependentNexusDuke(options);
+ return DIFFICULTIES.map(difficulty => {
+ const game = structuredClone(fixture.beforeCunning), view = viewGame(game, fixture.owner);
+ view.players.find(player => player.id === fixture.owner)!.bot = difficulty;
+ const action = botActions(view).find(candidate => candidate.type === 'nexusEcazDuke'); assert.ok(action, difficulty);
+ const actions: E3IndependentDukeStep[] = [];
+ const acquired = actE3IndependentDuke(game, fixture.owner, action, actions);
+ const expired = advanceE3IndependentDuke(acquired, state => state.turn > game.turn &&
+ state.phase === 0 && e3IndependentDukeClean(state), actions);
+ assert.equal(expired.dukeVidal!.controller, null);
+ return { difficulty, actions: actions.length, turn: expired.turn, phase: expired.phase,
+ discarded: expired.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length };
+ });
+}
diff --git a/tests/fixture-e3-independent-nexus-occupy.ts b/tests/fixture-e3-independent-nexus-occupy.ts
new file mode 100644
index 00000000..d575b684
--- /dev/null
+++ b/tests/fixture-e3-independent-nexus-occupy.ts
@@ -0,0 +1,251 @@
+import assert from 'node:assert/strict';
+import { viewGame, type Action, type Game } from '../game/engine';
+import { territory } from '../game/board';
+import { nativeShipmentSources } from '../game/homeworld-options';
+import { REINFORCEMENTS_CARD } from '../game/reinforcements';
+import { HARASS_WITHDRAW_CARD, type HarassWithdrawSelection } from '../game/harass-withdraw';
+import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
+import {
+ createIndependentCardModulesFixture, type IndependentCardModulesOptions,
+ type IndependentCardModulesStep,
+} from './fixture-independent-card-modules';
+import {
+ actSelectedIndependentNexusBattle, selectedIndependentNexusClean,
+} from './fixture-selected-independent-nexus-battles';
+import { nextClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus';
+
+export type E3IndependentNexusOccupyStep = IndependentCardModulesStep;
+export type E3IndependentNexusOccupyOptions = Pick & { lead?: 'ecaz' | 'ally' };
+export const e3IndependentNexusOccupyClean = selectedIndependentNexusClean;
+export const e3IndependentNexusOccupyPlayer = (game: Game, actor: string) => {
+ const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
+};
+
+/** Quiet printed optional placement and original reaction windows; the actual
+ * coalition owner still chooses its lead, sealed plan and typed withdrawal. */
+export function nextE3IndependentNexusOccupyStep(game: Game): E3IndependentNexusOccupyStep | null {
+ if (game.pendingGuildBetrayal) {
+ const frame = game.pendingGuildBetrayal;
+ const actor = frame.required.find(id => !frame.passed.includes(id)); assert.ok(actor);
+ return { actor, action: { type: 'guildBetrayalPass', event: frame.invoice.event } };
+ }
+ if (!game.response && !game.phaseOpening) {
+ if (game.pendingNexusIxianBetrayal) {
+ const frame = game.pendingNexusIxianBetrayal;
+ const actor = frame.required.find(id => !frame.passed.includes(id)); assert.ok(actor);
+ return { actor, action: { type: 'nexusIxianBetrayalPass', event: frame.source.event } };
+ }
+ const decision = game.decision;
+ if (decision?.kind === 'ecazPlacement' || decision?.kind === 'moritaniPlacement')
+ return { actor: decision.player, action: { type: 'decision', decline: true } };
+ if (decision && ['ecazBattleLead', 'harassWithdraw', 'battleLosses'].includes(decision.kind)) return null;
+ }
+ return nextClassicDiscoveryNexusStep(game);
+}
+export function actE3IndependentNexusOccupy(game: Game, step: E3IndependentNexusOccupyStep,
+ actions?: E3IndependentNexusOccupyStep[]): Game {
+ return actSelectedIndependentNexusBattle(game, step, actions);
+}
+export function advanceE3IndependentNexusOccupy(game: Game, until: (game: Game) => boolean,
+ actions: E3IndependentNexusOccupyStep[] = []): Game {
+ for (let count = 0; count < 2200; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished', 'Original Occupy game ended before its requested card window');
+ const step = nextE3IndependentNexusOccupyStep(game);
+ assert.ok(step, `Choose the real plan/allocation at ${game.turn}/${game.phase}/${game.decision?.kind}`);
+ game = actE3IndependentNexusOccupy(game, step, actions);
+ }
+ throw Error('Original independent Nexus Occupy programme did not reach its boundary');
+}
+
+/** Select an unopened singleton, then buy it with the real native auction.
+ * The roster has no Ixian provider; selected family cards alone create no pool. */
+function buyCard(game: Game, owner: string, desired: string, actions: E3IndependentNexusOccupyStep[], staging: string[]) {
+ assert.equal(game.phase, 2);
+ const at = game.deck.findIndex(card => card.id === desired); assert.ok(at >= 0);
+ game.deck.unshift(game.deck.splice(at, 1)[0]);
+ staging.push(`Position unopened ${desired} before the actual turn-${game.turn} ordinary auction; ${owner} acquires it only by its paid one-spice bid.`);
+ let bid: { before: Game; step: E3IndependentNexusOccupyStep } | undefined;
+ for (let count = 0; count < 280; count++) {
+ if (e3IndependentNexusOccupyPlayer(game, owner).hand.some(card => card.id === desired)) {
+ assert.ok(bid, 'The actual auction must have accepted its owner bid');
+ return { game, bid };
+ }
+ let step: E3IndependentNexusOccupyStep | null = null;
+ if (e3IndependentNexusOccupyClean(game) && game.auction?.active === owner &&
+ game.auction.cards[game.auction.index].id === desired && game.auction.bid === 0) {
+ step = { actor: owner, action: { type: 'bid', amount: 1, allyPayment: 0 } };
+ bid = { before: structuredClone(game), step: structuredClone(step) };
+ }
+ step ??= nextE3IndependentNexusOccupyStep(game); assert.ok(step);
+ game = actE3IndependentNexusOccupy(game, step, actions);
+ }
+ throw Error('Original Occupy auction did not deliver its selected physical card');
+}
+
+/** Original native Ecaz setup and first Mentat, two funded auctions, real worm
+ * alliance/closing Nexus draw, paid typed arrivals and actual lead selection.
+ * Only unopened physical deck positions are chosen; original six Imperial
+ * Basin counters, hands, purses, receipts and module owners are never assigned. */
+export function createE3IndependentNexusOccupyFixture(options: E3IndependentNexusOccupyOptions = {}) {
+ const lead = options.lead ?? 'ecaz', cardUser = lead === 'ecaz' ? 'ecaz' : 'emperor';
+ const source = createIndependentCardModulesFixture({ ...options, advanced: true,
+ expansions: options.expansions ?? ['ecaz'], factions: ['ecaz', 'emperor', 'guild'],
+ independent: true, nexus: true, tech: options.tech ?? false });
+ let game = structuredClone(source.game);
+ const afterSetup = structuredClone(game), actions = [...source.steps], staging: string[] = [];
+ const act = (actor: string, action: Action) => {
+ game = actE3IndependentNexusOccupy(game, { actor, action }, actions);
+ };
+ const reach = (until: (game: Game) => boolean) => {
+ game = advanceE3IndependentNexusOccupy(game, until, actions);
+ };
+ const takeLand = () => {
+ const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(at >= 0);
+ return game.spiceDeck.splice(at, 1)[0];
+ };
+ game.spiceDeck.unshift(takeLand(), takeLand());
+ staging.push('Order unopened first-turn land Spice Cards; later order one original ordinary worm before the second-turn Blow.');
+ reach(state => state.phase === 2 && e3IndependentNexusOccupyClean(state));
+ const firstPurchase = buyCard(game, cardUser, REINFORCEMENTS_CARD, actions, staging); game = firstPurchase.game;
+ const purchases = [firstPurchase.bid];
+ const kind = 'arrakeen', sector = territory(kind).sectors[0], location = `${kind}:${sector}`;
+ const shipments: { before: Game; step: E3IndependentNexusOccupyStep; after: Game }[] = [];
+ const ship = (actor: string, amount: number, elite: number) => {
+ const homeworldSources = game.homeworlds ? nativeShipmentSources(viewGame(game, actor), amount, elite) : undefined;
+ assert.ok(!game.homeworlds || homeworldSources);
+ const before = structuredClone(game), step: E3IndependentNexusOccupyStep = { actor, action: {
+ type: 'ship', territory: kind, sector, amount, elite, allyPayment: 0,
+ ...(homeworldSources ? { homeworldSources } : {}),
+ } };
+ game = actE3IndependentNexusOccupy(game, step, actions);
+ reach(e3IndependentNexusOccupyClean);
+ shipments.push({ before, step, after: structuredClone(game) });
+ };
+ reach(state => state.phase === 5 && state.active === 'ecaz' && e3IndependentNexusOccupyClean(state));
+ ship('ecaz', 5, 0);
+ reach(state => state.turn === 2 && state.phase === 1 && e3IndependentNexusOccupyClean(state));
+ const afterFirstMentat = structuredClone(game);
+ const worm = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed);
+ assert.ok(worm >= 0); game.spiceDeck.unshift(game.spiceDeck.splice(worm, 1)[0]);
+ reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && e3IndependentNexusOccupyClean(state));
+ const beforeAlliance = structuredClone(game);
+ act('ecaz', { type: 'alliance', target: 'emperor' });
+ act('emperor', { type: 'alliance', target: 'ecaz' });
+ reach(state => state.nexusCards?.phase?.stage === 'drawing');
+ const cards = game.nexusCards!.cards!, next = cards.deck.indexOf('guild'); assert.ok(next >= 0);
+ cards.deck.unshift(cards.deck.splice(next, 1)[0]);
+ staging.push('Order the unopened Guild Nexus singleton; the unallied Guild receives its own card through the real closing draw while the coalition is already allied.');
+ const beforeNexusDraw = structuredClone(game), drawStep: E3IndependentNexusOccupyStep = { actor: 'guild', action: {
+ type: 'nexusCardChoice', turn: game.turn, card: cards.hands.guild, choice: 'draw', ownRedraws: 0,
+ } };
+ game = actE3IndependentNexusOccupy(game, drawStep, actions);
+ const afterNexusDraw = structuredClone(game);
+ reach(state => state.phase === 2 && e3IndependentNexusOccupyClean(state));
+ const secondPurchase = buyCard(game, cardUser, HARASS_WITHDRAW_CARD, actions, staging);
+ game = secondPurchase.game; purchases.push(secondPurchase.bid);
+ reach(state => state.phase === 5 && e3IndependentNexusOccupyClean(state));
+ const waiting = new Set(['emperor', 'guild']);
+ while (game.phase === 5) {
+ if (e3IndependentNexusOccupyClean(game) && waiting.has(game.active!)) {
+ const actor = game.active!; ship(actor, actor === 'emperor' ? 5 : 3, actor === 'emperor' ? 2 : 0);
+ waiting.delete(actor);
+ } else {
+ const step = nextE3IndependentNexusOccupyStep(game); assert.ok(step);
+ game = actE3IndependentNexusOccupy(game, step, actions);
+ }
+ }
+ assert.equal(waiting.size, 0);
+ reach(state => state.phase === 6 && e3IndependentNexusOccupyClean(state));
+ const beforeBattle = structuredClone(game), choice = viewGame(game, game.active!).battleChoices.find(choice => choice.territory === kind);
+ assert.ok(choice);
+ const battleStep: E3IndependentNexusOccupyStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory: kind,
+ target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
+ game = actE3IndependentNexusOccupy(game, battleStep, actions);
+ reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'ecazBattleLead');
+ const beforeLead = structuredClone(game);
+ assert.ok(game.decision?.kind === 'ecazBattleLead');
+ const leadStep: E3IndependentNexusOccupyStep = { actor: 'ecaz', action: { type: 'decision', event: game.decision.event, lead: cardUser } };
+ game = actE3IndependentNexusOccupy(game, leadStep, actions);
+ reach(state => !!state.battle && !state.battle.preparation && state.battle.preLeader?.closed !== false && e3IndependentNexusOccupyClean(state));
+ const own = viewGame(game, cardUser).battle!, profile = own.ecazOccupy?.profile; assert.ok(profile);
+ const leaders = (actor: string) => e3IndependentNexusOccupyPlayer(game, actor).leaders.filter(leader => !leader.dead && !leader.usedAt &&
+ !game.leaderSkills?.assignments.some(assignment => assignment.leader === leader.id));
+ const leader = leaders(cardUser).sort((one, two) => two.strength - one.strength)[0]; assert.ok(leader);
+ const enemy = leaders('guild').sort((one, two) => one.strength - two.strength)[0]; assert.ok(enemy);
+ const support = profile.forces.eliteFreeSupport || profile.forces.freeSupport ? 0 : 1;
+ const feeSources = own.reinforcements!.homeworldSources?.[0];
+ const plans: E3IndependentNexusOccupyStep[] = [
+ { actor: cardUser, action: { type: 'battlePlan', dial: profile.fixedEcazDial + 2, support, leader: leader.id,
+ weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD, allyPayment: 0,
+ ...(feeSources ? { reinforcementsSources: feeSources } : {}),
+ } },
+ { actor: 'guild', action: { type: 'battlePlan', dial: 0, support: 0, leader: enemy.id } },
+ ];
+ const returns: HarassWithdrawSelection = { [location]: cardUser === 'ecaz'
+ ? { normal: 2, elite: 0 } : { normal: 3, elite: 1 } };
+ return { ...source, afterSetup, afterFirstMentat, beforeAlliance, beforeNexusDraw, drawStep, afterNexusDraw,
+ beforeBattle, battleStep, beforeLead, leadStep, game, lead, cardUser, kind, sector, location,
+ profile, support, plans, returns, actions, staging, purchases, shipments };
+}
+
+export function revealE3IndependentNexusOccupy(game: Game, plans: readonly E3IndependentNexusOccupyStep[],
+ actions: E3IndependentNexusOccupyStep[] = []): Game {
+ for (const step of plans) {
+ game = actE3IndependentNexusOccupy(game, step, actions);
+ game = advanceE3IndependentNexusOccupy(game, state => !state.response && !state.phaseOpening &&
+ (!state.decision || state.decision.kind === 'harassWithdraw') && !state.pendingGuildBetrayal &&
+ !state.battle?.preparation && state.battle?.preLeader?.closed !== false, actions);
+ }
+ assert.ok(game.battle?.revealed); return game;
+}
+export function settleE3IndependentNexusOccupy(game: Game, returns: HarassWithdrawSelection,
+ actions: E3IndependentNexusOccupyStep[] = []): Game {
+ for (let count = 0; count < 360; count++) {
+ if (game.phase === 7 && !game.battle && e3IndependentNexusOccupyClean(game)) return game;
+ let step: E3IndependentNexusOccupyStep | null = null;
+ if (!game.response && !game.phaseOpening && game.decision?.kind === 'harassWithdraw')
+ step = { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, returns } };
+ if (!game.response && !game.phaseOpening && game.decision?.kind === 'battleLosses') {
+ const choice = game.decision.options.findIndex(loss => loss.normal === 0 && loss.elite === 1); assert.ok(choice >= 0);
+ step = { actor: game.decision.player, action: { type: 'decision', choice } };
+ }
+ step ??= nextE3IndependentNexusOccupyStep(game); assert.ok(step);
+ game = actE3IndependentNexusOccupy(game, step, actions);
+ }
+ throw Error('Original combined card costs/returns/losses did not finish into Collection');
+}
+
+/** Project the genuinely revealed normal-outcome plans with explicit original
+ * owner allocations; the programme subsequently declines every real Traitor. */
+export function quoteE3IndependentNexusOccupy(game: Game, cardUser: string, returns: HarassWithdrawSelection) {
+ const battle = game.battle; assert.ok(battle?.revealed);
+ const own = viewGame(game, cardUser).battle!, profile = own.ecazOccupy?.profile; assert.ok(profile);
+ const combatant = (actor: string): ResolutionCombatant => {
+ const player = e3IndependentNexusOccupyPlayer(game, actor), view = viewGame(game, actor).battle!;
+ const plan = battle.plans[actor]; assert.ok(plan);
+ return { id: actor, faction: player.faction, spice: player.spice, hand: player.hand, plan,
+ leader: player.leaders.find(leader => leader.id === plan.leader), forces: view.ownForces!,
+ stronghold: view.strongholdEffects[actor],
+ leaderSkills: (game.leaderSkills?.assignments ?? []).filter(assignment => assignment.owner === actor)
+ .map(assignment => ({ skill: assignment.skill, leader: assignment.leader,
+ faceUp: battle.leaderSkillHidden?.[actor] !== true, captured: false })),
+ ...(view.reinforcements ? { reinforcementsReserves: {
+ normal: player.reserves - (player.elites?.reserves ?? 0), elite: player.elites?.reserves ?? 0 } } : {}),
+ ...(view.harassWithdraw ? { harassWithdraw: { blocked: null, forces: view.harassWithdraw.forces,
+ locations: view.harassWithdraw.locations,
+ ...(view.harassWithdraw.occupy ? { occupy: view.harassWithdraw.occupy } : {}) }, harassSelection: returns } : {}),
+ };
+ };
+ return quoteBattleResolution({ advanced: game.advanced, turn: game.turn, territory: battle.territory,
+ aggressor: viewGame(game, game.host).battle!.aggressor, ecazOccupy: profile,
+ attacker: combatant(battle.attacker), defender: combatant(battle.defender),
+ voters: viewGame(game, game.host).battle!.traitorVoters.map(actor => ({ id: actor,
+ beneficiary: [profile.ecaz, profile.ally].includes(actor) ? profile.lead : actor,
+ called: false, traitors: e3IndependentNexusOccupyPlayer(game, actor).traitors })),
+ participants: game.players,
+ physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)],
+ pendingAuditorPresent: false, pendingRetentionPresent: false,
+ });
+}
diff --git a/tests/selected-independent-ixian-betrayal.test.ts b/tests/selected-independent-ixian-betrayal.test.ts
index 47a7f7db..48f25b1b 100644
--- a/tests/selected-independent-ixian-betrayal.test.ts
+++ b/tests/selected-independent-ixian-betrayal.test.ts
@@ -108,7 +108,7 @@ void test('selected physical inventories require all three independent faces and
assert.deepEqual(ctx, before);
});
-void test('public selected families authorize native responders without admitting unselected or E3 seats', () => {
+void test('public selected families authorize native responders without admitting unselected native seats', () => {
const ctx = fixture();
for (const expansions of [['choam'], ['ix', 'ix'], ['ix', 'ecaz'], []])
assert.throws(() => createIxianBetrayalSource({ ...ctx, expansions }, authority(),
@@ -125,6 +125,26 @@ void test('public selected families authorize native responders without admittin
assert.equal(ixianBetrayalEligible(allied.cards, allied.players, 'holder', selection(allied)), false);
});
+for (const mixed of [false, true]) void test(`selected E3/${mixed ? 'Ix/CHOAM cache' : 'Ix'} native responders preserve original cancellation and physical cards`, () => {
+ const original = fixture(mixed);
+ const expansions = [...original.expansions!, 'ecaz'];
+ const players: NexusPlayer[] = [...original.players.map(player => player.id === 'observer'
+ ? {...player, faction:'ecaz' as const} : player), {id:'moritani', faction:'moritani'}];
+ const ctx: IxianBetrayalContext = {...original, expansions, players,
+ cards:{...original.cards, hands:{...original.cards.hands, moritani:null}},
+ physicalCards:[...treacheryDeck(expansions), ...ecazTreacheryCards(), ...(mixed ? richeseCards() : [])]};
+ const before = structuredClone(ctx);
+ const source = createIxianBetrayalSource(ctx, authority(), 'original-selected-e3-native-pool');
+ assert.equal(source.eligible, 'holder');
+ assert.deepEqual(ixianBetrayalResponders(ctx.cards, players, selection(ctx)), source.required);
+ const closed = closeIxianBetrayalSource(source, 'use', 'holder', initialIxianBetrayalCursor());
+ validateIxianBetrayalHistory([closed.receipt], closed.cursor, ctx.physicalCards, selection(ctx));
+ assert.equal(ctx.physicalCards.length, mixed ? 60 : 50);
+ assert.deepEqual(ctx, before, 'Neither the native E3 presence nor cancellation changes the selected catalogue.');
+ assert.throws(() => createIxianBetrayalSource({...ctx, ecazTreachery:false}, authority(),
+ source.parent), IxianBetrayalError, 'This new selected E3 lane requires its real independent-card selection.');
+});
+
void test('omitted selection preserves original Ix47 source and receipt grammar and rejects silent new card inventory', () => {
const selected = fixture(false), ctx = { ...selected, physicalCards: treacheryDeck(['ix']) };
delete ctx.expansions;
diff --git a/tests/selected-independent-nexus-entry.test.ts b/tests/selected-independent-nexus-entry.test.ts
index f79bf83d..5e6fc9b7 100644
--- a/tests/selected-independent-nexus-entry.test.ts
+++ b/tests/selected-independent-nexus-entry.test.ts
@@ -11,6 +11,12 @@ const entries: SelectedIndependentEntryOptions[] = [
{expansions:['ix','choam'], factions:['atreides','guild']},
{expansions:['choam'], factions:['emperor','atreides'], advanced:true, homeworlds:true, strongholds:true},
{expansions:['ix'], factions:['tleilaxu','guild'], homeworlds:true},
+ {expansions:['ecaz'], factions:['ecaz','moritani']},
+ {expansions:['ecaz'], factions:['moritani','harkonnen'], advanced:true},
+ {expansions:['ecaz'], factions:['ecaz','moritani','guild'], advanced:true, skills:true, homeworlds:true, tech:true, strongholds:true},
+ {expansions:['ecaz','ix','choam'], factions:['ecaz','moritani','ixians','tleilaxu','choam','richese'], advanced:true, skills:true, homeworlds:true, tech:true, strongholds:true},
+ {expansions:['ecaz','ix','choam'], factions:['atreides','guild']},
+ {expansions:['ecaz','choam'], factions:['ecaz','emperor'], advanced:true, skills:true, strongholds:true},
];
for (const options of entries) void test(`normal independent Nexus entry ${options.expansions.join('+')}/${options.factions.join('+')} retains selected components`, () => {
const {lobby, setup, game} = createSelectedIndependentEntry(options);
@@ -28,12 +34,15 @@ for (const options of entries) void test(`normal independent Nexus entry ${optio
assert.equal(game.nexusCards!.cards!.deck.length, 12);
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null));
const ordinary = game.deck.length + game.discard.length + game.players.reduce((sum, player) => sum + player.hand.length, 0);
- assert.equal(ordinary, options.expansions.includes('ix') ? 50 : 38);
+ assert.equal(ordinary, options.expansions.includes('ix') ? 50 : options.expansions.includes('choam') ? 38 : 36);
assert.equal(game.richeseCache?.length ?? 0, options.factions.includes('richese') ? 10 : 0);
assert.equal(!!game.nexusIxianReplacementPreview, !options.factions.includes('ixians'));
assert.equal(!!game.nexusIxianBetrayalPreview, options.factions.includes('ixians'));
assert.equal(!!game.nexusHarkonnenBetrayalPreview, options.factions.includes('harkonnen'));
assert.equal(!!game.guildBetrayalPreview, options.factions.includes('guild'));
+ assert.equal(!!game.ecazOccupyPreview, options.factions.includes('ecaz'));
+ assert.equal(!!game.moritaniAssassinatePreview, !!options.advanced && options.factions.includes('moritani'));
+ assert.equal(!!game.moritaniAssassinate, !!options.advanced && options.factions.includes('moritani'));
if (options.skills) assert.ok(['skillTreachery','leaderSkills'].includes(setup.setupStage!),
'The original Ixian starting-card choice may precede skill training');
if (setup.techTokens) {
@@ -44,6 +53,19 @@ for (const options of entries) void test(`normal independent Nexus entry ${optio
if (game.strongholdCards) assert.ok(Object.values(game.strongholdCards.owners).every(owner => owner === null));
});
+void test('selected E3 core retains native assassination stamps and the existing trained capture boundary', () => {
+ const native = {advanced:true, expansions:['ecaz'], players:[{faction:'moritani'},{faction:'harkonnen'}], nexusCards:true, ecazTreachery:true};
+ assert.equal(selectedIndependentNexusProfile(native), true);
+ assert.equal(selectedIndependentNexusProfile({...native, moritaniAssassinatePreview:true}), true);
+ assert.equal(selectedIndependentNexusProfile({...native, leaderSkills:true}), false);
+ assert.equal(selectedIndependentNexusProfile({...native, advanced:false, leaderSkills:true}), true);
+ assert.equal(selectedIndependentNexusProfile({...native, advanced:false, moritaniAssassinatePreview:true}), false);
+ assert.equal(selectedIndependentNexusProfile({...native, players:[{faction:'ecaz'},{faction:'emperor'}], moritaniAssassinatePreview:true}), false);
+ assert.equal(selectedIndependentNexusProfile({...native, expansions:['ix']}), false);
+ assert.equal(selectedIndependentNexusProfile({...native, expansions:['ecaz','ix','choam','ecaz']}), false);
+ assert.equal(selectedIndependentNexusProfile({...native, expansions:['ecaz','sandtrout']}), false);
+});
+
void test('selected core capability requires real independent cards and selected registered seats', () => {
const base = {advanced:true, expansions:['ix'], players:[{faction:'ixians'},{faction:'atreides'}], nexusCards:true, ecazTreachery:true};
assert.equal(selectedIndependentNexusProfile(base), true);