Spend the Ecaz Nexus card to learn whether one player holds any of your native leaders as Traitor Cards. You receive only yes or no, not a name or count. The answer is saved at the time of use.
+
+ {offer.blocked &&
{offer.blocked}
}
+
+ >}
+ {!!history.length &&
+
Earlier answers
+
+ {history.map(receipt =>
+ Turn {receipt.turn}, phase {receipt.phase} · {game.players.find(player => player.id === receipt.target)?.name ?? 'Player'} held one of your native leaders: {receipt.answer ? 'Yes' : 'No'}.
+
)}
+
+
}
+
+ );
+}
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index f403abf6..a09a558f 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -50,8 +50,10 @@ ally before the first Shipment and Movement turn advances. One spent card,
a provisional Karama response and exact typed ally-force return are wired
through human controls, legal bot action, JSON and in-memory SQL continuation,
and focused gameplay, race and restart tests. A local browser fixture rendered
-the holder-only offer and rival Karama notice without writing a room. Secret
-Ally inquiry, Duke Cunning and combined modes remain gated. The main page
+the holder-only offer and rival Karama notice without writing a room. The
+Secret Ally inquiry has a bounded private prototype; its native-leader
+scope/answer audience, Duke Cunning and combined modes remain release gates.
+The main page
labels its build's Git commit in the lobby and active-table masthead; local
Git HEAD does not represent uncommitted work. The main-branch container
workflow publishes an image but does not deploy the live site automatically.
@@ -258,7 +260,7 @@ These links define bounded working behavior, not complete module certification.
| Expansion factions | [Ixians/Tleilaxu](IX_PROTOTYPE.md) and [all seven faction selections](EXPANSION_FACTIONS_PROTOTYPE.md): genuine setup, Ecaz six-force placement and [Advanced Loyalty](ECAZ_LOYALTY.md), separate optional card variants, private controls and saved per-lot Ixian/Richese decline. Actual special-lot exchange remains pending. |
| Leader Skills | [Common lifecycle](LEADER_SKILLS_RUNTIME.md), [known skilled capture](LEADER_SKILLS_CAPTURE.md), [Mentat question preview](MENTAT_QUESTION.md), [Bureaucrat payments](BUREAUCRAT_PAYMENTS.md), five battle disciplines, [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md), [Rihani and other battle effects](LEADER_BATTLE_EFFECTS.md), [Smuggler shipment](SMUGGLER_SHIPMENT.md), [No-Field](SMUGGLER_NO_FIELD.md) and [battle collection](SMUGGLER_BATTLE.md), [Sandmaster routes](SANDMASTER_MOVEMENT.md) and [worm rides](SANDMASTER_WORM.md), [Banker spending](SPICE_BANKER_RUNTIME.md), [Diplomat defense](DIPLOMAT_DEFENSE.md) and [bounded retreat](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation). Remaining bands and combinations are explicit in those contracts. |
| Discoveries | [Prototype](DISCOVERY_PROTOTYPE.md): genuine setup, Great Maker, seven cards/eight tokens, inspection and stash rewards, nested sites, signed later free entry, carried Ornithopter, sole Cistern, bounded Orgiz transfers, Jacurutu income, Testing Station and Shrine. Orgiz's per-deposit interpretation, contested/shared benefits and mixed physical allocation remain provisional or pending. The Orgiz path in current source is not yet deployed. |
-| Nexus/Homeworlds | [Decision index](RULE_DECISIONS.md) links each integrated faction family, native/borrowed effects, physical custody, private choices, payments and transport. [CHOAM Secret Ally trade and bounded after-victory inspection](NEXUS_CHOAM_SECRET_ALLY.md), [Moritani Secret Ally losing-card retention](NEXUS_MORITANI_RULES.md#secret-ally-losing-card-retention-prototype) and [Emperor extra revival](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md) are connected. Uniform CHOAM winner and Moritani loser windows are privacy-preserving product inferences, not publisher text; Emperor purchase remains pending. A source audit or helper alone is not a completed effect. |
+| Nexus/Homeworlds | [Decision index](RULE_DECISIONS.md) links each integrated faction family, native/borrowed effects, physical custody, private choices, payments and transport. [CHOAM Secret Ally trade and bounded after-victory inspection](NEXUS_CHOAM_SECRET_ALLY.md), [Moritani Secret Ally losing-card retention](NEXUS_MORITANI_RULES.md#secret-ally-losing-card-retention-prototype), [Ecaz private yes/no traitor inquiry](NEXUS_ECAZ_RULES.md#bounded-secret-ally-traitor-inquiry-runtime) and [Emperor extra revival](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md) are connected. Uniform CHOAM winner/Moritani loser windows and Ecaz inquiry scope/audience are explicit product inferences, not publisher rulings; Emperor purchase remains pending. A source audit or helper alone is not a completed effect. |
| Truthtrance | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card counts](TRUTHTRANCE_CARD_COUNT.md), [hand inventory](TRUTHTRANCE_HAND_INVENTORY.md), [recorded knowledge](TRUTHTRANCE_KNOWLEDGE.md), and [Basic/no-Guild Advanced reserve-shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md) use authoritative private state. Earlier readiness audits do not supersede these follow-ups. |
| Other cards | [Sapho runtime](JUICE_OF_SAPHO_RUNTIME.md) includes first among remaining unstarted movement turns after Advanced Guild has finished. [Recruits](RECRUITS_RUNTIME.md) connects clean Revival play; [Harass & Withdraw](HARASS_WITHDRAW_RUNTIME.md) connects the bounded battle return in the same independent three-card preview. Richese card contracts and the checklist identify other connected effects and explicit gaps. |
| Multiplayer | [Public and private discussion](TABLE_DISCUSSION.md), saved rooms/seats, [recovery](../README.md#saved-seats-and-reconnecting), uncertain-request retry, [own-seat AI](AUTOPILOT.md), [named-player AI permission](SEAT_AI_PERMISSION.md) and [voluntary seat handover](SEAT_HANDOVER.md) have connected controls and recovery evidence. |
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 8d2684ab..7150a49d 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,13 @@
# Dune implementation status
+## 27 September 2026 — Bounded Ecaz Nexus Secret Ally traitor inquiry
+
+The [Secret Ally prototype](NEXUS_ECAZ_RULES.md#bounded-secret-ally-traitor-inquiry-runtime) lets an unallied holder spend the physical Ecaz Nexus card at a quiet boundary when Ecaz is absent and ask any seated player, including themself, whether that player holds one of the holder's native leaders as an ordinary Traitor Card. The public action names the target but discloses no answer or matching identity; the holder alone can revisit the signed historical yes/no after the target's hand changes and the Nexus discard recycles. Native printed leader scope and holder-only audience are privacy-preserving product interpretations, not publisher rulings. Basic/Advanced real actions, four AI profiles, owner/rival projections, malformed and interrupted requests, JSON recovery and concurrent in-memory SQLite CAS passed six focused cases. A local read-only browser fixture showed the holder's target control and subsequent private answer, while a rival saw neither; no room write occurred. An independent rules/privacy/persistence review found no actionable defect.
+
+The complete offline suite passed 5,571 tests across 699 files after the source tree stopped changing. Two source-bound CLI cases had reported a changed-source fingerprint during the earlier concurrent edit; all 19 CLI cases and the full suite passed on the frozen tree.
+
+The shared clean-play predicate preserves CHOAM's existing boundary (12 focused cases). App build, scoped lint and a project TypeScript check excluding only the unrelated in-progress Nexus/Fremen draft passed. `npm run check` stops on that draft's missing `Player.fremenNexusMovementBlockedTurn`; it was not included here. Full Nexus and combined-module acceptance remain gated. This checkpoint has not been deployed.
+
## 27 September 2026 — Bounded Moritani Nexus losing-card retention
The [Secret Ally prototype](NEXUS_MORITANI_RULES.md#secret-ally-losing-card-retention-prototype) now opens the same after-loss choice for every Nexus loser when Moritani is absent and at least one publicly played card can be retained. Only an unallied holder may spend the physical Moritani Nexus card to keep one eligible played card; nonholders continue without disclosing custody. Mandatory discards and the other played cards follow ordinary saved battle cleanup. Native allied retention and its Karama response remain separate. Real Basic/Advanced battles, all four legal AI profiles, CHOAM winner inspection ordering, JSON restoration, rejected-action immutability and concurrent in-memory SQLite commits passed seven focused cases. An independent review found a Homeworld location-label crash; the corrected panel renders the server's public combat location name. Local browser views displayed the Arrakis decision, retained private hand and corrected Homeworld decision through read-only intercepted room responses; no room was mutated.
diff --git a/docs/NEXUS_ECAZ_RULES.md b/docs/NEXUS_ECAZ_RULES.md
index 4af43de5..8f0aa5b7 100644
--- a/docs/NEXUS_ECAZ_RULES.md
+++ b/docs/NEXUS_ECAZ_RULES.md
@@ -78,5 +78,35 @@ pending snapshot binds the card's discard position, ally and exact typed board
group through the Karama window, even if another Nexus card is discarded
during that response. The ally's reserve counters are validated before any
board return. Saved receipts reject replay but are not cryptographic proof of
-historical save authenticity. Ecaz Cunning's Duke override and the Secret
-Ally identity/audience questions above remain unimplemented.
+historical save authenticity. Ecaz Cunning's Duke override remains
+unimplemented; Secret Ally's exceptional leader scope and audience remain
+unsettled beyond the bounded runtime below.
+
+## Bounded Secret Ally traitor inquiry runtime
+
+When Ecaz is absent, an unallied holder may spend its physical Nexus card at
+a quiet play boundary to ask about any seated player, including themself. The
+owner-only offer lists public names and never consults a target's hidden hand
+to determine availability. Acceptance atomically reads the target's currently
+held ordinary Traitor Cards, records one yes/no answer, and consumes the Nexus
+card. The target and public chronicle learn that an inquiry occurred, but only
+the holder sees the historical answer. Later exchanges or leader deaths do not
+rewrite it. No generic Karama response to this borrowed panel is invented.
+
+For this first playable interpretation, “your leaders” means the holder's
+native faction leader identities, including dead and captured native discs,
+but not foreign captives/Gholas, Duke Vidal or Cheap Hero. Face Dancers are
+not ordinary Traitor Cards. This identity scope and the holder-only audience
+are **product interpretations**, not explicit publisher rulings. A fresh
+online search found no authenticated clarification beyond the previously
+inspected primary component and rulebook linked above. The
+exceptional controlled-pool and table-wide disclosure alternatives remain
+visible release boundaries; this prototype does not certify them.
+
+The completed, signed receipt binds the accepted event, original seats and
+factions, turn/phase, native scope and answer without storing or projecting a
+matching identity. The owner's private history survives JSON restoration and
+later Nexus discard recycling. Focused Basic/Advanced, four-profile AI,
+rejection, spectator projection and concurrent saved-room cases are in
+[the inquiry tests](../tests/nexus-ecaz-inquiry.test.ts). Full Nexus and
+combined-mode acceptance remain gated.
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index c3ae27ae..26e601a4 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -84,6 +84,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Testing Station contest/timing | Only sole non-advisor occupation is prototyped. Two-occupant priority/stacking remains unresolved; opening after the ordinary storm-card window is an explicit source-timing inference. Weather Control is excluded by the printed text. | [Discovery interpretations](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
| CHOAM Secret Ally battle timing | The printed after-victory inspection is optional and the Nexus identity stays secret. The connected prototype chooses a uniform use-or-continue window for every winner when Nexus is enabled and CHOAM absent; nonholders pass, while a holder may decide after the result. This privacy-preserving prompt is a product inference, not publisher text. A holder-only pause would reveal custody and a precommitment would remove the printed choice. Ordinary Karama cancellation remains unestablished by retrieved sources; no response is invented. | [After-victory inspection boundary](NEXUS_CHOAM_SECRET_ALLY.md#after-victory-inspection-audit) |
| Moritani Secret Ally battle timing | The printed after-loss retention is optional and the Nexus card remains secret. The connected prototype offers the same choice to every loser when Moritani is absent and at least one publicly revealed played card is retainable; a loser with no eligible public card has no useful choice, independent of hidden custody. Only an unallied holder may spend the physical card. This prompt is a privacy-preserving product inference, not publisher timing text. Native Moritani alliance retention and its Karama response remain separate; no generic Secret Ally counter is invented. | [Nexus retention boundary](NEXUS_CARD_RULES.md#moritani-secret-ally-retention-timing) |
+| Ecaz Secret Ally traitor inquiry | The printed panel asks one selected player whether they hold any of the holder's leaders as Traitors, without identifying a leader; it specifies neither native-versus-controlled scope nor answer audience. The connected prototype chooses native printed faction leaders (including dead/captured), ordinary Traitor Cards rather than Face Dancers, and a holder-only historical yes/no. This is a privacy-preserving product interpretation, **not** a publisher ruling. Foreign Ghola/captive scope and table-wide disclosure remain release boundaries. | [Ecaz inquiry source and bounded runtime](NEXUS_ECAZ_RULES.md#bounded-secret-ally-traitor-inquiry-runtime) |
| Emperor Secret Ally purchase timing | The purchase choice follows winning the bid. A holder-only payment pause exposes concealed card eligibility, while per-bid preauthorization moves the choice earlier. The user has been asked whether to permit a uniform winner payment confirmation, including sole spice payments, or retain the purchase gate. No answer is assumed; the independent three-force revival grant can proceed. | [Purchase source contract](NEXUS_EMPEROR_RULES.md#secret-ally-retain-the-actual-purchase-price) |
| Discovery prototype inferences | Free entry uses storm order and ordinary source-storm restrictions; these are general-rule inferences. Great Maker currently follows ordinary first-turn worm skipping and Sandtrout suppression; an exhausted token type creates no replacement token. These preserve current source interpretation and physical inventory but are not separately confirmed rulings or complete combined-module certification. | [Interpretations for refinement](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
| Other Homeworld combinations | Population/force splitting, removal custody, replacement shortages, income accounting and placement ordering remain local to their detailed contracts. | [Homeworld rules](HOMEWORLD_RULES.md), [occupation](HOMEWORLD_OCCUPATION_RULES.md) and the effect documents above |
diff --git a/game/bots.ts b/game/bots.ts
index a093c13f..92c76d4c 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -16,6 +16,7 @@ import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildHajrAction,
import { nexusMoritaniBotActions } from './nexus-moritani-options';
import { moritaniBetrayalBotActions } from './nexus-moritani-betrayal-options';
import { ecazBetrayalBotActions } from './nexus-ecaz-betrayal-options';
+import { ecazInquiryBotActions } from './nexus-ecaz-inquiry-options';
import { ecazBetrayalOffer } from './nexus-ecaz-betrayal';
import { ECAZ_START_FORCES, ECAZ_START_LOCATIONS, quoteEcazStartingForces } from './ecaz-setup';
import { choamPowerAction, choamPowerBotPlay } from './choam-power-options';
@@ -3875,6 +3876,8 @@ export function botActions(g: GameView): Action[] {
if (moritaniBetrayal.length) return moritaniBetrayal;
const ecazBetrayal = ecazBetrayalBotActions(g);
if (ecazBetrayal.length) return ecazBetrayal;
+ const ecazInquiry = ecazInquiryBotActions(g);
+ if (ecazInquiry.length) return ecazInquiry;
const sardaukar = nexusSardaukarBotActions(g);
if (sardaukar.length) return sardaukar;
const advisorConversion = nexusAdvisorBotActions(g);
diff --git a/game/engine.ts b/game/engine.ts
index 8cef8231..385a316e 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -36,6 +36,7 @@ import { quoteNexusChoamTrade, validateNexusChoamTrade, nexusChoamTradeSignature
import { quoteNexusChoamInspection, sampleNexusChoamInspection, nexusChoamInspectionSignature, type NexusChoamInspectionReceipt } from './nexus-choam-inspection';
import { quoteNexusMoritaniRetention, nexusMoritaniRetentionSignature, type NexusMoritaniRetentionReceipt } from './nexus-moritani-retention';
import { quoteNexusChoamBetrayal, validateNexusChoamBetrayal, nexusChoamBetrayalSignature, type NexusChoamBetrayal } from './nexus-choam-betrayal';
+import { quoteEcazInquiry, ecazInquiryAnswer, ecazInquiryHistory, ecazInquirySignature, validateEcazInquiry, type EcazInquiryReceipt } from './nexus-ecaz-inquiry';
import { EMPEROR_NEXUS_REVIVALS, emperorNexusEvent, emperorNexusSignature, emperorNexusModeSupported, emperorNexusPools, emperorNexusRevivalElites, validateEmperorNexusRevival, type NexusEmperorRevival } from './nexus-emperor-secret-ally';
import { FREMEN_NEXUS_FREE_FORCES, fremenNexusRevivalOffer, fremenNexusRevivalPools, fremenNexusRevivalSignature, validateFremenNexusRevival, type FremenNexusRevival } from './nexus-fremen-revival';
import { captureFremenCunningOccurrence, quoteFremenCunning, quoteFremenCunningRide, validateFremenCunningSelection, type FremenCunningOccurrence, type FremenCunningRideAuthorization, type FremenCunningSelection } from './nexus-fremen-cunning';
@@ -1046,6 +1047,8 @@ export type Game = {
nexusChoamTradeLast?: { event: string; stage: NexusChoamTrade['stage'] };
nexusChoamBetrayals?: NexusChoamBetrayal[];
nexusChoamBetrayalLast?: { event: string; stage: NexusChoamBetrayal['stage'] };
+ nexusEcazInquiries?: EcazInquiryReceipt[];
+ nexusEcazInquiryLast?: { event: string };
nexusEmperorSecretHistory?: NexusEmperorRevival[];
nexusEmperorSecretEvents?: string[];
nexusFremenRevivalHistory?: FremenNexusRevival[];
@@ -1950,6 +1953,27 @@ function nexusChoamBetrayalIntegrity(g: Game) {
getPlayer(g, last.target).hand.length === last.handBefore - 1,
'The CHOAM Nexus Betrayal no longer matches its random discard.');
}
+function nexusEcazInquiryIntegrity(g: Game) {
+ const history = g.nexusEcazInquiries;
+ if (history === undefined) {
+ requireRule(!g.nexusEcazInquiryLast, 'The Ecaz Nexus inquiry lost its history.');
+ return;
+ }
+ requireRule(Array.isArray(history) && history.length > 0 && !!g.nexusCards?.cards,
+ 'The Ecaz Nexus inquiry lost its physical card history.');
+ for (const [index, receipt] of history.entries()) {
+ nexusRule(() => validateEcazInquiry(g, receipt, index));
+ if (index > 0) {
+ const previous = history[index - 1];
+ requireRule(previous.turn < receipt.turn ||
+ (previous.turn === receipt.turn && previous.phase <= receipt.phase),
+ 'The Ecaz Nexus inquiries changed their original order.');
+ }
+ }
+ requireRule(g.nexusEcazInquiryLast?.event === history.at(-1)!.event,
+ 'The last Ecaz Nexus inquiry lost its original answer.');
+}
+
function currentNexusChoamTrade(g: Game, owner: string) {
return nexusRule(() => quoteNexusChoamTrade(g, owner,
g.phase === 7 && grummanCollectionAutomatic(g)));
@@ -2319,6 +2343,28 @@ function playNexusChoamBetrayal(g: Game, p: Player, action: Action) {
log(g, `${p.name} spent CHOAM Nexus Betrayal. ${target.name} discarded one randomly selected Treachery card without receiving spice.`,
{ faction: p.faction, name: 'CHOAM Nexus Betrayal' });
}
+function playNexusEcazInquiry(g: Game, p: Player, action: Action) {
+ const offer = quoteEcazInquiry(g, p.id,
+ g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g));
+ requireRule(Object.keys(action).sort().join(',') === 'event,target,type' &&
+ offer && !offer.blocked && action.event === offer.event &&
+ offer.targets.some(target => target.id === action.target),
+ offer?.blocked ?? 'Choose the current Ecaz Nexus inquiry and a seated player.');
+ const target = getPlayer(g, action.target as string);
+ const receipt: EcazInquiryReceipt = {
+ event: offer.event, owner: p.id, ownerFaction: p.faction,
+ target: target.id, targetFaction: target.faction,
+ turn: g.turn, phase: g.phase, scope: 'native',
+ answer: ecazInquiryAnswer(p, target), signature: '',
+ };
+ receipt.signature = ecazInquirySignature(receipt);
+ g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
+ (g.nexusEcazInquiries ??= []).push(receipt);
+ g.nexusEcazInquiryLast = { event: receipt.event };
+ log(g, `${p.name} spent Ecaz Nexus Secret Ally to ask whether ${target.name} holds any of their native leaders as Traitor Cards. The yes/no answer was delivered privately.`,
+ { faction: p.faction, name: 'Ecaz Nexus inquiry' });
+}
+
function playMoritaniBetrayal(g: Game, p: Player, action: Action) {
const offer = moritaniBetrayalOffer(g, p.id,
g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g));
@@ -21635,6 +21681,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
nexusChoamInspectionIntegrity(state);
nexusMoritaniRetentionIntegrity(state);
nexusChoamBetrayalIntegrity(state);
+ nexusEcazInquiryIntegrity(state);
marketGholaIntegrity(state);
homeworldRule(() => homeworldGameIntegrity(state));
homeworldBattleLossIntegrity(state);
@@ -21796,6 +21843,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
nexusChoamInspectionIntegrity(g);
nexusMoritaniRetentionIntegrity(g);
nexusChoamBetrayalIntegrity(g);
+ nexusEcazInquiryIntegrity(g);
marketGholaIntegrity(g);
homeworldRule(() => homeworldGameIntegrity(g));
homeworldBattleLossIntegrity(g);
@@ -21911,6 +21959,7 @@ export function normalizeAutomaticGame(state: Game): Game {
nexusChoamInspectionIntegrity(state);
nexusMoritaniRetentionIntegrity(state);
nexusChoamBetrayalIntegrity(state);
+ nexusEcazInquiryIntegrity(state);
marketGholaIntegrity(state);
homeworldRule(() => homeworldGameIntegrity(state));
homeworldBattleLossIntegrity(state);
@@ -21944,6 +21993,7 @@ export function normalizeAutomaticGame(state: Game): Game {
nexusChoamInspectionIntegrity(g);
nexusMoritaniRetentionIntegrity(g);
nexusChoamBetrayalIntegrity(g);
+ nexusEcazInquiryIntegrity(g);
marketGholaIntegrity(g);
homeworldRule(() => homeworldGameIntegrity(g));
homeworldBattleLossIntegrity(g);
@@ -22043,6 +22093,7 @@ function applyActionInner(
if (t === 'discovery') { playDiscovery(g, p, action); return g; }
if (t === 'nexusChoamTrade') { playNexusChoamTrade(g, p, action); return g; }
if (t === 'nexusChoamBetrayal') { playNexusChoamBetrayal(g, p, action); return g; }
+ if (t === 'nexusEcazInquiry') { playNexusEcazInquiry(g, p, action); return g; }
if (t === 'nexusEmperorRevive') { playNexusEmperorRevive(g,p,action); return g; }
if (t === 'nexusEmperorBetrayal') { playNexusEmperorBetrayal(g,p,action); return g; }
if (t === 'nexusBgBetrayal') { playNexusBgBetrayal(g,p,action); return g; }
@@ -25714,6 +25765,7 @@ export function viewGame(state: Game, id: string) {
nexusChoamInspectionIntegrity(state);
nexusMoritaniRetentionIntegrity(state);
nexusChoamBetrayalIntegrity(state);
+ nexusEcazInquiryIntegrity(state);
marketGholaIntegrity(state);
homeworldRule(() => homeworldGameIntegrity(state));
homeworldBattleLossIntegrity(state);
@@ -26030,6 +26082,11 @@ export function viewGame(state: Game, id: string) {
nexusChoamInspection: quoteNexusChoamInspection(g, id),
nexusChoamBetrayal: quoteNexusChoamBetrayal(g, id,
g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)),
+ nexusEcazInquiry: {
+ offer: quoteEcazInquiry(g, id,
+ g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)),
+ history: ecazInquiryHistory(g, id),
+ },
nexusEmperorSecretAlly: nexusEmperorSecretAllyOffer(g,id),
nexusEmperorBetrayal: nexusEmperorBetrayalOffer(g, id),
nexusBgBetrayal: nexusBgBetrayalOffer(g, id),
diff --git a/game/nexus-choam-betrayal.ts b/game/nexus-choam-betrayal.ts
index 7f96bcff..6a99129c 100644
--- a/game/nexus-choam-betrayal.ts
+++ b/game/nexus-choam-betrayal.ts
@@ -1,6 +1,7 @@
import type { Card } from './cards';
import type { Game } from './engine';
import { nexusCardMode } from './nexus-cards';
+import { nexusCleanPlayBlocked } from './nexus-play-boundary';
export type NexusChoamBetrayalOffer = Readonly<{
event: string;
@@ -46,22 +47,7 @@ export function quoteNexusChoamBetrayal(
let blocked: string | null = null;
if (holder.ally) blocked = 'Use a Nexus card while unallied.';
else if (g.status !== 'playing') blocked = 'Use CHOAM Betrayal during play.';
- else if (automaticPending || g.response || g.decision || g.truthtrance || g.phaseOpening ||
- g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy ||
- g.pendingExchange || g.pendingAmbassador || g.pendingRicheseGift ||
- g.pendingRichesePurchaseIncome || g.pendingRevival || g.pendingCapture ||
- g.pendingShipment || g.pendingHomeworldShipment || g.pendingIxMove ||
- g.pendingFremenMove || g.pendingMobileMove || g.pendingTerrorEntry ||
- g.pendingMoritaniPlacement || g.pendingEcazPlacement || g.pendingChoamMove ||
- g.pendingChoamWorthless || g.pendingChoamBattleIncome ||
- g.pendingChoamMarketGhola || g.pendingAuditor || g.pendingFaceDance ||
- g.pendingTech || g.pendingIxTechnology || g.pendingIxAlly ||
- g.pendingIxSubstitution || g.pendingIxRicheseTechnology ||
- g.pendingWinnerDiscards || g.pendingSukRescue || g.ornithopter ||
- g.summonedWorm || g.battle || g.auction || g.richeseAuction ||
- g.choamMarket || g.nexusTraitorPending ||
- g.nexusTraitorExchanges?.some(record => record.stage === 'return') ||
- g.nexusCards?.phase?.stage === 'drawing')
+ else if (nexusCleanPlayBlocked(g, automaticPending))
blocked = 'Finish the current interaction before using CHOAM Betrayal.';
else if (handSize === 0) blocked = 'CHOAM has no Treachery cards to discard.';
return {
diff --git a/game/nexus-ecaz-inquiry-options.ts b/game/nexus-ecaz-inquiry-options.ts
new file mode 100644
index 00000000..69d7133f
--- /dev/null
+++ b/game/nexus-ecaz-inquiry-options.ts
@@ -0,0 +1,17 @@
+import type { Action, GameView } from './engine';
+
+/** The target list is public; only the server reads its hidden Traitor Cards. */
+export function ecazInquiryAction(game: GameView, target: string): Action | null {
+ const offer = game.nexusEcazInquiry.offer;
+ const owner = game.players.find(player => player.id === game.me);
+ if (game.status !== 'playing' || game.automaticContinuationPending || !owner ||
+ owner.ally || game.nexusCards?.card !== 'ecaz' || !offer || offer.blocked ||
+ !offer.targets.some(player => player.id === target)) return null;
+ return { type: 'nexusEcazInquiry', event: offer.event, target };
+}
+
+export function ecazInquiryBotActions(game: GameView): Action[] {
+ const target = game.nexusEcazInquiry.offer?.targets.find(player => player.id !== game.me);
+ const action = target && ecazInquiryAction(game, target.id);
+ return action ? [action] : [];
+}
diff --git a/game/nexus-ecaz-inquiry.ts b/game/nexus-ecaz-inquiry.ts
new file mode 100644
index 00000000..89805eaa
--- /dev/null
+++ b/game/nexus-ecaz-inquiry.ts
@@ -0,0 +1,80 @@
+import type { Game, Player } from './engine';
+import type { FactionId } from './catalog';
+import { DUKE_VIDAL_ID } from './duke-vidal';
+import { nexusCardMode } from './nexus-cards';
+import { nexusCleanPlayBlocked } from './nexus-play-boundary';
+
+export type EcazInquiryReceipt = {
+ event: string;
+ owner: string;
+ ownerFaction: FactionId;
+ target: string;
+ targetFaction: FactionId;
+ turn: number;
+ phase: number;
+ scope: 'native';
+ answer: boolean;
+ signature: string;
+};
+
+export function ecazInquiryEvent(turn: number, phase: number, owner: string, index: number): string {
+ return JSON.stringify(['nexusEcazInquiry', turn, phase, owner, index]);
+}
+
+export function ecazInquirySignature(receipt: EcazInquiryReceipt): string {
+ return JSON.stringify([receipt.event, receipt.owner, receipt.ownerFaction, receipt.target,
+ receipt.targetFaction, receipt.turn, receipt.phase, receipt.scope, receipt.answer]);
+}
+
+/** No target hand or leader custody is read while offering every public target. */
+export function quoteEcazInquiry(g: Game, owner: string, automaticPending = false) {
+ const holder = g.players.find(player => player.id === owner);
+ if (!holder || g.nexusCards?.cards?.hands[owner] !== 'ecaz' ||
+ nexusCardMode('ecaz', holder.faction, g.players.map(player => player.faction)) !== 'secretAlly')
+ return null;
+ let blocked: string | null = null;
+ if (holder.ally) blocked = 'Use a Nexus card while unallied.';
+ else if (g.status !== 'playing') blocked = 'Use Ecaz Secret Ally during play.';
+ else if (nexusCleanPlayBlocked(g, automaticPending))
+ blocked = 'Finish the current interaction before using Ecaz Secret Ally.';
+ return {
+ event: ecazInquiryEvent(g.turn, g.phase, owner, g.nexusEcazInquiries?.length ?? 0),
+ blocked,
+ targets: g.players.map(player => ({ id: player.id, name: player.name })),
+ };
+}
+
+/** Native printed leader identity survives death, capture and later revival.
+ * Foreign Ghola discs and the separate Duke/Cheap Hero identities do not enter. */
+export function ecazInquiryAnswer(owner: Player, target: Player): boolean {
+ return owner.leaders.some(leader => leader.faction === owner.faction &&
+ leader.id !== DUKE_VIDAL_ID && target.traitors.includes(leader.id));
+}
+
+export function ecazInquiryHistory(g: Game, owner: string) {
+ const visible: Pick[] = [];
+ for (const receipt of g.nexusEcazInquiries ?? [])
+ if (receipt.owner === owner)
+ visible.push({ event: receipt.event, target: receipt.target,
+ turn: receipt.turn, phase: receipt.phase, answer: receipt.answer });
+ return visible;
+}
+
+/** Historical answers do not re-read a hand changed after the accepted play. */
+export function validateEcazInquiry(g: Game, receipt: EcazInquiryReceipt, index: number): void {
+ const owner = g.players.find(player => player.id === receipt?.owner);
+ const target = g.players.find(player => player.id === receipt?.target);
+ if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt) ||
+ Object.keys(receipt).sort().join(',') !==
+ 'answer,event,owner,ownerFaction,phase,scope,signature,target,targetFaction,turn' ||
+ !owner || !target || !!g.players.find(player => player.faction === 'ecaz') ||
+ !g.nexusCards?.cards || owner.faction !== receipt.ownerFaction ||
+ target.faction !== receipt.targetFaction || receipt.scope !== 'native' ||
+ typeof receipt.answer !== 'boolean' ||
+ !Number.isSafeInteger(receipt.turn) || receipt.turn < 1 || receipt.turn > g.turn ||
+ !Number.isSafeInteger(receipt.phase) || receipt.phase < 0 || receipt.phase > 8 ||
+ (receipt.turn === g.turn && receipt.phase > g.phase) ||
+ receipt.event !== ecazInquiryEvent(receipt.turn, receipt.phase, receipt.owner, index) ||
+ receipt.signature !== ecazInquirySignature(receipt))
+ throw new Error('Ecaz Nexus inquiry lost its original private answer or seated factions.');
+}
diff --git a/game/nexus-play-boundary.ts b/game/nexus-play-boundary.ts
new file mode 100644
index 00000000..7860a96a
--- /dev/null
+++ b/game/nexus-play-boundary.ts
@@ -0,0 +1,21 @@
+import type { Game } from './engine';
+
+/** Optional no-phase Nexus effects must not interrupt a committed parent. */
+export function nexusCleanPlayBlocked(g: Game, automaticPending = false): boolean {
+ return !!(automaticPending || g.response || g.decision || g.truthtrance || g.phaseOpening ||
+ g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy ||
+ g.pendingExchange || g.pendingAmbassador || g.pendingRicheseGift ||
+ g.pendingRichesePurchaseIncome || g.pendingRevival || g.pendingCapture ||
+ g.pendingShipment || g.pendingHomeworldShipment || g.pendingIxMove ||
+ g.pendingFremenMove || g.pendingMobileMove || g.pendingTerrorEntry ||
+ g.pendingMoritaniPlacement || g.pendingEcazPlacement || g.pendingChoamMove ||
+ g.pendingChoamWorthless || g.pendingChoamBattleIncome ||
+ g.pendingChoamMarketGhola || g.pendingAuditor || g.pendingFaceDance ||
+ g.pendingTech || g.pendingIxTechnology || g.pendingIxAlly ||
+ g.pendingIxSubstitution || g.pendingIxRicheseTechnology ||
+ g.pendingWinnerDiscards || g.pendingSukRescue || g.ornithopter ||
+ g.summonedWorm || g.battle || g.auction || g.richeseAuction ||
+ g.choamMarket || g.nexusTraitorPending ||
+ g.nexusTraitorExchanges?.some(record => record.stage === 'return') ||
+ g.nexusCards?.phase?.stage === 'drawing');
+}
diff --git a/game/reference.ts b/game/reference.ts
index 495379c1..8f01b462 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -4041,7 +4041,8 @@ export const RULE_TOPICS: RuleTopic[] = [
'Moritani Cunning modifies its normal Mentat placement: choose one available supply token and a territory on the printed Arrakis board, including a territory already containing Terror. The explicit Cunning toggle keeps ordinary placement and relocation unchanged. Homeworlds, the Hidden Mobile Stronghold, Cunning relocation and combined Grumman use are unavailable.',
'With Moritani seated, an unallied holder of its Nexus card may spend Betrayal at a clean play boundary to return one placed Terror token to hidden Moritani supply without revealing its face. Choose the public location and token, not its secret effect; this does not use Moritani’s Mentat placement or trigger that token.',
'When Moritani is absent, a defeated combatant with an eligible publicly played card receives the same postbattle choice whether or not they hold Moritani Nexus. An unallied holder may spend it to retain one played card that a winner could keep; other played cards follow ordinary discard. Without a publicly eligible card there is no prompt. The choice is after the battle result and before later income/capture; the public prompt protects hidden custody and is an interface inference. This Secret Ally effect has no invented ordinary Karama response. Native Moritani allied retention remains a different, cancelable ability.',
- 'Ecaz Betrayal is available to an unallied holder with Ecaz seated and reciprocally allied in a shared Arrakis territory. At the opening of Shipment and Movement, choose one such territory; the ally returns its entire normal and starred force group from every sector there to reserves, leaving Ecaz forces in place. The Nexus card is spent before a Karama response. Cancellation preserves the board but not the card. Only the base/Ecaz Nexus roster without combined modules is supported; Duke Cunning and Secret Ally inquiry remain unavailable.',
+ 'Ecaz Betrayal is available to an unallied holder with Ecaz seated and reciprocally allied in a shared Arrakis territory. At the opening of Shipment and Movement, choose one such territory; the ally returns its entire normal and starred force group from every sector there to reserves, leaving Ecaz forces in place. The Nexus card is spent before a Karama response. Cancellation preserves the board but not the card. Only the base/Ecaz Nexus roster without combined modules is supported; Duke Cunning remains unavailable.',
+ 'When Ecaz is absent, an unallied holder may spend its Nexus card at a quiet play boundary to ask whether any one player, including themself, holds one of the holder’s native faction leaders as an ordinary Traitor Card. The owner receives a private yes/no answer saved at the moment of use; no name or count is disclosed. Dead or captured native leaders count, but foreign Gholas, Duke Vidal, Cheap Hero and Face Dancers do not. Native-leader scope and private-only audience are explicit product interpretations, not publisher rulings; exceptional controlled-leader and public-disclosure combinations remain open.',
'When Richese is absent, its Secret Ally replaces your ordinary reserve shipment with up to five physical forces charged as one. Select the explicit shipment checkbox; forces, elite identity, destination restrictions, Homeworld reserve sources and normal movement remain unchanged. Fremen remain free within their normal reinforcement range; Guild and an active Karama rate retain their existing tariff and payment routing. This is not a concealed No-Field or an extra shipment.',
'Guild Cunning is declared when finishing your ordinary Shipment and Movement turn. It spends the Nexus and opens one Karama response. Allowance grants a second native shipment at normal Guild prices, including supported cross-shipment, return and Homeworld routes. Cancellation leaves the completed first turn intact. Only an unused Hajr move can follow the second shipment; no ordinary movement or third move is granted. The physical Ornithopter card combination remains unavailable.',
'With Guild absent, its Secret Ally is an explicit optional source for one ordinary shipment at Guild prices. It supports physical reserve shipment, cross-shipment and return to abstract reserves, plus half-price independently legal world-to-world shipment. Fremen may retain free reinforcement or choose the paid card route beyond its normal range, outside the storm. Native faction, typed sources and normal movement stay unchanged. Homeworld reserve return awaits a ruling; arbitrary foreign-world transport and concealed No-Fields are not granted.',
@@ -4050,11 +4051,11 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['ecaz-modules', 'spice-blow', 'alliance-funding'],
checklist: [
- { area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, revealed Face Dancer refresh, turn-wide Suboid strength, whole-territory advisor conversion and native Voice Betrayal suppression, temporary Emperor Sardaukar and actual Sardaukar Betrayal suppression, bounded Fremen Secret Ally free-three revival and native empty-worm Cunning, five source-aware CHOAM Cunning effects, Moritani Cunning placement and Betrayal token return, Ecaz Betrayal allied-force return, and Richese Secret Ally reserve shipping priced as one. Borrowed Voice, other Betrayal reactions, Emperor purchase effects, 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/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.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 with native Karama responses, Moritani Betrayal token selection, Ecaz Betrayal shared-territory choice, Richese shipment pricing and Fremen Cunning offer/remote ride. 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/game-table.tsx'] },
- { area: 'AI', status: 'Partial', detail: 'All profiles handle integrated effects through private offers, including Moritani Betrayal public-token selection, Ecaz Betrayal public-territory selection and legal Fremen Cunning offer/ride decisions. 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-richese-options.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] },
+ { area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, revealed Face Dancer refresh, turn-wide Suboid strength, whole-territory advisor conversion and native Voice Betrayal suppression, temporary Emperor Sardaukar and actual Sardaukar Betrayal suppression, bounded Fremen Secret Ally free-three revival and native empty-worm Cunning, five source-aware CHOAM Cunning effects, Moritani Cunning placement and Betrayal token return, Ecaz Betrayal allied-force return and bounded native-leader Secret Ally inquiry, and Richese Secret Ally reserve shipping priced as one. Ecaz inquiry has a private holder control, legal AI action and saved historical yes/no; native scope and audience remain product interpretations. Borrowed Voice, other Betrayal reactions, Emperor purchase effects, 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-inquiry-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'components/nexus-ecaz-inquiry.tsx', 'tests/nexus-ecaz-inquiry.test.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 with native Karama responses, Moritani Betrayal token selection, Ecaz Betrayal shared-territory choice and owner-only Secret Ally inquiry, Richese shipment pricing and Fremen Cunning offer/remote ride. 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/game-table.tsx'] },
+ { area: 'AI', status: 'Partial', detail: 'All profiles handle integrated effects through private offers, including Moritani Betrayal public-token selection, Ecaz Betrayal public-territory selection and legal Secret Ally inquiry, and Fremen Cunning offer/ride decisions. 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-richese-options.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and integrated effect families retain explicit unresolved boundaries, including Ecaz Duke/inquiry, a second Cunning offer during an interrupted Fremen control, Great Maker and combined modules, fresh advisors, storm and borrowed Voice.', 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_GUILD_CUNNING_RUNTIME.md'] },
- { area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and in-memory SQLite recovery regressions include Moritani Betrayal, bounded Fremen Cunning with a genuine special summon, and Ecaz Betrayal. Local natural and summoned Fremen offer/ride and Ecaz holder/Karama response browser surfaces were checked with isolated fixture views without a room write. Full module games and combined interactions remain separate.', evidence: ['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-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-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts'] },
+ { area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and in-memory SQLite recovery regressions include Moritani Betrayal, bounded Fremen Cunning with a genuine special summon, Ecaz Betrayal and private Secret Ally inquiry. Local natural and summoned Fremen offer/ride, Ecaz Betrayal holder/Karama and Ecaz inquiry owner/rival browser surfaces were checked with isolated fixture views without a room write. Full module games and combined interactions remain separate.', evidence: ['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-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-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts'] },
],
},
{
diff --git a/tests/nexus-ecaz-inquiry.test.ts b/tests/nexus-ecaz-inquiry.test.ts
new file mode 100644
index 00000000..a2baa361
--- /dev/null
+++ b/tests/nexus-ecaz-inquiry.test.ts
@@ -0,0 +1,218 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import { ecazInquiryAction } from '../game/nexus-ecaz-inquiry-options';
+import { ecazInquiryAnswer } from '../game/nexus-ecaz-inquiry';
+import { drawNexusCard } from '../game/nexus-cards';
+import { NexusEcazInquiry } from '../components/nexus-ecaz-inquiry';
+import { nexusTraitorFixture, nexusTraitorInventory } from './fixture-nexus-traitors';
+import { unitStore } from './fixture-nexus-room-store';
+import type { RoomsClock } from '../db/rooms';
+
+const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game;
+const seat = (g: Game, id: string) => g.players.find(player => player.id === id)!;
+
+function setAnswer(g: Game, positive: boolean) {
+ const [owner, target] = g.players;
+ const native = new Set(owner.leaders.map(leader => leader.id));
+ const reserve = g.traitorReserve!;
+ if (positive) {
+ if (target.traitors.some(id => native.has(id))) return;
+ const wanted = owner.leaders[0].id;
+ const other = g.players.find(player => player.id !== target.id && player.traitors.includes(wanted));
+ if (other) {
+ const index = other.traitors.indexOf(wanted);
+ other.traitors[index] = target.traitors[0];
+ } else {
+ const index = reserve.indexOf(wanted);
+ assert.ok(index >= 0);
+ reserve[index] = target.traitors[0];
+ }
+ target.traitors[0] = wanted;
+ } else {
+ for (let i = 0; i < target.traitors.length; i++) {
+ if (!native.has(target.traitors[i])) continue;
+ const index = reserve.findIndex(id => !native.has(id));
+ assert.ok(index >= 0);
+ [target.traitors[i], reserve[index]] = [reserve[index], target.traitors[i]];
+ }
+ }
+ nexusTraitorInventory(g);
+}
+
+function position(positive: boolean, advanced = false,
+ ids: [string,string,string] = ['p','q','r']) {
+ const [ownerId, targetId] = ids;
+ const g = nexusTraitorFixture({ ownerFaction: 'atreides', opponentFaction: 'guild',
+ advanced, phase: 2, seatIds: ids });
+ const cards = g.nexusCards!.cards!;
+ const index = cards.deck.indexOf('ecaz');
+ assert.ok(index >= 0);
+ cards.deck[index] = cards.hands[ownerId]!;
+ cards.hands[ownerId] = 'ecaz';
+ setAnswer(g, positive);
+ nexusTraitorInventory(g);
+ const action = ecazInquiryAction(viewGame(g, ownerId), targetId);
+ assert.ok(action);
+ return { g, action };
+}
+
+function reject(g: Game, id: string, action: {type: string;[key: string]: unknown}) {
+ const before = reload(g);
+ assert.throws(() => applyAction(g, id, action));
+ assert.deepEqual(g, before);
+}
+
+void test('Basic and Advanced inquiries spend one physical card and preserve only the owner’s historical yes/no', () => {
+ for (const advanced of [false, true]) for (const positive of [false, true]) {
+ const { g, action } = position(positive, advanced);
+ const offer = viewGame(g, 'p').nexusEcazInquiry.offer!;
+ assert.equal(offer.blocked, null);
+ assert.deepEqual(offer.targets.map(target => target.id), ['p', 'q', 'r']);
+ assert.equal(viewGame(g, 'q').nexusEcazInquiry.offer, null);
+ assert.equal(JSON.stringify(offer).includes(seat(g, 'q').traitors[0]), false);
+ const before = reload(g), beforeLog = g.log.length;
+ const done = applyAction(g, 'p', action);
+ assert.deepEqual(g, before);
+ assert.equal(done.nexusCards!.cards!.hands.p, null);
+ assert.equal(done.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
+ assert.equal(done.nexusEcazInquiries?.length, 1);
+ assert.equal(done.nexusEcazInquiries![0].answer, positive);
+ assert.equal(done.log.slice(beforeLog).some(row => /\b(?:Yes|No)\b/.test(row.text)), false);
+ assert.equal(viewGame(done, 'p').nexusEcazInquiry.history[0].answer, positive);
+ assert.deepEqual(viewGame(done, 'q').nexusEcazInquiry.history, []);
+ assert.deepEqual(viewGame(done, 'r').nexusEcazInquiry.history, []);
+ assert.equal(JSON.stringify(viewGame(done, 'q')).includes(done.nexusEcazInquiries![0].signature), false);
+ assert.deepEqual(normalizeAutomaticGame(reload(done)), done);
+ reject(done, 'p', action);
+ nexusTraitorInventory(done);
+ const changed = reload(done);
+ setAnswer(changed, !positive);
+ assert.equal(viewGame(changed, 'p').nexusEcazInquiry.history[0].answer, positive);
+ }
+});
+
+void test('self-targeting and later Nexus discard recycling preserve the recorded answer', () => {
+ const { g } = position(false);
+ const expected = ecazInquiryAnswer(seat(g, 'p'), seat(g, 'p'));
+ const action = ecazInquiryAction(viewGame(g, 'p'), 'p');
+ assert.ok(action);
+ const done = applyAction(g, 'p', action);
+ assert.deepEqual(viewGame(done, 'p').nexusEcazInquiry.history[0],
+ { event: action.event, target: 'p', turn: done.turn, phase: done.phase, answer: expected });
+ const recycled = reload(done), cards = recycled.nexusCards!.cards!;
+ cards.discard.push(...cards.deck.splice(0));
+ recycled.nexusCards!.cards = drawNexusCard(cards, 'p', recycled.players, () => 0);
+ assert.equal(recycled.nexusCards!.cards!.discard.includes('ecaz'), false);
+ assert.equal(viewGame(recycled, 'p').nexusEcazInquiry.history[0].answer, expected);
+ assert.deepEqual(normalizeAutomaticGame(reload(recycled)), recycled);
+});
+
+void test('inquiry counts dead or captured native leaders but not foreign discs, Duke or Face Dancers', () => {
+ const { g } = position(false);
+ const owner = seat(g, 'p'), target = seat(g, 'q');
+ assert.equal(ecazInquiryAnswer(owner, target), false);
+ const native = owner.leaders[0];
+ native.dead = true;
+ native.capturedBy = 'r';
+ target.traitors.push(native.id);
+ assert.equal(ecazInquiryAnswer(owner, target), true);
+ target.traitors.pop();
+ owner.leaders.push({ ...native, id: 'duke-vidal' });
+ target.traitors.push('duke-vidal');
+ assert.equal(ecazInquiryAnswer(owner, target), false);
+ target.traitors.pop();
+ target.faceDancers = [{ leader: native.id, revealed: false }];
+ assert.equal(ecazInquiryAnswer(owner, target), false);
+});
+
+void test('stale, forged, foreign, interrupted and damaged saved inquiry rejects without changing a game', () => {
+ const { g, action } = position(true);
+ reject(g, 'q', action);
+ reject(g, 'p', { ...action, event: 'stale' });
+ reject(g, 'p', { ...action, target: 'foreign' });
+ reject(g, 'p', { ...action, answer: true });
+ const busy = reload(g);
+ busy.decision = { kind: 'choamMarket', player: 'q' };
+ assert.ok(viewGame(busy, 'p').nexusEcazInquiry.offer?.blocked);
+ reject(busy, 'p', action);
+ const done = applyAction(g, 'p', action);
+ for (const damage of [
+ (state: Game) => { state.nexusEcazInquiries![0].answer = false; },
+ (state: Game) => { state.nexusEcazInquiries![0].target = 'r'; },
+ (state: Game) => { state.nexusEcazInquiryLast!.event = 'stale'; },
+ (state: Game) => { delete state.nexusEcazInquiries; },
+ ]) {
+ const corrupt = reload(done); damage(corrupt); const before = reload(corrupt);
+ assert.throws(() => viewGame(corrupt, 'p'));
+ assert.deepEqual(corrupt, before);
+ }
+});
+
+void test('all four AI profiles inquire legally and the table keeps prior answers private after spending', () => {
+ const { g, action } = position(true);
+ for (const difficulty of ['Easy','Medium','Hard','Brutal'] as const) {
+ const view = viewGame(g, 'p');
+ view.players.find(player => player.id === 'p')!.bot = difficulty;
+ assert.deepEqual(botActions(view)[0], action);
+ }
+ const offerMarkup = renderToStaticMarkup(createElement(NexusEcazInquiry, {
+ game: viewGame(g, 'p'), busy: false, act() {},
+ }));
+ assert.match(offerMarkup, /Spend Ecaz Nexus · ask privately/);
+ assert.equal(renderToStaticMarkup(createElement(NexusEcazInquiry, {
+ game: viewGame(g, 'q'), busy: false, act() {},
+ })), '');
+ const done = applyAction(g, 'p', action);
+ const historyMarkup = renderToStaticMarkup(createElement(NexusEcazInquiry, {
+ game: viewGame(done, 'p'), busy: false, act() {},
+ }));
+ assert.match(historyMarkup, /held one of your native leaders: Yes/);
+ assert.equal(renderToStaticMarkup(createElement(NexusEcazInquiry, {
+ game: viewGame(done, 'q'), busy: false, act() {},
+ })), '');
+});
+
+const clock: RoomsClock = {now: () => 10000, sleep: async () => {}};
+void test('simultaneous saved inquiries commit once and keep answer private after authenticated recovery', async () => {
+ const store = unitStore();
+ try {
+ const made = await store.rooms.createRoom('Ecaz inquiry SQL', 'atreides', false, []);
+ const code = made.view.code;
+ const tokens = [made.token];
+ for (const faction of ['guild', 'fremen'] as const)
+ tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
+ const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
+ const ids = auths.map(auth => auth.playerId) as [string,string,string];
+ const { g } = position(true, false, ids);
+ g.code = code;
+ g.version = (await store.rooms.readRoom(code)).version;
+ store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
+ .run(JSON.stringify(g), g.version, code);
+ const action = ecazInquiryAction(viewGame(g, ids[0]), ids[1])!;
+ let arrivals = 0, release!: () => void;
+ const gate = new Promise(resolve => { release = resolve; });
+ store.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; };
+ const timer = setTimeout(release, 2000);
+ let results: PromiseSettledResult[];
+ try {
+ results = await Promise.allSettled([
+ store.restart().act(code, auths[0], g.version, action, clock),
+ store.restart().act(code, auths[0], g.version, action, clock),
+ ]);
+ } finally { clearTimeout(timer); delete store.hooks.beforeWrite; }
+ assert.equal(arrivals, 2);
+ assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
+ const done = await store.restart().readRoom(code);
+ assert.equal(done.version, g.version + 1);
+ assert.equal(done.nexusEcazInquiries?.length, 1);
+ assert.equal(done.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
+ assert.equal((await store.restart().readSeatView(code, auths[0])).nexusEcazInquiry.history[0].answer, true);
+ for (const auth of auths.slice(1))
+ assert.deepEqual((await store.restart().readSeatView(code, auth)).nexusEcazInquiry.history, []);
+ reject(done, ids[0], action);
+ } finally { store.sqlite.close(); }
+});