diff --git a/components/nexus-tleilaxu-revival.tsx b/components/nexus-tleilaxu-revival.tsx
new file mode 100644
index 00000000..6b2f9a55
--- /dev/null
+++ b/components/nexus-tleilaxu-revival.tsx
@@ -0,0 +1,36 @@
+'use client';
+
+import {useState} from 'react';
+import type {Action,GameView} from '@/game/engine';
+import {nexusTleilaxuRevivalAction} from '@/game/nexus-tleilaxu-options';
+import {Button} from './ui/button';
+
+export function NexusTleilaxuRevival({game,act,busy}:{game:GameView;act:(action:Action)=>void;busy:boolean}) {
+ const [selected,setSelected]=useState('');
+ const [amount,setAmount]=useState(0);
+ const [elite,setElite]=useState(0);
+ const offer=game.nexusTleilaxu?.secretAlly;
+ if(!offer)return null;
+ const leader=offer.leaders.some(candidate=>candidate.id===selected)?selected:offer.leaders[0]?.id??'';
+ const action=nexusTleilaxuRevivalAction(game,offer.event,leader,amount,elite);
+ return
+
Tleilaxu Secret Ally
+
Return one own leader free and up to five physical forces at one bank spice each. The leader is required; zero forces is legal. The shared ordinary force allowance and one-leader slot apply.
+ {offer.blocked?
{offer.blocked}
:<>
+
+
+ {offer.maxElite>0&&}
+
Bank payment: {amount} spice. Leader payment: 0. Returned forces retain their actual normal/elite identities.
- {mode === 'secretAlly'
- ? 'Secret Ally revival is unavailable while its revival-limit ruling is pending.'
- : 'Betrayal is unavailable while its private response timing is pending.'}
+ Betrayal is unavailable while its private response timing is pending.
);
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 192972cc..a715ac3d 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -29,6 +29,28 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
+**Tleilaxu Secret Ally combined revival — 14 October.**
+The recommended prototype now provides owner-only leader/paid-force controls
+in quiet Revival, with the actual Tleilaxu Nexus held and faction absent.
+One mandatory own dead leader returns free and consumes the ordinary slot;
+zero-to-five exact counters cost one bank spice each and share an ordinary
+ceiling expanded to at least five. Earlier ordinary returns count; larger
+native/Recruits allowances remain. Only the unopened roster trigger is waived:
+ordinary cycles, repeated deaths, typed caps and special custody remain.
+This policy is visible and provisional, not publisher clarification.
+
+Genuine Basic/Advanced setup, shipment, opposing weapon, eight-counter loss,
+worm pact and real closing draw lead to this action. No target hand/force/
+wallet/death/phase is fabricated. Original skill return, reserves and typed
+Homeworld deployment source are reused. Final nine-file union141/141,
+types/lint/build pass. Four legal profiles execute the real leader+five return.
+Actual390px table: Dr. Yueh alive with deaths1/cycle0; spice11→6,
+Tanks8→3, reserves10→15, returns5 and leader slot consumed, one Nexus
+discard and Ready restored. No overflow or browser errors. Existing Ecaz
+six-disc/Advanced-Harkonnen Duke candidate guards and full module/deployed
+acceptance remain open.
+[Contract](NEXUS_TLEILAXU_RULES.md#recommended-combined-revival-prototype--14-october-2026).
+
**Moritani Atomics after alliance change — 14 October.**
The recommended interpretation keeps the permanent hand-limit penalty on
Moritani and its original ally at detonation. Later Nexus and Enemy of My Enemy
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index e64f95dc..68623492 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,40 @@
# Dune implementation status
+## 14 October 2026 — Tleilaxu Secret Ally free-leader/paid-force revival
+
+Recommended-option priority closes the earlier whole-effect gate under an
+explicit provisional policy: mandatory free own leader/ordinary leader slot,
+zero-to-five physical counters at one bank spice each, shared ordinary ceiling
+expanded to at least five. Prior returns count; card-first and ordinary-first
+agree. Larger native/Recruits allowance is not reduced; fixed group never
+doubles. Only unopened roster eligibility is waived; original cycles,
+repeated-death/face-down, actual typed caps and special custody are retained.
+
+Two disjoint implementation/native-proof slices were integrated by one owner.
+The pure quote reuses canonical leader/counter eligibility; the engine binds
+actual held card/event, pays the bank once, returns the real leader through
+original skill/custody handling, deposits exact counters and records per-phase
+source permission. Homeworld signed return accepts the new Nexus source.
+Owner-only controls and four minimal legal profiles use the same offered
+selection. Wording/forwarding/fixture-trace assertions were removed rather
+than re-pinned.
+
+Native fixtures retain genuine setup, starting cards, Emperor shipment,
+real weapon death/eight-counter exchange, worm alliance and actual original
+Nexus draw. Only original unplayed deck order and producer entropy are staged.
+Ignored immutable Ready results and Advanced Guild interception were repaired
+in the fixture, never by fabricating phases, hands, deaths, wallets or forces.
+
+Final nine-file union141/141; types/lint and app build pass. Actual390×844
+table returns Dr. Yueh with deaths1 and cycle0 unchanged, spice11→6,
+Tanks8→3/reserves10→15, ordinary returns5 and leader slot consumed.
+One physical Nexus goes to discard, the offer disappears and Ready stays
+enabled. No overflow or browser errors. All four legal policies execute that
+actual five-counter return; not strategic calibration. Owned hosts/tabs/
+scaffolding removed; proof outside Git. Existing build warnings remain.
+Ecaz six-disc/Advanced-Harkonnen Duke candidate restrictions, combined
+modules, full rules, assurance and deployed acceptance remain open.
+
## 14 October 2026 — Persistent original Atomics hand-limit targets
Recommended-option priority: the permanent one-card reduction stays on
diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md
index 562dda99..a8846ef3 100644
--- a/docs/NEXUS_CARD_RULES.md
+++ b/docs/NEXUS_CARD_RULES.md
@@ -1351,7 +1351,7 @@ The [bounded Ixian Secret Ally replacement integration](NEXUS_IXIAN_REPLACEMENT_
- The common trigger is the end of the full Spice Blow and Nexus phase. The later shorthand about the next Nexus is read with that opening condition; it does not establish an extra draw after each Advanced blow. Keep that implementation decision explicit in runtime documentation.
- No retrieved card or E3 FAQ gives a general rule that every Nexus effect is Karama-immune, or that playing one suspends all ordinary responses. Homeworld immunity on the preceding page is limited to Homeworld advantages/penalties. Audit cancellation of an underlying native advantage separately from cancellation of the Nexus card before enabling each family.
- Duration is explicit for several panels but not all. Ixian Betrayal's scope over multiple auction lots, the optional continuation after canceling native Prescience, and exact multi-party cancellation priority require the corresponding existing faction timing contract. Do not make an entire-phase cancellation merely from the word “advantage.”
-- Emperor forced ally payment needs a precise invoice allocation when the ally has some funds and Emperor is short. Its own purchase-refund alternative also needs original payer contributions. Tleilaxu's combined leader/force revival requires an audit of partial selection and interaction with ordinary free rates; the card does not say that all five force revivals are free.
+- Emperor forced ally payment and purchase refunds retain their precise source-invoice requirements. Tleilaxu's combined revival now uses the [explicit recommended policy](NEXUS_TLEILAXU_RULES.md#recommended-combined-revival-prototype--14-october-2026): a mandatory free own leader/ordinary slot and fixed paid physical group sharing an expanded ordinary ceiling, with preserved cycles and typed caps. This is a provisional implementation, not a new publisher answer; the five counters are not free.
- Richese's two-No-Field effect has a bounded native Cunning shipment follow-up, but wider marker/module compositions remain separate. Its [bounded Betrayal auction contract](NEXUS_RICHESE_RULES.md#bounded-betrayal-auction-contract--30-september-2026) connects public self-cache veto and other-buyer sale diversion, not normal-deck hidden-family self-purchases or private special-Karama acquisition. Ecaz's Duke override needs explicit restoration/end-turn handling across capture, death and Ghola records. These are material implementation tasks, not reasons to invent substitute effects.
- A complete twelve-card reference/inventory and common lifecycle are substantial first work, but do not alone make all thirty-six panels playable. Keep the public module release gated until every face's supported timing, secrecy, custody, bots, UI, rejection, multiplayer CAS and saved continuation paths are verified. If development supports only Atreides' family first, other faces must remain accurately represented and explicitly unavailable in the audit mode; they must not silently act as blanks in a released module.
diff --git a/docs/NEXUS_TLEILAXU_RULES.md b/docs/NEXUS_TLEILAXU_RULES.md
index 8c3795ae..0b10a8a9 100644
--- a/docs/NEXUS_TLEILAXU_RULES.md
+++ b/docs/NEXUS_TLEILAXU_RULES.md
@@ -44,7 +44,7 @@ Important compositions and exclusions:
## Secret Ally questions that still affect implementation
-The inspected E3 FAQ does not answer these. Keep Secret Ally gated rather than implementing a smaller interpretation disguised as the complete effect.
+The inspected E3 FAQ does not answer these. The earlier blanket gate is superseded by the explicit recommended prototype policy below; that choice is not publisher clarification or a separately answered numerical user ruling.
| Issue | Concrete interpretations requiring a ruling |
| --- | --- |
@@ -54,10 +54,59 @@ The inspected E3 FAQ does not answer these. Keep Secret Ally gated rather than i
Normal leader revival has an unavailable-roster trigger and a separate repeated-death cycle. E1 expressly permits face-down leaders for its negotiated service, and the FAQ separately discusses native foreign-ghola revival. Neither automatically grants those permissions to an absent-faction Nexus effect. Similarly, the FAQ's Emperor extras are explicitly additional, while this panel does not settle its ordinary counter debit. [Base p.9](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=9), [E1 p.7](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=7), [November FAQ p.6](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=6)
-Coordinator question recommendation: ask explicitly about independent versus ordinary force/leader allowance, waived leader prerequisites, and whether the leader portion is optional. The related [Ambassador accounting questions](TLEILAXU_AMBASSADOR_RULES.md) are already pending; do not duplicate those or assume an answer covers differently worded Nexus text. No user question was sent by this audit.
+The 14 October choose-recommended-option instruction supersedes the coordinator's earlier recommendation to ask again. The related Ambassador wording remains a separate contract; this implementation does not invent publisher answers or apply its policy to another component.
-Future revival quote inputs should retain source `nexusTleilaxuSecretAlly`, chosen leader/Kwisatz identity, exact normal/starred force counts, payment and existing usage histories. Require a resolved accounting policy before mutating ordinary counters. A receipt must preserve original revival, subsequent Homeworld deployment and phase continuation once; no card/force/spice effect should be inferred afresh from a restored hand or current Tanks.
+The committed action binds the actual held card, current turn/phase/owner event, selected own leader/Kwisatz/Duke and exact physical paid group. It uses the original return/skill/reserve helpers and an explicit `nexusTleilaxu` Homeworld source. The per-phase source permission records the shared five-counter ceiling; no native Tleilaxu income or cancellation owner is manufactured.
### Source-search follow-up
-The requested web search found no publisher/designer answer to the three questions above. The [publisher-authored expansion rulebook, pp. 11, 14–16](https://gamers-hq.de/media/pdf/0f/7a/86/Dune_EcazMoritani_Rulebook_EN.pdf) explains Nexus cards and answers other faction interactions, but does not specify whether this grant is additional to ordinary revival, consumes the ordinary leader slot, or permits using only one part. The printed card wording quoted above establishes quantities and prices, not these accounting or partial-use rules. Search-result summaries that conflate Secret Ally with native Tleilaxu revival or Ghola are not rulings. All three questions remain pending; do not implement an inferred answer.
+The prior requested search found no publisher/designer answer to these questions. The [publisher-authored expansion rulebook, pp. 11, 14–16](https://gamers-hq.de/media/pdf/0f/7a/86/Dune_EcazMoritani_Rulebook_EN.pdf) establishes other Nexus interactions, not this allowance/slot/partial-use accounting. Search summaries conflating Secret Ally with native revival or Ghola are not rulings. Publisher provenance remains unanswered; the explicit recommended prototype policy below is a separate implementation decision.
+
+## Recommended combined revival prototype — 14 October 2026
+
+The selected provisional interpretation is complete and visible:
+
+- **Shared allowance:** expand the ordinary ceiling to at least five, with
+ earlier ordinary returns counting against it, not an extra five. Card-first
+ and ordinary-first both retain that ceiling afterward. A larger native or
+ Recruits allowance is not reduced, but the fixed card group never exceeds
+ five. Every card counter costs one bank spice; none is an actual free force.
+- **Leader:** one own dead leader is mandatory, costs zero and consumes the
+ ordinary one-leader slot. Zero forces is legal; force-only use is not.
+ The card waives the unopened unavailable-roster prerequisite, not repeated
+ death/face-down cycles or special custody. Do not write the synthetic open
+ cycle into ordinary history. Kwisatz/Auditor and own eligible Duke reuse
+ canonical identity rules; captured/living/foreign-Ghola/Cheap Hero choices
+ are excluded.
+- **Existing limits:** Advanced Fedaykin/Sardaukar share their one-per-turn
+ cap across producers; Basic keeps physical stars; Cyborgs keep their actual
+ identity and the printed one-spice price. The explicit card grant does not
+ pass through the native Fremen paid-revival prohibition.
+
+Own Ecaz repeated six-disc cycle and Advanced-Harkonnen Duke custody guards
+remain explicit pre-existing candidate limitations, not a whole-card blank.
+No native Tleilaxu early-revival or income response can exist while that
+faction is absent. Southern Hemisphere receives the actual new typed deposit
+through its original signed deployment/arrival continuation. Broader optional
+module acceptance remains open.
+
+Implementation: `game/nexus-tleilaxu-revival.ts`,
+`game/nexus-tleilaxu-options.ts`, `game/revival.ts`, original engine and
+Homeworld return helpers, `components/nexus-tleilaxu-revival.tsx`.
+Genuine Basic/Advanced fixtures preserve original setup and cards: a real
+Emperor shipment/weapon kills Dr. Yueh during the actual winning eight-counter
+exchange, a real worm opens Nexus, the other two seats ally, and the original
+Tleilaxu singleton is drawn before quiet Revival. Staging is only unplayed
+deck order and original-producer entropy; no hands, forces, deaths, Tanks,
+wallets, alliances or phases are fabricated.
+
+Final nine-file union141/141, types/lint and app build pass. Native cases cover
+paid/zero force use, both ordinary/card orders, typed and repeated-death
+boundaries, wrong actor/quantity/stale event rejection and the next real turn.
+All four legal policies return the actual leader and five counters for5 spice.
+Actual390×844 table: Dr. Yueh returns with deaths1/cycle0 unchanged;
+Atreides spice11→6, Tanks8→3, reserves10→15, ordinary returns5 and leader
+slot consumed. Exactly one physical Nexus is discarded, the offer disappears
+and Ready remains enabled. No overflow or browser errors. Owned hosts/tabs/
+scaffolding were removed; local proof stays outside Git. Full Nexus, faction,
+combined-module and deployed acceptance remain open.
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 8823e257..54d09d10 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -546,7 +546,8 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Base special Karama | Unfunded auction recovery, stopped-shipment settlement and Atreides full-plan commitment order remain provisional. Native full-plan/Voice/Prescience/Truthtrance/Ghola interactions now have genuine-setup focused evidence, but the source audit does not settle the special offer/deadline or target-first interpretation. | [Explicit base interpretations](BASE_ADVANCED_READINESS_20260907.md#explicit-unresolved-base-interpretations), [full-plan interaction audit](ATREIDES_FULL_PLAN_TIMING.md) |
| Homeworld high-threshold revival deployment | The publisher does not state whether a return that itself first reaches the high threshold may deploy on that same return. The content-first prototype evaluates the resulting population, so the crossing return qualifies; this is a visibly provisional composition, not a located timing ruling. The Tleilaxu low-income start-condition snapshot is unchanged, and partial groups, split destinations, storm placement, allied-Homeworld limits and shipment-classified entry effects remain guarded. | [Benefits contract](HOMEWORLD_BENEFITS_RULES.md#provisional-high-threshold-revival-deployment--9-october-2026), [resumption record](IMPLEMENTATION_STATUS.md#9-october-2026--gated-captures-resumed-under-the-basic-occupation-and-odd-force-occupy-prototypes) |
| Occupied Homeworlds | The publisher question about retention after departure, replacement or contest, unique-power composition and shared income remains open; the first-occupier/current-occupier question was already sent. The content-first Basic benefit selector now uses the visibly provisional current-sole policy above (Tupile excepted), which is an implementation choice, not an answer to that question. | [Occupation questions](HOMEWORLD_OCCUPATION_RULES.md#questions-the-retrieved-sources-do-not-settle), [lifecycle audit](HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md), [Basic policy](HOMEWORLD_OCCUPATION_RULES.md#basic-current-sole-benefit-policy--9-october-2026) |
-| Nexus responses and composition | Betrayal private-response timing, partial revival/payment choices, Emperor fewer-than-five, fresh advisor/storm cases, Duke restoration and remaining faction-specific combinations. Tleilaxu Secret Ally force accounting, leader slot/eligibility and force-only use remain unresolved after requested publisher/designer source searches; do not infer answers from native Tleilaxu revival or Ghola. | [Common boundaries](NEXUS_CARD_RULES.md#remaining-source-questions-and-release-boundaries), [Tleilaxu source search](NEXUS_TLEILAXU_RULES.md#source-search-follow-up) and individual faction source/runtime documents |
+| Nexus responses and composition | Betrayal private-response timing, Emperor fewer-than-five, fresh advisor/storm cases, Duke restoration and remaining faction-specific combinations remain source-bound. Tleilaxu Secret Ally now has the explicitly provisional recommended combined policy in its own row below; it is not borrowed authority from native revival or Ghola. | [Common boundaries](NEXUS_CARD_RULES.md#remaining-source-questions-and-release-boundaries) and individual faction source/runtime documents |
+| Tleilaxu Secret Ally revival | Under choose-recommended-option priority, a quiet unallied actual holder with Tleilaxu absent returns one mandatory own dead leader free, using the ordinary leader slot, and zero-to-five exact counters at one bank spice each. The card shares an ordinary ceiling expanded to at least five; prior normal returns count, larger native/Recruits allowances remain, and the fixed card group is never doubled. Only the unavailable-roster trigger is waived; repeated-death/face-down and special custody rules remain, with original ordinary cycles preserved. No force-only use, native Tleilaxu income or nonexistent cancellation owner is invented. This is provisional implementation policy, not publisher clarification or a separately answered numerical user ruling. | [Recommended combined policy](NEXUS_TLEILAXU_RULES.md#recommended-combined-revival-prototype--14-october-2026) |
| Moritani Atomics after alliance change | The publisher’s permanent −1 text does not identify later-ally treatment, and prior requested searches found no clarification. Under choose-recommended-option priority, retain the original affected pair permanently: Moritani and its ally at detonation. Later Nexus and Enemy of My Enemy alliances are allowed without moving the penalty to a new ally; an unallied detonation gains no later ally target. Existing stored activation targets migrate immutably, without replaying effects. This is a conservative provisional implementation interpretation, not publisher clarification or a separately answered numerical user ruling. | [Recommended persistent targets](MORITANI_TERROR_RULES.md#recommended-persistent-activation-targets--14-october-2026) |
| Moritani Sneak Attack entry classification | The printed verb is “Send”; no retrieved ruling classifies the off-planet reserve transfer as a shipment for other arrival powers. The engine now **explicitly adopts** the supported shipment composition: a positive Sneak Attack in Shipment and Movement accrues the Heighliners token once for its owner, then offers the Bene Gesserit Intrusion (Advanced, with fighters present) and the free spiritual-advisor shipment, with the committed Terror entry resolving first; a zero-force resolution accrues nothing and opens no reaction. This order is an implementation interpretation, not a publisher ruling. | [Source boundary and adopted classification](MORITANI_ASSASSINATION_SNEAK.md#adopted-shipment-classification-7-october-2026) |
| Orgiz collection | Basic retains the publisher-p13 per-blow ambiguity and provisional one-transfer-per-positive-deposit mapping. Authorized supplied Advanced rulebook physical24 says each time Collection occurs in a territory containing spice: one transfer per actual positive territory Collection per fighter occupant, never extra transfers for multiple source keys. Canonical ordinary deposit receipts identify actual payers, including co-present collectors; force presence and old wallets alone do not qualify. Under choose-recommended-option priority, owners and eligible foreign ordinary payers resolve in original storm order. Shared Ecaz lots in both modes wait for actual bilateral allocation, then consider positive foreign recipients Ecaz first, ally second; Advanced charges once per territory, Basic counts actually reduced positive source deposits in that territory. Self, zero shares and empty purses are skipped; balances track every transfer without debt. Ordering and the Basic mapping are provisional implementations, not separate numerical user answers or publisher rulings. | [Advanced source cutover and Basic boundary](DISCOVERY_PROTOTYPE.md#authorized-advanced-collection-wording) |
diff --git a/game/engine.ts b/game/engine.ts
index 878a96d4..f2410f93 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -309,6 +309,7 @@ import {
DiscoveryCollectionError,
type DiscoveryCollectionQuote,
} from './discovery-collection';
+import {tleilaxuSecretAllyOptions,quoteTleilaxuSecretAlly} from './nexus-tleilaxu-revival';
import {
quoteMovementCancellation,
validateAmbassadorRelocationContext,
@@ -3269,9 +3270,10 @@ function nexusFaceDancerIntegrity(g: Game) {
}
function projectedNexusTleilaxu(g: Game, id: string) {
if (!g.nexusCards?.cards) return null;
- if (g.nexusCards.cards.hands[id] !== 'tleilaxu') return { cunning: null };
+ if (g.nexusCards.cards.hands[id] !== 'tleilaxu') return { cunning: null, secretAlly: null };
const p = getPlayer(g, id);
- if (p.faction !== 'tleilaxu') return { cunning: null };
+ if (p.faction !== 'tleilaxu')
+ return { cunning: null, secretAlly: byFaction(g,'tleilaxu')?null:tleilaxuSecretAllyOptions(g,p) };
const count = p.faceDancers?.filter(card => card.revealed).length ?? 0;
let blocked: string | null = null;
if (g.status !== 'playing') blocked = 'Use Cunning during an active game.';
@@ -3282,7 +3284,7 @@ function projectedNexusTleilaxu(g: Game, id: string) {
else if (pendingNexusTraitors(g)) blocked = 'Finish the current private Traitor Deck return first.';
else if (!count) blocked = 'No revealed Face Dancers are available to replace.';
else if ((g.traitorReserve?.length ?? 0) < count) blocked = 'The Traitor Deck cannot supply the full replacement draw.';
- return { cunning: { event: JSON.stringify(['nexusFaceDancers', g.turn, g.phase, g.nexusFaceDancerHistory?.length ?? 0, id]), count, blocked } };
+ return { cunning: { event: JSON.stringify(['nexusFaceDancers', g.turn, g.phase, g.nexusFaceDancerHistory?.length ?? 0, id]), count, blocked }, secretAlly: null };
}
function playNexusFaceDancers(g: Game, p: Player, action: Action) {
requireRule(Object.keys(action).every(key => ['type', 'event'].includes(key)),
@@ -3299,6 +3301,46 @@ function playNexusFaceDancers(g: Game, p: Player, action: Action) {
{ faction: p.faction, name: 'Nexus Face Dancer replacement' });
}
+function playNexusTleilaxuRevival(g: Game, p: Player, action: Action) {
+ requireRule(Object.keys(action).every(key=>['type','event','leader','amount','elite'].includes(key)),
+ 'Use only the owned Nexus revival selection; its price and source are authoritative.');
+ const offer=projectedNexusTleilaxu(g,p.id)?.secretAlly;
+ requireRule(offer&&!offer.blocked&&action.event===offer.event,
+ offer?.blocked??'Choose the current Tleilaxu Secret Ally offer.');
+ const quote=nexusRule(()=>quoteTleilaxuSecretAlly(g,p,{
+ leader:stringField(action.leader),amount:integer(action.amount,0,5,'Forces'),
+ elite:integer(action.elite,0,5,'Elite forces'),
+ }));
+ const group={amount:quote.amount,elite:quote.elite,free:0};
+ const grant=quote.amount>0?requireHomeworldRevivalGrant(g,p,'nexusTleilaxu',group):null;
+ const ordinary=leaderRevivalOptions(g,p);
+ const payment=g.spiceBankerIncomePreview&"e.cost>0
+ ?bankerIncomePayment(g,'force-revival',[{payer:p.id,amount:quote.cost,recipient:'bank'}]):undefined;
+ g.nexusCards!.cards=nexusRule(()=>discardNexusCard(g.nexusCards!.cards!,p.id,g.players));
+ g.revivalRules??=newRevivalRules();
+ (g.revivalRules.nexusTleilaxu??=[]).push(p.id);
+ if(quote.kind!=='duke'||ordinary.cycle>p.revivalCycle)p.revivalCycle=ordinary.cycle;
+ // The card quote establishes a free own return; the original mutation keeps
+ // Kwisatz/Duke custody, one-leader usage and skill revival lifecycle.
+ finishRevival(g,{
+ pending:{player:p.id,kind:quote.kind==='kwisatz'?'kwisatz':'leader',
+ ...(quote.kind==='kwisatz'?{}:{leader:quote.leader}),normalCost:0,cost:0,free:0,checks:[]},
+ outcome:'revive',
+ });
+ if(quote.amount>0) {
+ p.spice-=quote.cost;
+ addNativeReserves(g,p,quote.amount,quote.elite);
+ p.tanks-=quote.amount;
+ if(p.freeForcesRevived!==undefined||p.revived===0)p.freeForcesRevived??=0;
+ p.revived+=quote.amount;
+ if(p.elites){p.elites.tanks-=quote.elite;p.elites.revived+=quote.elite}
+ if(payment)awardBankerIncome(g,payment);
+ stageHomeworldRevivalReturn(g,p,'nexusTleilaxu',group,grant);
+ }
+ log(g,`${p.name} spent Tleilaxu Nexus Secret Ally: one own leader returned free and ${quote.amount} physical forces returned for ${quote.cost} bank spice. The ordinary leader slot and shared five-force allowance apply.`,
+ {faction:p.faction,name:'Tleilaxu Nexus revival'});
+}
+
function nexusSuboidIntegrity(g: Game) {
if (g.nexusSuboidHistory === undefined) {
requireRule(!g.nexusSuboidLast, 'The Suboid Nexus effect has lost its saved history.');
@@ -28796,6 +28838,7 @@ function applyActionInner(
requireRule(g.nexusCards?.phase?.stage !== 'drawing', 'Finish the closing Nexus card choices first.');
if (t === 'nexusTraitorDraw') { playNexusTraitorDraw(g, p, action); return g; }
if (t === 'nexusFaceDancers') { playNexusFaceDancers(g, p, action); return g; }
+ if (t === 'nexusTleilaxuRevival') { playNexusTleilaxuRevival(g,p,action); return g; }
if (t === 'nexusSuboids') { playNexusSuboids(g, p, action); return g; }
if (t === 'nexusAdvisors') { playNexusAdvisors(g, p, action); return g; }
if (t === 'nexusSardaukar') { playNexusSardaukar(g,p,action); return g; }
@@ -31472,8 +31515,9 @@ function applyActionInner(
requireRule(quote.cost <= p.spice, 'Not enough spice for revival.');
const checks: PendingRevival['checks'] = [];
const choamBenefit = p.faction === 'choam' && !g.revivalRules?.choamBlocked;
- const uncancelableLimit =
- g.recruits?.turn === g.turn ? 7 : Math.max(3, freeRevivalRate(g, p));
+ const uncancelableLimit = Math.max(
+ g.recruits?.turn === g.turn ? 7 : Math.max(3, freeRevivalRate(g, p)),
+ g.revivalRules?.nexusTleilaxu?.includes(p.id) ? 5 : 0);
if (choamBenefit && (n > quote.free || p.revived + n > uncancelableLimit))
checks.push('choamRevival');
if (!choamBenefit && p.revived + n > uncancelableLimit)
diff --git a/game/homeworld-revival-deployment.ts b/game/homeworld-revival-deployment.ts
index 25ecd7bc..69578880 100644
--- a/game/homeworld-revival-deployment.ts
+++ b/game/homeworld-revival-deployment.ts
@@ -16,7 +16,8 @@ export type HomeworldRevivalDeploymentContext = Pick<
export type HomeworldRevivalDeploymentSource =
| 'normal'
| 'emperorExtra'
- | 'ghola';
+ | 'ghola'
+ | 'nexusTleilaxu';
export type HomeworldRevivalDeploymentGroup = {
amount: number;
elite: number;
@@ -61,7 +62,7 @@ export function quoteHomeworldRevivalDeployment(
group: HomeworldRevivalDeploymentGroup,
): HomeworldRevivalDeploymentQuote | null {
if (
- !['normal', 'emperorExtra', 'ghola'].includes(source) ||
+ !['normal', 'emperorExtra', 'ghola', 'nexusTleilaxu'].includes(source) ||
!group ||
typeof group !== 'object' ||
Array.isArray(group) ||
diff --git a/game/homeworld-revival-return.ts b/game/homeworld-revival-return.ts
index af1302c4..175e590d 100644
--- a/game/homeworld-revival-return.ts
+++ b/game/homeworld-revival-return.ts
@@ -136,7 +136,7 @@ function validateOriginal(frame: Original): void {
!text(frame.player) ||
!count(frame.turn, 1, Number.MAX_SAFE_INTEGER) ||
!count(frame.phase, 0, 8) ||
- !['normal', 'emperorExtra', 'ghola'].includes(frame.source) ||
+ !['normal', 'emperorExtra', 'ghola', 'nexusTleilaxu'].includes(frame.source) ||
(frame.source === 'ghola' ? !text(frame.card) : frame.card !== undefined) ||
!keys(frame.group, ['amount', 'elite', 'free']) ||
!count(frame.group.amount, 1) ||
diff --git a/game/nexus-tleilaxu-options.ts b/game/nexus-tleilaxu-options.ts
index 23c2f4c4..89a5d6a2 100644
--- a/game/nexus-tleilaxu-options.ts
+++ b/game/nexus-tleilaxu-options.ts
@@ -1,5 +1,19 @@
import type { Action, GameView } from './engine';
+
+export function nexusTleilaxuRevivalAction(
+ game:GameView,event:string,leader:string,amount:number,elite:number,
+):Action|null {
+ const offer=game.nexusTleilaxu?.secretAlly;
+ const owner=game.players.find(p=>p.id===game.me);
+ if(!offer||offer.blocked||!owner||game.status!=='playing'||game.phase!==4||
+ owner.ally||game.nexusCards?.card!=='tleilaxu'||offer.event!==event||
+ !offer.leaders.some(candidate=>candidate.id===leader)||
+ !Number.isSafeInteger(amount)||amount<0||amount>offer.maxForces||amount>(owner.spice??0)||
+ !Number.isSafeInteger(elite)||elite<0||elite>amount||elite>offer.maxElite||
+ amount-elite>owner.tanks-(owner.elites?.tanks??0))return null;
+ return {type:'nexusTleilaxuRevival',event,leader,amount,elite};
+}
/** The server's private offer binds the complete revealed replacement group.
* Do not reconstruct eligibility from private Face Dancer hands. */
export function nexusFaceDancersAction(
@@ -31,6 +45,14 @@ export function nexusFaceDancersAction(
/** All profiles share the legal replacement policy. There is no post-draw
* choice and no access to another player's hand or the reserve order. */
export function nexusTleilaxuBotActions(game: GameView): Action[] {
+ const revival=game.nexusTleilaxu?.secretAlly;
+ if(revival&&!revival.blocked&&revival.leaders.length) {
+ const owner=game.players.find(p=>p.id===game.me)!;
+ const amount=Math.min(revival.maxForces,owner.spice??0);
+ const elite=Math.max(0,amount-(owner.tanks-(owner.elites?.tanks??0)));
+ const action=nexusTleilaxuRevivalAction(game,revival.event,revival.leaders[0].id,amount,elite);
+ if(action)return [action];
+ }
const offer = game.nexusTleilaxu?.cunning;
const action = offer ? nexusFaceDancersAction(game, offer.event) : null;
return action ? [action] : [];
diff --git a/game/nexus-tleilaxu-revival.ts b/game/nexus-tleilaxu-revival.ts
new file mode 100644
index 00000000..f781e8cc
--- /dev/null
+++ b/game/nexus-tleilaxu-revival.ts
@@ -0,0 +1,342 @@
+import type { Game, Player } from './engine';
+import { DUKE_VIDAL_ID } from './duke-vidal';
+import { nexusCardMode } from './nexus-cards';
+import {
+ eliteRevivalRemaining,
+ leaderRevivalOptions,
+ normalForceRevivalLimit,
+} from './revival';
+
+/** The panel's own force group is fixed at five exact counters. */
+export const NEXUS_TLEILAXU_FORCE_GROUP_CAP = 5;
+/** Every counter of that group, typed or not, costs one bank spice; none is free. */
+export const NEXUS_TLEILAXU_FORCE_PRICE = 1;
+
+/**
+ * Provisional composed policy, not a publisher or user numerical ruling.
+ * `docs/NEXUS_TLEILAXU_RULES.md` leaves three questions open (independent versus
+ * ordinary force allowance, waived leader prerequisites, optional leader
+ * portion). This module deliberately answers them the conservative way and
+ * publishes the answer instead of narrowing the effect silently.
+ */
+export const NEXUS_TLEILAXU_POLICY =
+ 'Provisional composed policy. One combined action during a quiet Revival by the unallied actual holder of the Tleilaxu Nexus card in a game where Tleilaxu is absent: exactly one own dead leader (or an eligible Kwisatz Haderach / own shared Duke) returns free and uses the ordinary one-leader slot, and that leader portion is mandatory, so a force-only return is unavailable. The card waives only the unavailable-roster trigger, so repeated-death/face-down cycles, the one-leader slot and every special identity custody rule still gate candidates. Up to five exact physical counters return from the same shared ordinary ceiling, which this grant raises to at least five (earlier ordinary returns count against it, CHOAM\'s larger native ceiling is not reduced, and Recruits never doubles this fixed group); each counter costs one bank spice, typed counters included, and none is free or Cyborg-priced. Zero forces is permitted. Advanced Fedaykin/Sardaukar keep the one-per-turn cap across producers; Ixian Cyborgs have no matching cap. Paid card counters are not free forces.';
+
+export type TleilaxuSecretAllyKind = 'leader' | 'kwisatz' | 'duke';
+export type TleilaxuSecretAllyLeader = {
+ id: string;
+ name: string;
+ kind: TleilaxuSecretAllyKind;
+ /** True only when the card's waiver, rather than the ordinary roster trigger, offers this candidate. */
+ early: boolean;
+};
+export type TleilaxuSecretAllyOptions = {
+ event: string;
+ blocked: string | null;
+ leaders: TleilaxuSecretAllyLeader[];
+ maxForces: number;
+ maxElite: number;
+ policy: string;
+};
+export type TleilaxuSecretAllySelection = {
+ leader: string;
+ amount: number;
+ elite: number;
+};
+export type TleilaxuSecretAllyQuote = {
+ leader: string;
+ kind: TleilaxuSecretAllyKind;
+ amount: number;
+ elite: number;
+ /** Exact bank spice for this group; the leader adds nothing. */
+ cost: number;
+ /** Maximum legal counter count for the same state, so callers never guess the bound. */
+ limit: number;
+};
+/** Physical counter bounds of the card group for one holder and state. */
+export type TleilaxuSecretAllyForceBounds = {
+ /** Ordinary ceiling after this grant's floor of five. */
+ ceiling: number;
+ normalTanks: number;
+ eliteTanks: number;
+ /** Typed counters that may return under the one-per-turn cap and physical inventory. */
+ eliteRoom: number;
+ maxForces: number;
+ maxElite: number;
+};
+
+export type TleilaxuSecretAllyFailure =
+ | 'source'
+ | 'timing'
+ | 'leader'
+ | 'forces'
+ | 'price';
+
+export class TleilaxuSecretAllyError extends Error {
+ constructor(
+ message: string,
+ readonly reason: TleilaxuSecretAllyFailure,
+ ) {
+ super(message);
+ this.name = 'TleilaxuSecretAllyError';
+ }
+}
+
+const leaderBlock =
+ 'Tleilaxu Secret Ally needs one own dead leader, and zero forces does not replace it.';
+const noSource =
+ 'Only the actual holder of the Tleilaxu Nexus card may use Tleilaxu Secret Ally.';
+const whole = (n: unknown): n is number =>
+ Number.isSafeInteger(n) && (n as number) >= 0;
+/** Cheap Hero/Heroine are Treachery cards, never leader discs in custody. */
+const discId = (id: string) => !id.startsWith('treachery-');
+
+export const tleilaxuSecretAllyEvent = (
+ g: Pick,
+ p: Pick,
+) => JSON.stringify(['nexusTleilaxuSecretAlly', g.turn, g.phase, p.id]);
+
+function heldCard(g: Game, p: Player) {
+ return g.nexusCards?.cards?.hands?.[p.id] === 'tleilaxu';
+}
+
+/** Quiet Revival: no other interaction, reaction or pending revival owns the board. */
+function interrupted(g: Game) {
+ return !!(
+ g.response ||
+ g.decision ||
+ g.phaseOpening ||
+ g.pendingRevival ||
+ g.pendingKarama ||
+ g.pendingTreacheryDiscard ||
+ g.pendingNullentropy ||
+ g.pendingExchange ||
+ g.pendingRicheseGift ||
+ g.pendingAmbassador ||
+ g.pendingCapture ||
+ g.battle ||
+ g.nexusTraitorPending ||
+ g.nexusTraitorParent ||
+ g.nexusCards?.phase?.stage === 'drawing' ||
+ (g.homeworldRevivalReturn && g.homeworldRevivalReturn.stage !== 'complete')
+ );
+}
+
+/** Actual held card, printed mode, unallied actor and quiet Revival only.
+ * A projected offer is not authority: validate the physical held card again. */
+function sourceState(
+ g: Game,
+ p: Player,
+): { reason: TleilaxuSecretAllyFailure; message: string } | null {
+ if (!heldCard(g, p)) return { reason: 'source', message: noSource };
+ if (p.ally)
+ return { reason: 'source', message: 'An allied player cannot use a Nexus card.' };
+ let mode: string;
+ try {
+ mode = nexusCardMode(
+ 'tleilaxu',
+ p.faction,
+ g.players.map((seat) => seat.faction),
+ );
+ } catch {
+ return { reason: 'source', message: 'The Nexus custody does not describe this table.' };
+ }
+ if (mode !== 'secretAlly')
+ return {
+ reason: 'source',
+ message:
+ 'Tleilaxu Secret Ally needs Tleilaxu absent from the seated roster and a non-Tleilaxu holder.',
+ };
+ if (g.status !== 'playing')
+ return { reason: 'timing', message: 'Nexus effects belong to the started game.' };
+ if (g.phase !== 4)
+ return { reason: 'timing', message: 'Use Tleilaxu Secret Ally during Revival.' };
+ if (interrupted(g))
+ return {
+ reason: 'timing',
+ message: 'Finish the current interaction before using Tleilaxu Secret Ally.',
+ };
+ return null;
+}
+
+export function tleilaxuSecretAllyBlock(g: Game, p: Player): string | null {
+ return sourceState(g, p)?.message ?? null;
+}
+
+/** An own physical disc, one of the only three leader identities this grant may return. */
+function ownDisc(p: Player, id: string) {
+ const leader = p.leaders.find((candidate) => candidate.id === id);
+ return !!leader &&
+ discId(leader.id) &&
+ leader.faction === p.faction &&
+ leader.dead === true &&
+ !leader.capturedBy &&
+ !leader.gholaBy;
+}
+
+/**
+ * Canonical ordinary/Duke/Kwisatz eligibility plus the card's single waiver.
+ * The waiver is expressed by pretending the ordinary cycle is open at level one;
+ * repeated-death matching, the ordinary one-leader slot, the Auditor's own
+ * special projection, Duke custody and every other eligibility rule stay the
+ * canonical ones. Nothing here is written back: the caller must never store the
+ * synthetic cycle, or an early grant would open later ordinary returns.
+ */
+function leaderCandidates(
+ g: Game,
+ p: Player,
+): { leaders: TleilaxuSecretAllyLeader[]; dukeBlocked: string | null; cycleBlock: string | null } {
+ const canonical = leaderRevivalOptions(g, p);
+ const waived = leaderRevivalOptions(g, {
+ ...p,
+ revivalCycle: Math.max(1, p.revivalCycle),
+ });
+ const canonicalIds = new Set(canonical.leaders.map((option) => option.id));
+ const blockedEarly = (id: string) =>
+ !!g.revivalRules?.earlyBlocked?.includes(`${p.id}:${id}`);
+ const leaders: TleilaxuSecretAllyLeader[] = [];
+ const seen = new Set();
+ const push = (id: string, name: string, early: boolean) => {
+ if (seen.has(id) || (early && blockedEarly(id))) return;
+ // The shared Duke is not an own disc: its control/custody was already
+ // resolved by the canonical Ecaz quote, so only own discs are re-checked.
+ if (id !== DUKE_VIDAL_ID && !ownDisc(p, id)) return;
+ seen.add(id);
+ leaders.push({
+ id,
+ name,
+ kind: id === DUKE_VIDAL_ID ? 'duke' : 'leader',
+ early,
+ });
+ };
+ for (const option of canonical.leaders)
+ push(option.id, option.name, !canonicalIds.has(option.id));
+ for (const option of waived.leaders)
+ push(option.id, option.name, !canonicalIds.has(option.id));
+ if (canonical.kwisatz || waived.kwisatz) {
+ const early = !canonical.kwisatz && !blockedEarly('kwisatz');
+ if (early || canonical.kwisatz)
+ leaders.push({ id: 'kwisatz', name: 'Kwisatz Haderach', kind: 'kwisatz', early });
+ }
+ return {
+ leaders,
+ dukeBlocked: canonical.dukeBlocked,
+ cycleBlock: canonical.cycleBlock,
+ };
+}
+
+/**
+ * Shared ordinary ceiling raised to at least five, minus earlier ordinary
+ * returns, restricted to the exact physical typed counters in Tanks and to the
+ * Advanced one-per-turn typed cap. Force and elite maxima are always jointly
+ * attainable, so a caller may use both at once.
+ */
+export function tleilaxuSecretAllyForceBounds(
+ g: Game,
+ p: Player,
+): TleilaxuSecretAllyForceBounds {
+ const ceiling = Math.max(
+ normalForceRevivalLimit(g, p),
+ NEXUS_TLEILAXU_FORCE_GROUP_CAP,
+ );
+ const tanks = Math.max(0, p.tanks);
+ const eliteTanks = Math.max(0, p.elites?.tanks ?? 0);
+ const normalTanks = Math.max(0, tanks - eliteTanks);
+ const eliteRoom = Math.min(eliteTanks, eliteRevivalRemaining(p, g.advanced));
+ const maxForces = Math.min(
+ NEXUS_TLEILAXU_FORCE_GROUP_CAP,
+ Math.max(0, ceiling - p.revived),
+ normalTanks + eliteRoom,
+ );
+ return {
+ ceiling,
+ normalTanks,
+ eliteTanks,
+ eliteRoom,
+ maxForces,
+ maxElite: Math.min(maxForces, eliteRoom),
+ };
+}
+
+/** Owner-only projection input; blocked reasons name the real current limitation. */
+export function tleilaxuSecretAllyOptions(
+ g: Game,
+ p: Player,
+): TleilaxuSecretAllyOptions {
+ const event = tleilaxuSecretAllyEvent(g, p);
+ const blocked = tleilaxuSecretAllyBlock(g, p);
+ if (blocked)
+ return {
+ event,
+ blocked,
+ leaders: [],
+ maxForces: 0,
+ maxElite: 0,
+ policy: NEXUS_TLEILAXU_POLICY,
+ };
+ const bounds = tleilaxuSecretAllyForceBounds(g, p);
+ const { leaders, dukeBlocked, cycleBlock } = leaderCandidates(g, p);
+ return {
+ event,
+ blocked: leaders.length
+ ? null
+ : dukeBlocked ?? cycleBlock ?? leaderBlock,
+ leaders,
+ maxForces: bounds.maxForces,
+ maxElite: bounds.maxElite,
+ policy: NEXUS_TLEILAXU_POLICY,
+ };
+}
+
+/**
+ * Pure quote, no mutation, no randomness, no stored offer. Throws when the
+ * physical card, actor, phase, quiet window, leader identity, exact typed group
+ * or bank spice cannot support the selection, so a committed action always has
+ * a legal quote. `limit` repeats the maximum legal amount for the same state.
+ */
+export function quoteTleilaxuSecretAlly(
+ g: Game,
+ p: Player,
+ selection: TleilaxuSecretAllySelection,
+): TleilaxuSecretAllyQuote {
+ const source = sourceState(g, p);
+ if (source) throw new TleilaxuSecretAllyError(source.message, source.reason);
+ const { leaders, dukeBlocked, cycleBlock } = leaderCandidates(g, p);
+ const chosen = leaders.find((leader) => leader.id === selection?.leader);
+ if (!chosen)
+ throw new TleilaxuSecretAllyError(
+ leaders.length ? 'Choose one offered own leader.' : dukeBlocked ?? cycleBlock ?? leaderBlock,
+ 'leader',
+ );
+ const amount = selection.amount;
+ const elite = selection.elite;
+ const bounds = tleilaxuSecretAllyForceBounds(g, p);
+ if (!whole(amount) || !whole(elite) || elite > amount)
+ throw new TleilaxuSecretAllyError(
+ 'The card group needs a whole normal/typed counter split.',
+ 'forces',
+ );
+ if (
+ amount > bounds.maxForces ||
+ elite > bounds.eliteRoom ||
+ amount - elite > bounds.normalTanks
+ )
+ throw new TleilaxuSecretAllyError(
+ `The card group must fit ${bounds.maxForces} physical counters (at most ${bounds.maxElite} typed) from the shared ordinary ceiling of ${bounds.ceiling}.`,
+ 'forces',
+ );
+ const cost = amount * NEXUS_TLEILAXU_FORCE_PRICE;
+ if (cost > p.spice)
+ throw new TleilaxuSecretAllyError(
+ 'Not enough spice for one bank spice per returned counter.',
+ 'price',
+ );
+ return {
+ leader: chosen.id,
+ kind: chosen.kind,
+ amount,
+ elite,
+ cost,
+ limit: bounds.maxForces,
+ };
+}
diff --git a/game/reference.ts b/game/reference.ts
index c5ca5881..68734381 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -4562,7 +4562,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Ixian Cunning is used in your battle before submitting your Battle Plan. Your Suboids then count as strength one without spice support in every battle for the rest of the turn, in Basic and Advanced. Cyborg strength and support are unchanged. Earlier inspected or promised plan elements remain binding; you cannot activate the card if the new strength would make them impossible.',
'Ixian Secret Ally has a fresh local ixian-replacement profile for normal paid or printed Karama-paid auction purchases in classic base-deck/Nexus Basic or Advanced games. A supported unallied buyer may spend its actual Nexus to discard exactly the just-purchased card and draw the real deck top privately; Pass keeps both cards. Full-hand replacement and empty-deck same-card redraw are allowed, and original payment/income/auction continuation occur once. Harkonnen buyers, cache/Black Market/special origins and combined modules remain guarded. The neutral buyer choice is an inferred privacy convention, not publisher timing. See the dedicated guide for bounded verified native, private, recovery and phone evidence; this is not full-module certification.',
'Ixian Betrayal has a separate fresh native-Ixian ixian-betrayal profile for both original Bidding inspection/draw and Advanced Technology attempts. Native counters resolve first; then every publicly possible unallied rival held-Nexus seat acknowledges uniformly before the effect. A legal Use spends one physical Ixian Nexus to deny only that attempt; all-pass preserves the native effect once. No completed-card undo or whole-phase ban is added. This bounded ordering/privacy is an inference, not universal publisher priority; see its bounded verified native/private/recovery/phone evidence and unchanged Richese/module/public/deployment gates.',
- 'Tleilaxu Secret Ally revival remains unavailable pending its ordinary-allowance, leader-eligibility and optional-leader rulings. The printed fixed force price does not resolve those questions.',
+ 'With Tleilaxu absent, its actual unallied Nexus holder may use a quiet Revival control to return one mandatory own dead leader free and up to five exact physical counters for one bank spice each. The provisional policy uses the ordinary leader slot and expands the shared ordinary ceiling to at least five, counting prior returns and retaining larger native/Recruits allowance. Only the unopened roster trigger is waived: repeated deaths, actual stars/Cyborgs and special custody retain canonical rules, and ordinary cycle history is not fabricated. Zero forces is legal; force-only use is not. Spend the physical card once; native Tleilaxu income and cancellation are not imported.',
'In Advanced play, Bene Gesserit Cunning converts selected whole advisor territories during your own Shipment and Movement action. All your counters in each selected territory become fighters together across sectors. It spends the Nexus card once, with no spice, shipment or movement cost.',
'One Karama response covers the entire declared territory set. Cancellation keeps every selected group as advisors and leaves the Nexus card spent. Existing territory restrictions still apply; fresh accompanied advisors and storm-related conversion remain unavailable pending their rulings.',
'Bene Gesserit Secret Ally borrowed Voice remains unavailable pending its cancellation and timing interpretation. Its printed permission does not make the other unfinished effects available.',
@@ -4596,11 +4596,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: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, Face Dancers, Suboids, advisors, native Voice, Emperor Sardaukar and Betrayal, bounded Fremen revival/Cunning, CHOAM effects, Moritani Cunning/Betrayal, Ecaz Betrayal, native Duke Cunning and private Secret Ally inquiry, Richese shipment and bounded auction Betrayal, and bounded Emperor bank-auction purchase are integrated. Ecaz Cunning transfers the existing Duke at a quiet Battle boundary with a real card and distinct turn-end expiry. Ecaz inquiry scope/audience and Emperor purchase timing are provisional. Borrowed Voice, exceptional Duke custody, other reactions, Tleilaxu revival, Fremen worm protection and combined Cunning interactions remain unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts'] },
- { area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation, advisor selection, temporary Sardaukar and CHOAM effects, Moritani Betrayal, Ecaz Betrayal and owner-only Secret Ally inquiry, native Duke Cunning, Richese shipment and bounded auction Betrayal, Emperor purchase and Fremen Cunning controls are connected. Remaining effect controls are unfinished.', 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'] },
- { area: 'AI', status: 'Partial', detail: 'All four profiles choose legal actions through private offers, including Moritani/Ecaz Betrayal, Ecaz Secret Ally inquiry and native Duke Cunning, Fremen Cunning, Emperor purchase and bounded Richese auction Betrayal. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] },
+ { area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle and existing native/borrowed effects remain connected. Tleilaxu Secret Ally now returns one free mandatory own leader with up to five exact paid counters, sharing ordinary force allowance and the leader slot under an explicit provisional policy. Original cycle/custody, typed caps, bank payment, skill return and Homeworld force source remain connected. Borrowed Voice, exceptional custody, worm-protection responses and broader combinations remain open.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts'] },
+ { area: 'Player controls', status: 'Partial', detail: 'Existing private lifecycle and effect controls remain. Tleilaxu Secret Ally adds owner-only free-leader and exact paid-force/elite selection, authoritative price, zero-force use and a visible provisional-policy explanation. The spent card removes the offer and original Revival Ready remains usable. Remaining borrowed/reactive effects and complete modes are open.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx'] },
+ { area: 'AI', status: 'Partial', detail: 'All four legal profiles retain existing private effect policies and now execute actual Tleilaxu Secret Ally own-leader/five-paid-counter returns through the shared offered selection. Native Cunning priority/private boundaries remain. Complete module strategy and calibration are deferred and unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and bounded runtime contracts retain explicit unresolved Ecaz Duke capture/Tanks/Ghola and inquiry audience/scope, Fremen nested Cunning, Great Maker, combined modules, fresh advisors, storm and borrowed Voice. Richese auction Betrayal has a separate cost/prepayment/privacy contract and excluded-producer boundaries. Guild Betrayal separately records full funded payment, own pre-funding, donor escrow once, the printed full-Junction override, four native adapters and neutral privacy; ordinary occupation/rounding and general response-priority questions remain unresolved.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md'] },
- { area: 'Verification', status: 'Partial', detail: 'Mixed E1/E2 and paired E3 adds31 physical programmes plus2 Loyalty-stock regressions, final489/489 affected checks across42 files and types/lint. All64 original six-seat families finish258/288 games with338931/339426 actions and9013 JSON across three frozen trees and preserved-prefix continuations. Native Ecaz assassination, separate physical Loyalty stock and exact paired overlap admission are repaired; all30 remaining traces retain printed source guards. Actual390px original human Ixian offer/training, typed shipment/free Suboid plan and paired Ecaz foreign arrival/living shared Duke preserve20 counters. Earlier evidence remains canonical; not complete modes, new assurance or protected deployed acceptance.', evidence: ['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md'] },
+ { area: 'Verification', status: 'Partial', detail: 'Current Tleilaxu revival batch passes141/141 in9 related files, types/lint and build. Genuine Basic/Advanced weapon death/eight-counter exchange, worm alliance and actual Nexus draw feed leader/paid/zero returns, both shared-allowance orders, stale/invalid rejection and the next real turn. Actual390px Dr. Yueh/five return changes spice11 to6, Tanks8 to3 and reserves10 to15 with original cycle0/deaths1, one card discard and Ready restored; no overflow/errors. Four policies execute the actual return. Earlier mixed-family evidence stays source-bound; complete modes, assurance and deployed acceptance remain open.', evidence: ['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts'] },
],
},
{
diff --git a/game/revival.ts b/game/revival.ts
index 2f8e67e6..f3e3dfa7 100644
--- a/game/revival.ts
+++ b/game/revival.ts
@@ -17,6 +17,8 @@ type RevivalRateContext = Pick<
>;
export type RevivalRules = {
expanded: string[];
+ /** Provisional shared five-return permission from the actual absent-faction Nexus grant. */
+ nexusTleilaxu?: string[];
choamBlocked?: boolean;
freeBlocked?: string[];
limitBlocked: boolean;
@@ -38,6 +40,7 @@ export function forceRevivalLimit(
p: Pick,
) {
if (p.faction === 'choam' && !g.revivalRules?.choamBlocked) return 20;
+ if (g.revivalRules?.nexusTleilaxu?.includes(p.id)) return 5;
if (g.revivalRules?.limitBlocked) return 3;
if (p.faction === 'tleilaxu') return 20;
return g.revivalRules?.expanded.includes(p.id) ? 5 : 3;
diff --git a/tests/fixture-nexus-tleilaxu-secret-ally.ts b/tests/fixture-nexus-tleilaxu-secret-ally.ts
new file mode 100644
index 00000000..facca913
--- /dev/null
+++ b/tests/fixture-nexus-tleilaxu-secret-ally.ts
@@ -0,0 +1,370 @@
+import assert from 'node:assert/strict';
+import {
+ applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer,
+ type Action, type Game, type Player,
+} from '../game/engine';
+import type { FactionId } from '../game/catalog';
+import { location, territory, validGameLocation } from '../game/board';
+import { treacheryDeck } from '../game/cards';
+import { validateNexusCards } from '../game/nexus-cards';
+import { tleilaxuSecretAllyEvent, tleilaxuSecretAllyOptions } from '../game/nexus-tleilaxu-revival';
+import { shipmentAvailable } from '../game/shipment-opportunity';
+import { traitorDeck } from '../game/traitors';
+import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
+
+/** One real native step: the actor is always the seat the engine expects. */
+export type NexusTleilaxuSecretAllyStep = { actor: string; action: Action };
+export type NexusTleilaxuSecretAllyFixtureOptions = {
+ /** Only an original undealt classic lobby, or omit for a fresh Atreides/Guild/Emperor table. */
+ initial?: Game;
+ advanced?: boolean;
+ seatIds?: readonly [string, string, string];
+};
+export type NexusTleilaxuSecretAllyDeadLeader = { id: string; name: string; deaths: number };
+export type NexusTleilaxuSecretAllyFixture = {
+ initial: Game;
+ afterSetup: Game;
+ beforeShipment: Game;
+ shipment: NexusTleilaxuSecretAllyStep;
+ beforeBattle: Game;
+ battleActions: NexusTleilaxuSecretAllyStep[];
+ afterBattle: Game;
+ beforeBlow: Game;
+ beforeDraw: Game;
+ draw: NexusTleilaxuSecretAllyStep;
+ afterDraw: Game;
+ /** Quiet turn-two Revival with the physical Tleilaxu card held by an unallied owner. */
+ game: Game;
+ owner: string;
+ opponent: string;
+ partner: string;
+ ownerFaction: FactionId;
+ deadLeader: NexusTleilaxuSecretAllyDeadLeader;
+ nexusEvent: string;
+ actions: NexusTleilaxuSecretAllyStep[];
+ staging: string[];
+ /** Raw JSON-safe scenario facts for the parent's throwaway API and phone smoke. */
+ scenario: Record;
+};
+export function nexusTleilaxuSecretAllyPlayer(game: Game, id: string): Player {
+ const player = game.players.find(candidate => candidate.id === id);
+ assert.ok(player, `Missing classic seat ${id}`);
+ return player;
+}
+export const nexusTleilaxuSecretAllyClean = (game: Game): boolean =>
+ !game.phaseOpening && !game.response && !game.decision;
+
+/** Scoped producer entropy: every original shuffle keeps its printed order, so
+ * the real deal, real draws and real battle chronology stay reproducible. Only
+ * the initializer and genuine actions run inside this window. */
+function identityRandom(run: () => T): T {
+ const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
+ crypto.getRandomValues = (array: V): V => {
+ assert.ok(array);
+ new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
+ return array;
+ };
+ try { return run(); }
+ finally {
+ if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
+ else Reflect.deleteProperty(crypto, 'getRandomValues');
+ }
+}
+
+/** The closing Nexus draw is a private table choice, so the shared native
+ * continuation stops there instead of drawing a card for a seat. */
+export function nextNexusTleilaxuSecretAllyNativeStep(
+ game: Game,
+): NexusTleilaxuSecretAllyStep | null {
+ if (game.nexusCards?.phase?.stage === 'drawing' && nexusTleilaxuSecretAllyClean(game)) return null;
+ return nextStrongholdFactionsNativeStep(game);
+}
+export function stepNexusTleilaxuSecretAlly(
+ game: Game,
+ actions?: NexusTleilaxuSecretAllyStep[],
+): Game {
+ const next = nextNexusTleilaxuSecretAllyNativeStep(game);
+ assert.ok(next, `Original continuation stopped before ${game.status}/${game.turn}/${game.phase}`);
+ actions?.push(structuredClone(next));
+ return applyAction(game, next.actor, next.action);
+}
+export function advanceNexusTleilaxuSecretAlly(
+ game: Game,
+ until: (state: Game) => boolean,
+ actions?: NexusTleilaxuSecretAllyStep[],
+): Game {
+ for (let count = 0; count < 2000; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished');
+ game = stepNexusTleilaxuSecretAlly(game, actions);
+ }
+ throw Error('Original absent-Tleilaxu Nexus chronology did not reach its boundary');
+}
+/** Turn-pinned phase boundary; a turn change is an error, not a success. */
+export function advanceNexusTleilaxuSecretAllyToPhase(
+ game: Game,
+ phase: number,
+ actions?: NexusTleilaxuSecretAllyStep[],
+): Game {
+ const turn = game.turn;
+ const reached = advanceNexusTleilaxuSecretAlly(game,
+ state => state.turn === turn && state.phase >= phase && nexusTleilaxuSecretAllyClean(state),
+ actions);
+ assert.equal(reached.turn, turn, 'The requested phase belongs to the current turn');
+ return reached;
+}
+
+/** Original undealt Shai-Hulud singleton to the front of the physical Spice Deck;
+ * nothing is added, removed or replaced. */
+function frontUndealtShaiHulud(game: Game, staging: string[]): void {
+ const index = game.spiceDeck.findIndex(card =>
+ 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
+ assert.ok(index >= 0, 'The original undealt Spice Deck still holds a Shai-Hulud singleton');
+ const [card] = game.spiceDeck.splice(index, 1);
+ game.spiceDeck.unshift(card);
+ staging.push('Ordered one original undealt Shai-Hulud Spice singleton to the front; not a natural shuffle history.');
+}
+
+/** Fresh classic Nexus admission only: base seats, no expansions, offline audit seam. */
+export function initializeNexusTleilaxuSecretAllySetup(
+ options: NexusTleilaxuSecretAllyFixtureOptions = {},
+): Game {
+ const advanced = options.advanced ?? false;
+ if (options.initial) {
+ let supplied = structuredClone(options.initial);
+ assert.equal(supplied.status, 'lobby');
+ assert.equal(supplied.advanced, advanced);
+ assert.equal(supplied.turn, 1);
+ assert.equal(supplied.phase, 0);
+ assert.ok(!supplied.setupStage);
+ assert.deepEqual([...supplied.expansions], []);
+ assert.equal(supplied.players.length, 3);
+ assert.ok(supplied.players.every(player => player.reserves === 20 && player.tanks === 0 &&
+ player.spice === 0 && Object.keys(player.forces).length === 0 && player.hand.length === 0),
+ 'Only a fresh undealt classic lobby may be continued');
+ if (!supplied.nexusCards) supplied.nexusCards = { cards: null, phase: null };
+ for (const player of supplied.players) if (!player.ready) supplied=applyAction(supplied, player.id, { type: 'ready' });
+ return identityRandom(() => initializeNexusGameForAudit(supplied));
+ }
+ const ids = options.seatIds ?? ['atreides', 'guild', 'emperor'];
+ assert.equal(new Set(ids).size, 3);
+ let game = createGame('NEXUSTLEILAXUSECRETALLY', newPlayer(ids[0], 'Atreides', 'atreides'), advanced);
+ joinGame(game, newPlayer(ids[1], 'Guild', 'guild'));
+ joinGame(game, newPlayer(ids[2], 'Emperor', 'emperor'));
+ assert.deepEqual(game.expansions, []);
+ assert.ok(game.players.every(player => player.faction === 'atreides' || player.faction === 'guild' || player.faction === 'emperor'));
+ game.nexusCards ??= { cards: null, phase: null };
+ for (const player of game.players) if (!player.ready) game=applyAction(game, player.id, { type: 'ready' });
+ return identityRandom(() => initializeNexusGameForAudit(game));
+}
+
+/** Physical census of the original pieces; no module owner is assumed. */
+export function nexusTleilaxuSecretAllyInventory(game: Game): void {
+ const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
+ ...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
+ assert.deepEqual(cards.map(card => card.id).sort(), treacheryDeck(game.expansions).map(card => card.id).sort());
+ assert.equal(new Set(cards.map(card => card.id)).size, cards.length);
+ for (const player of game.players) {
+ assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20, player.faction);
+ if (player.elites) {
+ assert.equal(player.elites.reserves + player.elites.tanks +
+ Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0),
+ player.faction === 'emperor' ? 5 : 3, player.faction);
+ assert.ok(player.elites.reserves <= player.reserves && player.elites.tanks <= player.tanks);
+ }
+ }
+ assert.deepEqual([...(game.traitorReserve ?? []),
+ ...game.players.flatMap(player => [...player.traitors, ...player.traitorChoices])].sort(),
+ traitorDeck(game.players, false).sort());
+ assert.ok(game.nexusCards?.cards, 'The Nexus inventory must exist');
+ validateNexusCards(game.nexusCards.cards, game.players);
+}
+
+/** Genuine Basic/Advanced absent-Tleilaxu chronology: one real Emperor shipment
+ * into the printed Atreides position, one real battle that kills an own leader and
+ * eight own counters, the real turn-two Shai-Hulud Nexus occurrence with its real
+ * reciprocal alliance, and the real closing Nexus draw. No hand, wallet, board
+ * counter, Tanks, alliance, phase, death or pending frame is written. */
+export function createNexusTleilaxuSecretAllyFixture(
+ options: NexusTleilaxuSecretAllyFixtureOptions = {},
+): NexusTleilaxuSecretAllyFixture {
+ const initial = initializeNexusTleilaxuSecretAllySetup(options);
+ return identityRandom(() => {
+ const advanced = initial.advanced;
+ const actions: NexusTleilaxuSecretAllyStep[] = [], staging: string[] = [];
+ let game = advanceNexusTleilaxuSecretAlly(initial, state => state.status === 'playing', actions);
+ const afterSetup = structuredClone(game);
+ assert.ok(game.players.every(player => player.traitors.length > 0 && player.hand.length > 0),
+ 'The real setup deals traitors and starting Treachery Cards');
+ const owner = game.players.find(player => player.faction === 'atreides')!.id;
+ const opponent = game.players.find(player => player.faction === 'emperor')!.id;
+ const partner = game.players.find(player => player.faction === 'guild')!.id;
+ assert.equal(game.players.length, 3, 'One absent-faction holder, one aggressor and one alliance partner');
+ assert.ok(!game.players.some(player => player.faction === 'tleilaxu'), 'Secret Ally needs Tleilaxu absent');
+ assert.ok(game.players.every(player => player.ally === null));
+ const weapon = nexusTleilaxuSecretAllyPlayer(game, opponent).hand
+ .find(card => card.kind === 'projectile' || card.kind === 'poison');
+ assert.ok(weapon, 'The real Emperor deal must hold one printed leader-killing weapon');
+ nexusTleilaxuSecretAllyInventory(game);
+
+ // ---- Turn one, real Movement: the Emperor ships one ordinary counter into Arrakeen.
+ game = advanceNexusTleilaxuSecretAllyToPhase(game, 5, actions);
+ for (let count = 0; game.active !== opponent; count++) {
+ assert.ok(count < 12, 'Every original seat receives one real Movement turn');
+ game = stepNexusTleilaxuSecretAlly(game, actions);
+ }
+ const beforeShipment = structuredClone(game);
+ const sector = territory('arrakeen').sectors[0];
+ const target = location('arrakeen', sector);
+ assert.ok(validGameLocation(game, 'arrakeen', sector));
+ assert.ok(shipmentAvailable(game, nexusTleilaxuSecretAllyPlayer(game, opponent)),
+ 'The Emperor still holds the original turn-one shipment');
+ const shipment: NexusTleilaxuSecretAllyStep = { actor: opponent, action: {
+ type: 'ship', territory: 'arrakeen', sector, amount: 1, elite: 0, allyPayment: 0 } };
+ actions.push(structuredClone(shipment));
+ game = applyAction(game, opponent, shipment.action);
+ game=advanceNexusTleilaxuSecretAlly(game,nexusTleilaxuSecretAllyClean,actions);
+ assert.equal(nexusTleilaxuSecretAllyPlayer(game, opponent).forces[target], 1);
+ assert.equal(nexusTleilaxuSecretAllyPlayer(game, owner).forces[target], 10,
+ 'The printed Atreides position keeps its full real setup army');
+ staging.push('The real Emperor seat ships one ordinary reserve counter into the printed Atreides Arrakeen location; no counter, bank or phase is written.');
+
+ // ---- Turn one, real Battle: the Emperor's real weapon kills the defending disc.
+ game = advanceNexusTleilaxuSecretAllyToPhase(game, 6, actions);
+ const chooser = game.active!;
+ assert.ok([owner, opponent].includes(chooser), 'The real battle chooser must be one of the two co-located seats');
+ const battleActions: NexusTleilaxuSecretAllyStep[] = [];
+ if (!game.battle) {
+ const choose: NexusTleilaxuSecretAllyStep = { actor: chooser, action: {
+ type: 'chooseBattle', territory: 'arrakeen',
+ target: chooser === owner ? opponent : owner } };
+ battleActions.push(structuredClone(choose));
+ actions.push(structuredClone(choose));
+ game = applyAction(game, choose.actor, choose.action);
+ }
+ game = advanceNexusTleilaxuSecretAlly(game,
+ state => nexusTleilaxuSecretAllyClean(state) && !nextNexusTleilaxuSecretAllyNativeStep(state), actions);
+ assert.ok(game.battle && !game.battle.revealed, 'Both combatants still seal a private plan');
+ const beforeBattle = structuredClone(game);
+ const ownLeader = nexusTleilaxuSecretAllyPlayer(game, owner).leaders
+ .filter(leader => !leader.dead).sort((a, b) => a.strength - b.strength)[0];
+ const otherLeader = nexusTleilaxuSecretAllyPlayer(game, opponent).leaders
+ .filter(leader => !leader.dead).sort((a, b) => a.strength - b.strength)[0];
+ assert.ok(ownLeader && otherLeader);
+ // Basic dials the lost exchange directly; Advanced expresses the same eight
+ // committed ordinary counters on its half-step dial with no support spice.
+ const plans: NexusTleilaxuSecretAllyStep[] = [
+ { actor: owner, action: { type: 'battlePlan', dial: advanced ? 4 : 8, support: 0,
+ leader: ownLeader.id, weapon: null, defense: null } },
+ { actor: opponent, action: { type: 'battlePlan', dial: 0, support: 0,
+ leader: otherLeader.id, weapon: weapon.id, defense: null } },
+ ];
+ for (const plan of plans) {
+ battleActions.push(structuredClone(plan));
+ actions.push(structuredClone(plan));
+ game = applyAction(game, plan.actor, plan.action);
+ }
+ assert.ok(game.battle?.revealed, 'Both sealed plans are revealed');
+ game = advanceNexusTleilaxuSecretAlly(game, state => state.turn === 2 && state.phase === 1 &&
+ nexusTleilaxuSecretAllyClean(state) && !state.spiceSequence && !state.spiceResolution &&
+ !state.spiceWindow && !state.nexus && state.ready.length === 0, actions);
+ const afterBattle = structuredClone(game);
+ const deadLeader = nexusTleilaxuSecretAllyPlayer(game, owner).leaders
+ .find(leader => leader.id === ownLeader.id)!;
+ assert.equal(deadLeader.dead, true, 'The real opposing weapon killed this own leader disc');
+ assert.equal(deadLeader.deaths, 1);
+ assert.equal(nexusTleilaxuSecretAllyPlayer(game, owner).tanks, 8,
+ 'Exactly the eight real dialed ordinary counters reached the Tanks');
+ staging.push('Real turn-one battle in the printed Atreides stronghold: the opposing printed weapon killed one own disc and the real exchange sent eight ordinary counters to the Tanks.');
+
+ // ---- Turn two, real Spice Blow and Nexus: one Shai-Hulud occurrence and the real draw.
+ const beforeBlow = structuredClone(game);
+ frontUndealtShaiHulud(game, staging);
+ game = advanceNexusTleilaxuSecretAlly(game,
+ state => state.nexus === true && !state.spiceWindow && nexusTleilaxuSecretAllyClean(state), actions);
+ assert.equal(game.nexus, true, 'The real Shai-Hulud appearance opened the Nexus');
+ const otherSeats = game.players.filter(player => player.id !== owner).map(player => player.id);
+ assert.deepEqual([...otherSeats].sort(), [opponent, partner].sort());
+ const alliance: NexusTleilaxuSecretAllyStep[] = [
+ { actor: otherSeats[0], action: { type: 'alliance', target: otherSeats[1] } },
+ { actor: otherSeats[1], action: { type: 'alliance', target: otherSeats[0] } },
+ ];
+ for (const entry of alliance) {
+ actions.push(structuredClone(entry));
+ game = applyAction(game, entry.actor, entry.action);
+ }
+ assert.equal(nexusTleilaxuSecretAllyPlayer(game, opponent).ally, partner);
+ assert.equal(nexusTleilaxuSecretAllyPlayer(game, owner).ally, null, 'The holder must stay unallied');
+ staging.push('The two non-holder seats form the real reciprocal Nexus alliance the closing window requires; the holder stays unallied.');
+ game = advanceNexusTleilaxuSecretAlly(game,
+ state => state.nexusCards?.phase?.stage === 'drawing', actions);
+ assert.equal(game.turn, 2);
+ assert.equal(game.phase, 1);
+ const phase = game.nexusCards!.phase!;
+ assert.deepEqual(phase.eligible, [owner], 'Only the unallied holder receives the closing opportunity');
+ assert.deepEqual(phase.done, []);
+ const cards = game.nexusCards!.cards!;
+ assert.equal(cards.hands[owner], null, 'No Nexus hand is granted before the real draw');
+ const index = cards.deck.indexOf('tleilaxu');
+ assert.ok(index >= 0, 'The twelve-card Nexus inventory still holds the physical Tleilaxu singleton');
+ cards.deck.unshift(cards.deck.splice(index, 1)[0]);
+ staging.push('Ordered the original undealt Tleilaxu Nexus singleton before the real closing draw; no hand is granted.');
+ const beforeDraw = structuredClone(game);
+ const draw: NexusTleilaxuSecretAllyStep = { actor: owner, action: { type: 'nexusCardChoice',
+ turn: game.turn, card: cards.hands[owner], choice: 'draw', ownRedraws: 0 } };
+ actions.push(structuredClone(draw));
+ game = applyAction(game, owner, draw.action);
+ assert.equal(game.nexusCards!.cards!.hands[owner], 'tleilaxu',
+ 'The real closing draw puts the physical Tleilaxu card in the holder hand');
+ assert.equal(game.nexusCards!.phase?.stage, 'complete');
+ const afterDraw = structuredClone(game);
+ nexusTleilaxuSecretAllyInventory(game);
+
+ // ---- Quiet turn-two Revival with the real held card.
+ game = advanceNexusTleilaxuSecretAllyToPhase(game, 4, actions);
+ assert.equal(game.turn, 2);
+ assert.equal(game.phase, 4);
+ assert.ok(nexusTleilaxuSecretAllyClean(game), 'The card needs the quiet Revival window');
+ assert.equal(game.nexusCards!.phase?.stage, 'complete');
+ const holder = nexusTleilaxuSecretAllyPlayer(game, owner);
+ const dead = holder.leaders.find(leader => leader.dead);
+ assert.equal(dead?.id, deadLeader.id);
+ assert.equal(holder.tanks, 8, 'The real Tanks custody survives Movement, Battle and Collection');
+ const offer = tleilaxuSecretAllyOptions(game, holder);
+ assert.equal(offer.blocked, null, 'The physical state must support the owner-only offer');
+ assert.deepEqual(offer.leaders.map(leader => leader.id), [deadLeader.id]);
+ assert.equal(offer.maxForces, 5);
+ assert.equal(offer.maxElite, 0, 'This faction owns no typed counters');
+ const nexusEvent = tleilaxuSecretAllyEvent(game, holder);
+ assert.equal(offer.event, nexusEvent);
+ nexusTleilaxuSecretAllyInventory(game);
+
+ const deadLeaderFact: NexusTleilaxuSecretAllyDeadLeader = {
+ id: deadLeader.id, name: deadLeader.name, deaths: deadLeader.deaths,
+ };
+ const scenario = {
+ code: game.code,
+ advanced: game.advanced,
+ turn: game.turn,
+ phase: game.phase,
+ owner, opponent, partner,
+ ownerFaction: holder.faction,
+ nexusCard: game.nexusCards!.cards!.hands[owner],
+ nexusDeckCount: game.nexusCards!.cards!.deck.length,
+ event: nexusEvent,
+ deadLeader: { ...deadLeaderFact },
+ deadForces: { normal: holder.tanks, elite: holder.elites?.tanks ?? 0 },
+ seats: game.players.map(player => ({ id: player.id, faction: player.faction, ally: player.ally,
+ spice: player.spice, tanks: player.tanks, reserves: player.reserves, revived: player.revived })),
+ offer: { blocked: offer.blocked, maxForces: offer.maxForces, maxElite: offer.maxElite,
+ leaders: offer.leaders.map(leader => leader.id) },
+ action: { type: 'nexusTleilaxuRevival', event: nexusEvent,
+ leader: deadLeader.id, amount: offer.maxForces, elite: 0 },
+ staging: [...staging],
+ };
+ assert.ok(game.spiceDeck.length > 0, 'The real spice deck keeps its remaining undealt cards');
+ return { initial, afterSetup, beforeShipment, shipment, beforeBattle, battleActions,
+ afterBattle, beforeBlow, beforeDraw, draw, afterDraw, game, owner, opponent, partner,
+ ownerFaction: holder.faction, deadLeader: deadLeaderFact, nexusEvent, actions, staging, scenario };
+ });
+}
diff --git a/tests/homeworld-revival-deployment.test.ts b/tests/homeworld-revival-deployment.test.ts
index cf82a509..4321600a 100644
--- a/tests/homeworld-revival-deployment.test.ts
+++ b/tests/homeworld-revival-deployment.test.ts
@@ -46,7 +46,7 @@ function deposited(
void test('high Southern Hemisphere returns only the newly revived Fedaykin subset for each supported revival source', () => {
for (const advanced of [false, true])
- for (const source of ['normal', 'emperorExtra', 'ghola'] as const) {
+ for (const source of ['normal', 'emperorExtra', 'ghola', 'nexusTleilaxu'] as const) {
const before = fixture('fremen', 3, advanced);
before.players[0].elites!.reserves = 1;
const group = { amount: 4, elite: 2, free: source === 'normal' ? 2 : 0 };
diff --git a/tests/homeworld-revival-return.test.ts b/tests/homeworld-revival-return.test.ts
index b36f2f81..a461cb14 100644
--- a/tests/homeworld-revival-return.test.ts
+++ b/tests/homeworld-revival-return.test.ts
@@ -15,7 +15,7 @@ import {
function fixture(
kind: 'fedaykin' | 'tleilax' = 'fedaykin',
- source: 'normal' | 'emperorExtra' | 'ghola' = 'normal',
+ source: 'normal' | 'emperorExtra' | 'ghola' | 'nexusTleilaxu' = 'normal',
) {
const players = [
newPlayer('p', 'Owner', kind === 'fedaykin' ? 'fremen' : 'tleilaxu'),
@@ -85,7 +85,7 @@ function choose(
}
void test('typed revival receipt survives every source and JSON round trip without aliasing its quote', () => {
- for (const source of ['normal', 'emperorExtra', 'ghola'] as const) {
+ for (const source of ['normal', 'emperorExtra', 'ghola', 'nexusTleilaxu'] as const) {
const { g, original, frame } = fixture('fedaykin', source);
const copy = JSON.parse(JSON.stringify(frame)) as HomeworldRevivalReturn;
validateHomeworldRevivalReturn(g, copy);
diff --git a/tests/nexus-tleilaxu-bots.test.ts b/tests/nexus-tleilaxu-bots.test.ts
index 12061811..c350a3a5 100644
--- a/tests/nexus-tleilaxu-bots.test.ts
+++ b/tests/nexus-tleilaxu-bots.test.ts
@@ -32,24 +32,11 @@ function fixture(): GameView {
};
v.nexusTleilaxu = {
cunning: { event: 'dancer-event', count: 1, blocked: null },
+ secretAlly: null,
};
return v;
}
-void test('every bot profile takes the complete offered replacement off turn without a choice handshake', () => {
- for (const difficulty of DIFFICULTIES)
- for (const count of [1, 2, 3]) {
- const v = fixture();
- v.players[0].bot = difficulty;
- v.nexusTleilaxu!.cunning!.count = count;
- v.decision = { kind: 'nullentropy', player: 'a' };
- const before = structuredClone(v);
- assert.deepEqual(botActions(v), [
- { type: 'nexusFaceDancers', event: 'dancer-event' },
- ]);
- assert.deepEqual(v, before);
- }
-});
void test('all bot profiles preserve Truth, closing draw, automatic and preceding exchange priorities', () => {
for (const difficulty of DIFFICULTIES) {
diff --git a/tests/nexus-tleilaxu-controls.test.ts b/tests/nexus-tleilaxu-controls.test.ts
index e7e51af3..8d5f3aee 100644
--- a/tests/nexus-tleilaxu-controls.test.ts
+++ b/tests/nexus-tleilaxu-controls.test.ts
@@ -1,9 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import { createElement } from 'react';
-import { renderToStaticMarkup } from 'react-dom/server';
import { createGame, newPlayer, viewGame, type GameView } from '../game/engine';
-import { NexusTleilaxu } from '../components/nexus-tleilaxu';
import { nexusFaceDancersAction } from '../game/nexus-tleilaxu-options';
/** Explicit projected offers isolate presentation/consumer validation. These
@@ -36,31 +33,10 @@ function fixture(): GameView {
};
v.nexusTleilaxu = {
cunning: { event: 'dancer-event', count: 2, blocked: null },
+ secretAlly: null,
};
return v;
}
-function html(game: GameView, busy = false) {
- return renderToStaticMarkup(
- createElement(NexusTleilaxu, { game, busy, act() {} }),
- );
-}
-
-void test('Cunning explains the ordered complete replacement and submits no selection or extra confirmation', () => {
- const v = fixture();
- const before = structuredClone(v);
- assert.deepEqual(nexusFaceDancersAction(v, 'dancer-event'), {
- type: 'nexusFaceDancers',
- event: 'dancer-event',
- });
- const text = html(v);
- assert.match(text, /secretly draw their replacements/);
- assert.match(text, /Then shuffle the set-aside cards/);
- assert.match(text, /unrevealed Face Dancers stay in your hand/);
- assert.match(text, /Replace 2 revealed Face Dancers/);
- assert.doesNotMatch(text, /type="checkbox"|