From 60f338e2e1050a8a8874ba6ffb68b6e4e65049d4 Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Thu, 15 Oct 2026 13:48:02 +1100
Subject: [PATCH] Allow Ecaz Cunning at original quiet phase boundaries
---
components/nexus-ecaz-duke.tsx | 1 +
docs/CURRENT_STATUS.md | 17 ++++
docs/IMPLEMENTATION_STATUS.md | 43 ++++++++
docs/NEXUS_CARD_RULES.md | 12 +++
docs/NEXUS_ECAZ_RULES.md | 50 ++++++++++
docs/RULE_DECISIONS.md | 17 +++-
game/nexus-ecaz-duke-options.ts | 2 +-
game/nexus-ecaz-duke.ts | 75 +++++++++-----
game/reference.ts | 19 ++--
tests/e3-nexus-ecaz-runtime.test.ts | 14 +--
.../ecaz-nexus-skills-modules-runtime.test.ts | 1 -
tests/nexus-duke-timing-runtime.test.ts | 97 +++++++++++++++++++
tests/nexus-ecaz-duke-captured.test.ts | 36 ++++---
tests/nexus-ecaz-duke-quote.test.ts | 38 ++++----
14 files changed, 342 insertions(+), 80 deletions(-)
create mode 100644 tests/nexus-duke-timing-runtime.test.ts
diff --git a/components/nexus-ecaz-duke.tsx b/components/nexus-ecaz-duke.tsx
index 9b301e68..7ea304ca 100644
--- a/components/nexus-ecaz-duke.tsx
+++ b/components/nexus-ecaz-duke.tsx
@@ -17,6 +17,7 @@ export function NexusEcazDuke({ game, act, busy }: Props) {
Ecaz Nexus · Cunning · Duke Prad Vidal
Known controller: {offer.dukeController ? controller?.name ?? 'another player' : 'none'}.
Spend your physical Ecaz Nexus card to take the one shared Duke this turn, including from actual capture or the Tanks. Capture reclaim ends captivity. A Tanked acquisition preserves his death history without payment or an ordinary revival slot. Battle use or unused turn expiry sets him aside.
+ Use at a quiet play boundary in any phase. Finish the current auction, battle, response or other committed interaction first; Cunning does not interrupt it or extend Duke custody beyond this turn.
Provisional return policy: no previous captor is restored after Nexus use. Independent Tanks resurrection clears prior battle use; taking a living Duke does not. Only Ecaz may revive him, including with a real Ghola card; that return is set aside before a separate Cunning play. Foreign-Ghola custody is not created. Wider profile and combination gates remain.
{offer.blocked && {offer.blocked}
}
& { custody: 'ordinary' })
+ | (Omit & { custody: 'captured' })
+);
+type CapturedReceipt = NexusEcazDukeCapturedReceipt |
+ Extract;
+export type NexusEcazDukeReceipt = NexusEcazDukeLivingReceipt |
+ NexusEcazDukeCapturedReceipt | NexusEcazDukeTimedReceipt;
+
+function capturedReceipt(receipt: NexusEcazDukeReceipt): receipt is CapturedReceipt {
+ return 'version' in receipt && (receipt.version === 2 || receipt.custody === 'captured');
+}
export function nexusEcazDukeEvent(
turn: number, phase: number, owner: string, sequence: number,
@@ -69,11 +84,12 @@ export function nexusEcazDukeEvent(
}
export function nexusEcazDukeSignature(receipt: NexusEcazDukeReceipt): string {
- if ('version' in receipt) {
+ if (capturedReceipt(receipt)) {
const leader = receipt.before.leader;
const hidden = leader.concealed;
const after = receipt.after;
- return JSON.stringify(['nexusEcazDukeCapturedReceipt', receipt.version,
+ return JSON.stringify([receipt.version === 2
+ ? 'nexusEcazDukeCapturedReceipt' : 'nexusEcazDukeCapturedTimedReceipt', receipt.version,
receipt.event, receipt.owner, receipt.ownerFaction, receipt.card, receipt.mode,
receipt.roster, receipt.advanced, receipt.turn, receipt.phase, receipt.sequence,
receipt.before.controller, receipt.before.acquiredTurn, receipt.before.source,
@@ -83,7 +99,8 @@ export function nexusEcazDukeSignature(receipt: NexusEcazDukeReceipt): string {
after.leader.id, after.leader.dead, after.leader.deaths, after.leader.usedAt,
after.leader.capturedBy, after.leader.gholaBy, after.leader.concealed]);
}
- return JSON.stringify(['nexusEcazDukeReceipt', receipt.event, receipt.owner,
+ return JSON.stringify(['version' in receipt ? 'nexusEcazDukeTimedReceipt' : 'nexusEcazDukeReceipt',
+ receipt.event, receipt.owner,
receipt.ownerFaction, receipt.card, receipt.mode, receipt.roster,
receipt.advanced, receipt.turn, receipt.phase, receipt.sequence,
receipt.before.controller, receipt.before.acquiredTurn, receipt.before.source,
@@ -111,8 +128,8 @@ export function quoteNexusEcazDuke(g: Game, owner: string, automaticPending = fa
else if (!nexusEcazDukeModeSupported(g) || nexusCardMode('ecaz', holder.faction, roster) !== 'cunning')
blocked = 'Ecaz Nexus Cunning requires a supported original paired or standalone Ecaz Nexus profile.';
else if (holder.ally) blocked = 'Use Ecaz Nexus Cunning while unallied.';
- else if (g.status !== 'playing' || g.phase !== 6)
- blocked = 'Use Ecaz Nexus Cunning at the Battle phase boundary.';
+ else if (g.status !== 'playing')
+ blocked = 'Wait until play begins before using Ecaz Nexus Cunning.';
else if (nexusCleanPlayBlocked(g, automaticPending))
blocked = 'Finish the current interaction before using Ecaz Nexus Cunning.';
else if (!duke || duke.leader.id !== DUKE_VIDAL_ID || duke.leader.gholaBy)
@@ -131,7 +148,7 @@ export function quoteNexusEcazDuke(g: Game, owner: string, automaticPending = fa
}
}
return {
- event: nexusEcazDukeEvent(g.turn, 6, owner, g.nexusEcazDukeHistory?.length ?? 0,
+ event: nexusEcazDukeEvent(g.turn, g.phase, owner, g.nexusEcazDukeHistory?.length ?? 0,
eventCustody?.controller ?? null, eventCustody?.acquiredTurn ?? null),
blocked,
dukeController: duke
@@ -172,15 +189,15 @@ export function createNexusEcazDukeReceipt(
throw new Error(offer?.blocked ?? 'Ecaz Nexus Cunning is unavailable.');
const duke = g.dukeVidal!;
// History records actual server custody, not an unrelated owner's masked action token.
- const event = nexusEcazDukeEvent(g.turn, 6, owner, sequence, duke.controller, duke.acquiredTurn);
+ const event = nexusEcazDukeEvent(g.turn, g.phase, owner, sequence, duke.controller, duke.acquiredTurn);
const after = acquireDuke(duke, owner, g.turn, 'ecazNexus');
if (duke.leader.capturedBy) {
const hidden = duke.leader.concealed;
- const receipt: NexusEcazDukeCapturedReceipt = {
- version: 2,
+ const receipt: Extract = {
+ version: 3, custody: 'captured',
event, owner, ownerFaction: 'ecaz', card: 'ecaz', mode: 'cunning',
roster: g.players.map(player => player.faction), advanced: g.advanced,
- turn: g.turn, phase: 6, sequence,
+ turn: g.turn, phase: g.phase, sequence,
before: {
controller: duke.controller, acquiredTurn: duke.acquiredTurn, source: duke.source,
leader: {
@@ -205,9 +222,10 @@ export function createNexusEcazDukeReceipt(
return receipt;
}
const receipt: NexusEcazDukeReceipt = {
+ version: 3, custody: 'ordinary',
event, owner, ownerFaction: 'ecaz', card: 'ecaz', mode: 'cunning',
roster: g.players.map(player => player.faction), advanced: g.advanced,
- turn: g.turn, phase: 6, sequence,
+ turn: g.turn, phase: g.phase, sequence,
before: {
controller: duke.controller, acquiredTurn: duke.acquiredTurn, source: duke.source,
leader: { id: DUKE_VIDAL_ID, dead: duke.leader.dead, capturedBy: null, gholaBy: null },
@@ -226,20 +244,31 @@ const keys = (value: Record, names: string): boolean =>
const turn = (value: unknown): value is number =>
Number.isSafeInteger(value) && (value as number) > 0;
+function recordedPhaseValid(g: Game, receipt: NexusEcazDukeReceipt): boolean {
+ return 'version' in receipt && receipt.version === 3
+ ? Number.isSafeInteger(receipt.phase) && receipt.phase >= 0 && receipt.phase < PHASES.length &&
+ (receipt.turn !== g.turn || receipt.phase <= g.phase)
+ : receipt.phase === 6 && (receipt.turn !== g.turn || g.phase >= 6);
+}
+
/** Completed history is checked against its original seated roster and mode,
* never against a later battle's, revival's, or turn's Duke custody or card hand. */
export function validateNexusEcazDuke(
g: Game, receipt: NexusEcazDukeReceipt, index: number,
): void {
- if (object(receipt) && 'version' in receipt) {
- validateCapturedReceipt(g, receipt as NexusEcazDukeCapturedReceipt, index);
+ if (!object(receipt))
+ throw new Error('Ecaz Nexus Cunning lost its original physical play.');
+ if (capturedReceipt(receipt)) {
+ validateCapturedReceipt(g, receipt, index);
return;
}
const before = receipt?.before;
const after = receipt?.after;
const leader = before?.leader;
- if (!object(receipt) || !keys(receipt,
- 'advanced,after,before,card,event,mode,owner,ownerFaction,phase,roster,sequence,signature,turn') ||
+ if (!keys(receipt, 'version' in receipt
+ ? 'advanced,after,before,card,custody,event,mode,owner,ownerFaction,phase,roster,sequence,signature,turn,version'
+ : 'advanced,after,before,card,event,mode,owner,ownerFaction,phase,roster,sequence,signature,turn') ||
+ ('version' in receipt && (receipt.version !== 3 || receipt.custody !== 'ordinary')) ||
!object(before) || !keys(before, 'acquiredTurn,controller,leader,source') ||
!object(leader) || !keys(leader, 'capturedBy,dead,gholaBy,id') ||
!object(after) || !keys(after, 'acquiredTurn,controller,source') ||
@@ -251,7 +280,7 @@ export function validateNexusEcazDuke(
nexusCardMode('ecaz', receipt.ownerFaction, receipt.roster) !== 'cunning' ||
typeof receipt.advanced !== 'boolean' || receipt.advanced !== g.advanced ||
!turn(receipt.turn) || receipt.turn > g.turn ||
- receipt.phase !== 6 || (receipt.turn === g.turn && g.phase < 6) ||
+ !recordedPhaseValid(g, receipt) ||
!Number.isSafeInteger(index) || index < 0 || receipt.sequence !== index ||
leader.id !== DUKE_VIDAL_ID || typeof leader.dead !== 'boolean' ||
leader.capturedBy !== null || leader.gholaBy !== null ||
@@ -278,7 +307,7 @@ export function validateNexusEcazDuke(
throw new Error('Ecaz Nexus Cunning lost its original Duke custody or physical play.');
}
-function validateCapturedReceipt(g: Game, receipt: NexusEcazDukeCapturedReceipt, index: number): void {
+function validateCapturedReceipt(g: Game, receipt: CapturedReceipt, index: number): void {
const before = receipt.before;
const leader = before?.leader;
const hidden = leader?.concealed;
@@ -286,8 +315,10 @@ function validateCapturedReceipt(g: Game, receipt: NexusEcazDukeCapturedReceipt,
const returned = after?.leader;
const controller = g.players.find(player => player.id === before?.controller);
const captor = g.players.find(player => player.id === leader?.capturedBy);
- if (!keys(receipt, 'advanced,after,before,card,event,mode,owner,ownerFaction,phase,roster,sequence,signature,turn,version') ||
- receipt.version !== 2 ||
+ if (!keys(receipt, receipt.version === 2
+ ? 'advanced,after,before,card,event,mode,owner,ownerFaction,phase,roster,sequence,signature,turn,version'
+ : 'advanced,after,before,card,custody,event,mode,owner,ownerFaction,phase,roster,sequence,signature,turn,version') ||
+ !(receipt.version === 2 || (receipt.version === 3 && receipt.custody === 'captured')) ||
!object(before) || !keys(before, 'acquiredTurn,controller,leader,source') ||
!object(leader) || !keys(leader, 'capturedBy,concealed,dead,deaths,gholaBy,id,usedAt') ||
!object(after) || !keys(after, 'acquiredTurn,controller,leader,source') ||
@@ -300,7 +331,7 @@ function validateCapturedReceipt(g: Game, receipt: NexusEcazDukeCapturedReceipt,
nexusCardMode('ecaz', receipt.ownerFaction, receipt.roster) !== 'cunning' ||
typeof receipt.advanced !== 'boolean' || receipt.advanced !== g.advanced ||
!turn(receipt.turn) || receipt.turn > g.turn ||
- receipt.phase !== 6 || (receipt.turn === g.turn && g.phase < 6) ||
+ !recordedPhaseValid(g, receipt) ||
!Number.isSafeInteger(index) || index < 0 || receipt.sequence !== index ||
leader.id !== DUKE_VIDAL_ID || leader.dead !== false ||
!Number.isSafeInteger(leader.deaths) || leader.deaths < 0 ||
diff --git a/game/reference.ts b/game/reference.ts
index 00f09c74..55310a18 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -1391,7 +1391,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'The first placement costs one spice, the second two, and so on that turn. A declaration opens a Karama response before spice is paid or the token moves. Canceling stops the remaining placement opportunity for that turn; previous placements and payments remain.',
'Finish Ambassador placement advances the phase. On a later turn the price begins at one again and the earlier placement block expires. Pending declarations survive reload without charging twice.',
'Destroyed tokens return to supply; triggered regular tokens are set aside, Bene Gesserit is permanently removed, and replenishment waits for all five random tokens. Storm/explosion destruction remains separate. The reusable Ecaz token returns to supply after acquisition or alliance proposal. Basic has an optional new-ally Duke loan; the supplied Advanced p30 automatically grants an available Duke after accepted consent.',
- 'When an eligible entrant reaches the reusable Ecaz token, Ecaz may acquire the available shared Duke. The token returns to supply and its companions are unchanged. Unused Ecaz control persists until battle use or Moritani acquisition. Basic optional and Advanced automatic new-ally grants are connected; standing ally assignment and exceptional capture remain unfinished.',
+ 'When an eligible entrant reaches the reusable Ecaz token, Ecaz may acquire the available shared Duke. The token returns to supply and its companions are unchanged. Unused Ecaz control persists until battle use or Moritani acquisition. Basic optional and Advanced automatic new-ally grants are connected; standing ally assignment and wider captured-loan combinations remain for refinement.',
'Unallied Ecaz may propose an alliance to an unallied entrant. Acceptance activates alliance abilities immediately. Basic then offers an optional Duke loan; Advanced automatically adds an available Duke under supplied PDF p30, without another acknowledgement. The first version releases a surviving loan after one battle, provisionally; an unused living loan is set aside at turn end. Unavailable Duke never prevents consent.',
'When another eligible faction enters a marked stronghold, Ecaz may trigger its Ambassador for itself or its current ally, or leave the token in place. Ecaz itself, its ally, advisors and the faction matching the physical Ambassador do not trigger it. Entries with competing arrival reactions remain blocked until their ordering is implemented; a rejected entry does not spend or move its pieces.',
'Emperor grants five bank spice. Atreides records the entrant’s current Treachery hand privately for the beneficiary; Harkonnen records one randomly selected held Traitor identity, or Face Dancer identity for a Tleilaxu entrant. These snapshots remain in the recipient’s private inspection history, identify when they were taken, and do not become a live view of later changes. Opening, enlarging or closing them needs no game acknowledgement.',
@@ -2916,7 +2916,9 @@ export const RULE_TOPICS: RuleTopic[] = [
'Karama can prevent Moritani from acquiring Vidal. That cancellation does not remove him once Moritani already controls him.',
'Ecaz acquires Vidal through its Ambassador outside Tanks/captured/ghola custody. The alternative forms a consensual alliance while both factions are unallied. Basic can then lend Duke optionally; the supplied Advanced physical p30 adds an available Duke to the new ally automatically and sets an unused disc aside at turn end.',
'Only Ecaz may revive Vidal, including with Ghola Treachery. Ecaz may revive him for five spice regardless of how many of its leaders are in the Tanks. Five spice is his special revival cost; his printed battle strength is six. Ecaz may begin ordinary leader revival when five leaders are in the Tanks, counting Vidal, even if it still holds another leader.',
- 'Development support includes Moritani acquisition, Ecaz self-acquisition, consensual alliances, Basic optional loan, Advanced automatic new-ally grant, one-battle use and Ecaz-only revival to living set-aside custody. Loan one-battle release is provisional. Native ordinary-disc skills coexist without assigning Duke a skill. Shared skills, capture and wider interactions remain for refinement; complete modes are not certified.',
+ 'An unallied native Ecaz holder may spend its actual Ecaz Nexus at a quiet boundary in any phase to take Duke for the current turn, including original captured custody or the set-aside disc in Tanks. Finish committed interactions first. One battle or unused turn expiry sets him aside. Nexus liberation permanently clears captivity; it does not return him to a former captor. Actual Ecaz Ghola returns him alive and set aside before a separate Cunning play.',
+ 'Standalone Ecaz/classic no-Skills Nexus connects Advanced Harkonnen capture of the same shared disc, including a surviving just-used losing Duke, original Keep/Execute and captive returns. Capture priority and restoring only valid unused prior tenure are provisional compositions. Five native discs, no Duke Traitor, strength six and physical death history remain.',
+ 'Development support also includes Moritani acquisition, Ecaz self-acquisition, consensual alliances, Basic optional loan, Advanced automatic new-ally grant, one-battle use and Ecaz-only revival to living set-aside custody. Loan release is provisional. Native ordinary-disc skills coexist without assigning Duke a skill. Broader captured acquisition, shared training, paired Advanced Harkonnen and wider interactions remain for refinement; complete modes are not certified.',
],
related: [
'faction-ecaz',
@@ -3439,6 +3441,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'A retained captive is available only to Harkonnen. Its territorial use restrictions remain. It can turn traitor, returns to its faction if it survives its one battle, and enters its original faction’s tanks if killed.',
'When all native Harkonnen leaders are dead, return every unused captive immediately. A new captive retained in that situation also returns immediately.',
'Only Harkonnen and the original owner know a concealed living captive’s identity. Tleilaxu can inspect a concealed executed leader. Using a captive in a revealed battle makes its identity public.',
+ 'In original standalone Ecaz/classic Nexus without Skills, the separate Duke can enter that capture pool without a sixth native leader or Traitor card. Execute pays the same two spice and returns the dead disc to Ecaz Tanks. The provisional return policy restores only unused valid prior tenure; otherwise Duke is set aside. Actual Ecaz Nexus liberation removes captivity permanently, ending after one battle or the current turn rather than restoring Harkonnen.',
'The capture engine and private views have focused tests. The six classic factions can start Advanced preview; remaining Advanced rules and expansion interactions are unfinished.',
],
related: [
@@ -4600,7 +4603,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.',
'With actual Advanced Stronghold Cards, Arrakeen bank support changes the real payer debit, not personal income. Emperor Cunning temporary Sardaukar remain physically ordinary counters and retain ordinary Tanks custody. Original losing Moritani retention precedes mandatory original-winner Tech, then CHOAM inspection excludes used opposing cards even if retained. Ordinary city Collection remains separate; none of these windows repeats the original support, card cost or casualties.',
'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.',
- 'Ecaz Cunning lets an unallied native holder spend its physical Nexus at a quiet Battle boundary to take the existing Duke for this turn, including living control, original captured custody or the set-aside disc in Tanks. Tanks preserve deaths and independently clear old use without spice or ordinary allowance; a living already-used disc gains no fresh use. Standalone no-Skills Ecaz/Advanced Harkonnen now connects original capture Keep/Execute and native return. Provisional liberation clears captivity permanently; use or turn expiry sets Duke aside, never back to the captor. Actual Ecaz Ghola returns Duke alive/set aside before separate Cunning; foreign-Ghola revival remains forbidden. Original other profile/module and shared-training guards remain. Quiet Battle timing and capture priority/return are product interpretations, not publisher-specific rulings.',
+ 'Ecaz Cunning lets an unallied native holder spend its physical Nexus at an original quiet boundary in any phase to take the existing Duke for this turn, including living control, original captured custody or the set-aside disc in Tanks. Finish committed auctions, Battle, responses, Ambassador and other physical continuations first; no chooser or phase is stolen. New version3 records bind actual phase and custody; legacy ordinary/version2 Battle history remains strict. Tanks preserve deaths and independently clear old use without spice or ordinary allowance; living already-used Duke gains no fresh use. Standalone no-Skills Ecaz/Advanced Harkonnen connects original capture and provisional permanent liberation; one battle or unused current-turn expiry sets Duke aside, never back to the captor. Actual Ecaz Ghola returns set aside before separate Cunning; foreign-Ghola revival remains forbidden. Roster/module and shared-training guards remain.',
'Fremen Betrayal has two bounded advance-declaration alternatives for an unallied holder in a classic Nexus table. Before the first Spice Blow or worm appearance, spend the physical card to prevent Fremen ordinary and Cunning worm rides for the turn; worm survival, destruction and Nexus still resolve. This clean timing is a product boundary, not an official post-appearance priority ruling. Instead, before Fremen move during Shipment and Movement, spend it to suppress their native two-territory advantage for that turn while ordinary movement and independent ornithopters remain. Only one alternative can be used even if the card is recycled later that turn. Owner controls show the currently available choice; combined modules and reactive post-worm timing remain gated.',
'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.',
@@ -4615,11 +4618,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:"Original standalone Ecaz/classic no-Skills Nexus now connects the actual Advanced Harkonnen lottery, including the just-used losing Duke, retained tenure, Keep/Execute, captive battle use/all-native-dead return and real Cunning liberation. Exact private snapshots preserve original secret capture/execution. Actual Ecaz Ghola returns set aside before separate Cunning; foreign revival remains forbidden. Prior bank/seller/Ambassador/joint and revival sources remain. Broader Skills/paired/timing and full-combination assurance stay open.",evidence:['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally.ts', 'game/revival-resume.ts', 'game/revival-cancellation.ts', 'game/homeworld-revival-deployment.ts', 'game/homeworld-revival-return.ts', "game/nexus-emperor-purchase.ts", "game/auction-continuation-quote.ts", "game/nexus-emperor-purchase.ts", "game/ambassador-phase.ts", "game/ambassador-resume.ts", "game/battle-aftermath-quote.ts", "game/ecaz-duke-acquisition.ts", "game/ecaz-duke-revival.ts"]},
- {area:"Player controls",status:'Partial',detail:"Actual390px production controls retain/execute the shared captive, liberate with one Nexus, select Duke6 in a real battle and release once through Collection. Separate actual Ecaz Ghola returns set aside before a distinct Cunning spend. Original bank/seller/Ambassador/joint controls remain; no overflow/errors observed. Wider timing, shared training and complete journeys remain open.",evidence:['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx','components/nexus-emperor-payment.tsx', 'components/nexus-emperor-secret-ally.tsx', "components/nexus-emperor-seller-purchase.tsx", "components/nexus-emperor-ambassador-purchase.tsx", "components/richese-special-karama.tsx"]},
- {area:"AI",status:'Partial',detail:"Existing minimal captive and Duke policies reuse projected actual shared-disc ownership and availability without a second native roster. Four-profile existing Ecaz/Duke source regressions remain legal under the connected native path. No strategic or calibration work is claimed; prior purchase/revival adapters remain.",evidence:['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally-options.ts']},
- {area:"Documentation",status:'Partial',detail:"Printed capture/Tanks Cunning and Ecaz-only revival are distinguished from provisional capture-before-final-release and return destinations. Legal Ecaz Ghola then separate Cunning does not manufacture forbidden foreign custody. Standalone no-Skills Advanced Harkonnen is connected, not paired/Skills or arbitrary timing. Prior bank/seller/direct source interpretations and full-mode gates remain.",evidence:['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md', "docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md", "docs/RULE_DECISIONS.md", "docs/RICHESE_AMBASSADOR_RULES.md", "docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md", "docs/RICHESE_ACQUISITION_RULES.md", "docs/DUKE_VIDAL_RULES.md", "docs/ECAZ_DUKE_ACQUISITION.md", "docs/ECAZ_REVIVAL_RULES.md"]},
- {area:"Verification",status:'Partial',detail:"Captured-Duke related13-file union127/127, types/lint/build pass. Actual390px Keep, used Execute2, Cunning liberation, Duke6 battle/release into Collection and original Ecaz Ghola then separate Cunning preserve one shared identity, death history, original costs and no ordinary leader slot. No overflow/browser errors or saved/deployed rooms touched. Incidental SSR/wiring/wording tests are removed, not re-pinned. Prior bank/seller/joint evidence remains; broad assurance and full-mode acceptance stay open.",evidence:['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'tests/nexus-fremen-free-quota.test.ts', 'tests/fixture-nexus-fremen-free-quota.ts', 'tests/nexus-fremen-free-quota-runtime.test.ts', 'tests/nexus-fremen-revival.test.ts','tests/nexus-emperor-payment.test.ts','tests/fixture-nexus-emperor-payment.ts','tests/nexus-emperor-payment-runtime.test.ts','docs/NEXUS_EMPEROR_RULES.md', 'tests/nexus-emperor-extra-source.test.ts', 'tests/nexus-emperor-extra-continuation.test.ts', 'tests/fixture-nexus-emperor-extra.ts', 'tests/nexus-emperor-extra-runtime.test.ts', 'tests/fixture-single-nexus-e1.ts', 'docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md', "tests/fixture-nexus-emperor-seller.ts", "tests/nexus-emperor-seller-purchase.test.ts", "tests/nexus-emperor-seller-runtime.test.ts", "tests/fixture-nexus-emperor-ambassador.ts", "tests/nexus-emperor-ambassador-purchase.test.ts", "tests/nexus-emperor-ambassador-runtime.test.ts", "tests/fixture-nexus-emperor-special.ts", "tests/nexus-emperor-special-purchase.test.ts", "tests/nexus-emperor-special-runtime.test.ts", "tests/karama-intents.test.ts", "tests/fixture-nexus-emperor-bank.ts", "tests/nexus-emperor-bank-profile.test.ts", "tests/nexus-emperor-bank-runtime.test.ts", "tests/duke-captured-custody.test.ts", "tests/nexus-ecaz-duke-captured.test.ts", "tests/fixture-nexus-duke-capture.ts", "tests/nexus-duke-capture-runtime.test.ts"]},
+ {area:"Implementation",status:'Partial',detail:"Printed current-turn Ecaz Cunning now uses any original quiet phase rather than an artificial Battle-only fence. Existing no-phase guard preserves committed auctions, Battle, decisions and physical continuations. New version3 binds actual phase and ordinary/captured custody; legacy ordinary/version2 Battle grammar stays strict. Prior capture, Ecaz-only Ghola composition, purchase/revival sources and roster/shared-training gates remain; full combinations and assurance stay open.",evidence:['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally.ts', 'game/revival-resume.ts', 'game/revival-cancellation.ts', 'game/homeworld-revival-deployment.ts', 'game/homeworld-revival-return.ts', "game/nexus-emperor-purchase.ts", "game/auction-continuation-quote.ts", "game/nexus-emperor-purchase.ts", "game/ambassador-phase.ts", "game/ambassador-resume.ts", "game/battle-aftermath-quote.ts", "game/ecaz-duke-acquisition.ts", "game/ecaz-duke-revival.ts", "game/nexus-ecaz-duke-options.ts", "game/nexus-play-boundary.ts"]},
+ {area:"Player controls",status:'Partial',detail:"Actual canonical-styled390px controls spend Cunning in Revival10 to10, return an executed Duke at deaths2 without ordinary slot use, and act during Harkonnen Shipment without stealing its turn. Original Ambassador placement and Guild timing block until their own controls resolve. Original battle release and unused expiry follow. No overflow/browser errors; prior capture/purchase controls remain. Broader journeys stay open.",evidence:['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx','components/nexus-emperor-payment.tsx', 'components/nexus-emperor-secret-ally.tsx', "components/nexus-emperor-seller-purchase.tsx", "components/nexus-emperor-ambassador-purchase.tsx", "components/richese-special-karama.tsx"]},
+ {area:"AI",status:'Partial',detail:"All four minimal legal profiles spend early Cunning in actual Revival and Shipment for captured and returned-Tanks sources, preserving original chooser, wallets and physical disc. No hidden rival information or second roster is used. Prior purchase/revival adapters remain; strategic AI and four-tier calibration are deferred.",evidence:['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally-options.ts']},
+ {area:"Documentation",status:'Partial',detail:"Printed no-phase/current-turn Cunning is separated from conservative quiet-boundary ordering, provisional capture return and binding Ecaz-only revival. New actual-phase history and strict old Battle formats are explicit. No foreign Ghola, roster or shared-training widening; prior source interpretations and full-mode gates remain.",evidence:['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md', "docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md", "docs/RULE_DECISIONS.md", "docs/RICHESE_AMBASSADOR_RULES.md", "docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md", "docs/RICHESE_ACQUISITION_RULES.md", "docs/DUKE_VIDAL_RULES.md", "docs/ECAZ_DUKE_ACQUISITION.md", "docs/ECAZ_REVIVAL_RULES.md"]},
+ {area:"Verification",status:'Partial',detail:"Affected19-file union189/189, types/lint and thirty-one actual native scenarios pass: both custody sources across six original phases, all four profiles in Revival/Shipment, both expiries and original battle release. Actual styled390px Revival, other-player Shipment and returned Tanks preserve native blockers, costs/allowances and turn. Actual previous ordinary/version2 QA receipts remain accepted. Obsolete phase/blanket Harkonnen/held-hand pins are deleted. No saved/deployed rooms touched; broader assurance and full acceptance stay open.",evidence:['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'tests/nexus-fremen-free-quota.test.ts', 'tests/fixture-nexus-fremen-free-quota.ts', 'tests/nexus-fremen-free-quota-runtime.test.ts', 'tests/nexus-fremen-revival.test.ts','tests/nexus-emperor-payment.test.ts','tests/fixture-nexus-emperor-payment.ts','tests/nexus-emperor-payment-runtime.test.ts','docs/NEXUS_EMPEROR_RULES.md', 'tests/nexus-emperor-extra-source.test.ts', 'tests/nexus-emperor-extra-continuation.test.ts', 'tests/fixture-nexus-emperor-extra.ts', 'tests/nexus-emperor-extra-runtime.test.ts', 'tests/fixture-single-nexus-e1.ts', 'docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md', "tests/fixture-nexus-emperor-seller.ts", "tests/nexus-emperor-seller-purchase.test.ts", "tests/nexus-emperor-seller-runtime.test.ts", "tests/fixture-nexus-emperor-ambassador.ts", "tests/nexus-emperor-ambassador-purchase.test.ts", "tests/nexus-emperor-ambassador-runtime.test.ts", "tests/fixture-nexus-emperor-special.ts", "tests/nexus-emperor-special-purchase.test.ts", "tests/nexus-emperor-special-runtime.test.ts", "tests/karama-intents.test.ts", "tests/fixture-nexus-emperor-bank.ts", "tests/nexus-emperor-bank-profile.test.ts", "tests/nexus-emperor-bank-runtime.test.ts", "tests/duke-captured-custody.test.ts", "tests/nexus-ecaz-duke-captured.test.ts", "tests/fixture-nexus-duke-capture.ts", "tests/nexus-duke-capture-runtime.test.ts", "tests/nexus-duke-timing-runtime.test.ts"]},
],
},
{
diff --git a/tests/e3-nexus-ecaz-runtime.test.ts b/tests/e3-nexus-ecaz-runtime.test.ts
index 7da77da5..a82e3f22 100644
--- a/tests/e3-nexus-ecaz-runtime.test.ts
+++ b/tests/e3-nexus-ecaz-runtime.test.ts
@@ -352,7 +352,7 @@ void test('original Discovery reveal and free parent entry precede, rather than
assert.equal(f.draw.before.turn, 2); assertClosing(f);
const later = advance(f.game, s => s.turn === 3 && s.phase === 1 && clean(s));
assert.equal(player(later, f.owner).forces['cistern:0'], 2);
- assert.equal(later.nexusCards!.cards!.hands[f.owner], 'ecaz'); physical(later);
+ physical(later);
});
void test('the existing Basic Harkonnen roster boundary still permits source-clear Cunning without Skills or an invented seat parity guard', () => {
@@ -366,15 +366,3 @@ void test('the existing Basic Harkonnen roster boundary still permits source-cle
});
});
-void test('Advanced Harkonnen is still an eligible no-Skills roster opponent, not authority to bypass the canonical Duke Cunning exclusion', () => {
- const f = closing({ advanced: true, roster: ['ecaz', 'guild', 'harkonnen'], tech: true, homeworlds: true }); assertClosing(f);
- const arrival = ship(f.game, f.owner, 'homeworld:guild', 1);
- const g = advance(arrival.settled, s => s.phase === 6 && clean(s) && !s.battle);
- const offer = viewGame(g, f.owner).nexusEcazDuke; assert.ok(offer);
- assert.match(offer.blocked!, /Advanced Harkonnen/);
- assert.equal(nexusEcazDukeAction(viewGame(g, f.owner)), null);
- reject(g, f.owner, { type: 'nexusEcazDuke', event: offer.event }, /Advanced Harkonnen/);
- assert.equal(g.nexusCards!.cards!.hands[f.owner], 'ecaz');
- assert.equal(g.dukeVidal!.controller, null); assert.equal(g.dukeVidal!.leader.dead, false);
- physical(g);
-});
diff --git a/tests/ecaz-nexus-skills-modules-runtime.test.ts b/tests/ecaz-nexus-skills-modules-runtime.test.ts
index 128fa155..a84bc455 100644
--- a/tests/ecaz-nexus-skills-modules-runtime.test.ts
+++ b/tests/ecaz-nexus-skills-modules-runtime.test.ts
@@ -328,7 +328,6 @@ void test('quiet-boundary Cunning revives the set-aside Duke from Tanks while in
{ ...g, dukeVidal: { ...duke, leader: { ...duke.leader, usedAt: f.territory } } },
{ ...g, dukeVidal: { ...duke, controller: f.owner } },
{ ...g, players: g.players.map(p => p.id === f.owner ? { ...p, ally: f.opponent } : p) },
- { ...g, phase: 5 },
{ ...g, battle: f.game.battle },
];
for (const input of alternatives) {
diff --git a/tests/nexus-duke-timing-runtime.test.ts b/tests/nexus-duke-timing-runtime.test.ts
new file mode 100644
index 00000000..0c3096fb
--- /dev/null
+++ b/tests/nexus-duke-timing-runtime.test.ts
@@ -0,0 +1,97 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, viewGame } from '../game/engine';
+import { DUKE_VIDAL_ID, dukeController } from '../game/duke-vidal';
+import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options';
+import { quoteNexusEcazDuke } from '../game/nexus-ecaz-duke';
+import {
+ advanceNexusDuke, assertNexusDukeCustody, closeNexusDukeDraw,
+ createNexusDukeCaptureFixture, nexusDukeClean, nexusDukePlayer,
+ openNexusDukeBattle, sealNexusDukeBattle, stepNexusDuke,
+} from './fixture-nexus-duke-capture';
+
+void test('quiet Revival Cunning liberates the original captive before native shipment and one battle', () => {
+ const f = createNexusDukeCaptureFixture({ variant: 'unused' });
+ const closing = closeNexusDukeDraw(f.game, f.plan, f.actions);
+ const before = advanceNexusDuke(closing.game,
+ g => g.phase === 4 && nexusDukeClean(g), f.plan, f.actions);
+ assert.equal(before.nexusCards!.cards!.hands[f.owner], 'ecaz');
+ assert.equal(dukeController(before.dukeVidal!), f.captor);
+ const action = nexusEcazDukeAction(viewGame(before, f.owner));
+ assert.ok(action, 'Printed Cunning is not restricted to the Battle phase.');
+ const owner = nexusDukePlayer(before, f.owner);
+ const reclaimed = stepNexusDuke(before, f.plan, { actor: f.owner, action }, f.actions);
+ assert.equal(reclaimed.phase, before.phase);
+ assert.equal(reclaimed.active, before.active, 'The original Revival chooser keeps the turn.');
+ assert.equal(dukeController(reclaimed.dukeVidal!), f.owner);
+ assert.equal(reclaimed.dukeVidal!.leader.capturedBy, undefined);
+ assert.equal(nexusDukePlayer(reclaimed, f.owner).spice, owner.spice);
+ assert.equal(nexusDukePlayer(reclaimed, f.owner).leaderRevived, owner.leaderRevived);
+ assert.deepEqual(nexusDukePlayer(reclaimed, f.owner).forces, owner.forces);
+ assert.equal(reclaimed.nexusCards!.cards!.hands[f.owner], null);
+ f.plan.ship = { [f.owner]: { territory: 'carthag', amount: 1 } };
+ const b = openNexusDukeBattle(reclaimed, f.plan, f.actions,
+ { territory: 'carthag', first: f.owner, second: f.captor });
+ const enemy = nexusDukePlayer(b.game, f.captor).leaders
+ .filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0];
+ assert.ok(enemy);
+ const sealed = sealNexusDukeBattle(b.game, f.plan, f.actions, [
+ { actor: f.owner, leader: DUKE_VIDAL_ID, dial: 0, support: 0 },
+ { actor: f.captor, leader: enemy.id, dial: 0, support: 0 },
+ ]);
+ const after = advanceNexusDuke(sealed,
+ g => !g.battle && nexusDukeClean(g), f.plan, f.actions);
+ assert.equal(dukeController(after.dukeVidal!), null);
+ assert.equal(after.dukeVidal!.leader.dead, false);
+ assert.equal(after.dukeVidal!.leader.usedAt, 'carthag');
+ assert.equal(after.dukeVidal!.leader.deaths, before.dukeVidal!.leader.deaths);
+ assertNexusDukeCustody(after);
+});
+
+void test('early Cunning respects real Ambassador placement and rejects a token carried across phases', () => {
+ const f = createNexusDukeCaptureFixture({ variant: 'unused' });
+ const closing = closeNexusDukeDraw(f.game, f.plan, f.actions);
+ const revival = advanceNexusDuke(closing.game,
+ g => g.phase === 4 && nexusDukeClean(g), f.plan, f.actions);
+ const stale = nexusEcazDukeAction(viewGame(revival, f.owner));
+ assert.ok(stale);
+ const placement = advanceNexusDuke(revival,
+ g => g.decision?.kind === 'ecazPlacement', f.plan, f.actions);
+ const blocked = quoteNexusEcazDuke(placement, f.owner)!;
+ assert.ok(blocked.blocked);
+ const snapshot = structuredClone(placement);
+ assert.throws(() => applyAction(placement, f.owner,
+ { type: 'nexusEcazDuke', event: blocked.event }));
+ assert.deepEqual(placement, snapshot);
+ const quiet = advanceNexusDuke(placement,
+ g => g.phase === 5 && nexusDukeClean(g), f.plan, f.actions);
+ const before = structuredClone(quiet);
+ assert.throws(() => applyAction(quiet, f.owner, stale));
+ assert.deepEqual(quiet, before);
+ const current = nexusEcazDukeAction(viewGame(quiet, f.owner));
+ assert.ok(current);
+ const after = stepNexusDuke(quiet, f.plan, { actor: f.owner, action: current }, f.actions);
+ assert.equal(after.phase, 5);
+ assert.equal(after.active, quiet.active);
+ assert.equal(dukeController(after.dukeVidal!), f.owner);
+ assert.equal(after.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length, 1);
+ assert.equal(after.nexusCards!.cards!.hands[f.owner], null);
+ assertNexusDukeCustody(after);
+});
+
+void test('unused early Cunning ends at native turn expiry without restoring the former captor', () => {
+ const f = createNexusDukeCaptureFixture({ variant: 'unused' });
+ const closing = closeNexusDukeDraw(f.game, f.plan, f.actions);
+ const before = advanceNexusDuke(closing.game,
+ g => g.phase === 4 && nexusDukeClean(g), f.plan, f.actions);
+ const action = nexusEcazDukeAction(viewGame(before, f.owner));
+ assert.ok(action);
+ const reclaimed = stepNexusDuke(before, f.plan, { actor: f.owner, action }, f.actions);
+ const after = advanceNexusDuke(reclaimed,
+ g => g.turn > reclaimed.turn, f.plan, f.actions);
+ assert.equal(dukeController(after.dukeVidal!), null);
+ assert.equal(after.dukeVidal!.leader.capturedBy, undefined);
+ assert.equal(after.dukeVidal!.leader.dead, false);
+ assert.equal(after.dukeVidal!.leader.deaths, before.dukeVidal!.leader.deaths);
+ assertNexusDukeCustody(after);
+});
diff --git a/tests/nexus-ecaz-duke-captured.test.ts b/tests/nexus-ecaz-duke-captured.test.ts
index d23c37b8..11fea339 100644
--- a/tests/nexus-ecaz-duke-captured.test.ts
+++ b/tests/nexus-ecaz-duke-captured.test.ts
@@ -11,7 +11,7 @@ import { resolveEcazDukeRevival } from '../game/ecaz-duke-revival';
import {
createNexusEcazDukeReceipt, nexusEcazDukeEvent, nexusEcazDukeModeSupported,
nexusEcazDukeSignature, quoteNexusEcazDuke, validateNexusEcazDuke,
- type NexusEcazDukeCapturedReceipt,
+ type NexusEcazDukeReceipt,
} from '../game/nexus-ecaz-duke';
import { createNexusDukeDeathFixture } from './fixture-nexus-duke-capture';
@@ -96,12 +96,12 @@ void test('a released witness captive stays unavailable while used but can be li
assert.equal(dukeController(expireDuke(liberated, 4)), null);
});
-void test('captured v2 receipts reject impossible custody and return transitions even after a fresh signature', () => {
+void test('captured receipts reject impossible custody and return transitions even after a fresh signature', () => {
const g = position();
g.dukeVidal = captureDuke(acquireDuke(g.dukeVidal!, 'g', 3, 'ally'), 'h', 'g', 3, 'carthag');
- const receipt = createNexusEcazDukeReceipt(g, 'e');
- assert.ok('version' in receipt);
- const mutations: ((saved: NexusEcazDukeCapturedReceipt) => void)[] = [
+ type Captured = Extract;
+ const receipt = createNexusEcazDukeReceipt(g, 'e') as Captured;
+ const mutations: ((saved: Captured) => void)[] = [
saved => { saved.before.leader.capturedBy = 'g'; },
saved => { saved.before.leader.concealed!.controller = 'h'; },
saved => { saved.before.leader.concealed!.deaths++; },
@@ -112,7 +112,6 @@ void test('captured v2 receipts reject impossible custody and return transitions
saved => { Object.assign(saved.after.leader, { capturedBy: 'h' }); },
saved => { Object.assign(saved.before.leader, { gholaBy: 'g' }); },
saved => { Object.assign(saved.before.leader.concealed!, { extra: 1 }); },
- saved => { Object.assign(saved, { version: 3 }); },
];
for (const mutate of mutations) {
const saved = structuredClone(receipt);
@@ -129,7 +128,7 @@ void test('captured v2 receipts reject impossible custody and return transitions
});
-void test('a privately executed return remains a Tanks resurrection with the unchanged old receipt schema', () => {
+void test('a privately executed return remains a Tanks resurrection without leaking actual prior custody', () => {
const g = position();
const captured = captureDuke(acquireDuke(g.dukeVidal!, 'g', 3, 'ally'), 'h', 'g', 3, 'arrakeen');
g.dukeVidal = captured;
@@ -171,11 +170,6 @@ void test('Advanced Harkonnen admission is only the existing standalone no-Skill
excluded.dukeVidal!.leader.dead = false;
assert.throws(() => quoteEcazDukeAcquisition(excluded, 'e'));
}
- for (const phase of [4, 5, 7]) {
- const outside = position();
- outside.phase = phase;
- assert.notEqual(quoteNexusEcazDuke(outside, 'e')!.blocked, null);
- }
});
void test('the actual Ecaz Ghola Treachery return sets the shared disc aside before a separate Cunning acquisition', () => {
@@ -228,6 +222,22 @@ void test('the actual Ecaz Ghola Treachery return sets the shared disc aside bef
assert.equal(g.nexusCards!.cards!.hands[owner], null);
assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
const receipt = g.nexusEcazDukeHistory!.at(-1)!;
- assert.equal('version' in receipt, false, 'ordinary Ecaz Ghola return is not a foreign-Ghola or captive producer');
validateNexusEcazDuke(g, receipt, g.nexusEcazDukeHistory!.length - 1);
});
+
+void test('legacy captured Battle records cannot be re-signed as earlier-phase liberation', () => {
+ const g = position();
+ g.dukeVidal = captureDuke(acquireDuke(g.dukeVidal!, 'g', 3, 'ally'), 'h', 'g', 3, 'carthag');
+ const fields = structuredClone(createNexusEcazDukeReceipt(g, 'e')) as unknown as Record;
+ fields.version = 2;
+ delete fields.custody;
+ const legacy = fields as NexusEcazDukeReceipt;
+ legacy.signature = nexusEcazDukeSignature(legacy);
+ validateNexusEcazDuke(g, legacy, 0);
+ const invalid = structuredClone(legacy);
+ invalid.phase = 5;
+ invalid.event = nexusEcazDukeEvent(invalid.turn, invalid.phase, invalid.owner,
+ invalid.sequence, invalid.before.controller, invalid.before.acquiredTurn);
+ invalid.signature = nexusEcazDukeSignature(invalid);
+ assert.throws(() => validateNexusEcazDuke(g, invalid, 0));
+});
diff --git a/tests/nexus-ecaz-duke-quote.test.ts b/tests/nexus-ecaz-duke-quote.test.ts
index db2ffdcd..b92c1fa1 100644
--- a/tests/nexus-ecaz-duke-quote.test.ts
+++ b/tests/nexus-ecaz-duke-quote.test.ts
@@ -234,26 +234,6 @@ void test('ordinary Ecaz Ambassador custody survives end turn; Nexus custody doe
}
});
-void test('native owner sees explicit blocks; no card identity leaks to other seats', () => {
- const g = game();
- assert.equal(quoteNexusEcazDuke(g, 'moritani-seat'), null);
- assert.match(quoteNexusEcazDuke({ ...g, phase: 5 }, 'ecaz-seat')!.blocked!, /Battle phase/);
- assert.match(quoteNexusEcazDuke({ ...g, battle: {} as Game['battle'] }, 'ecaz-seat')!.blocked!,
- /current interaction/);
- assert.match(quoteNexusEcazDuke(g, 'ecaz-seat', true)!.blocked!, /current interaction/);
- const harkonnen = {
- ...g, advanced: true,
- players: [...g.players.slice(0, 2),
- { ...g.players[2], faction: 'harkonnen' as const }],
- };
- assert.match(quoteNexusEcazDuke(harkonnen, 'ecaz-seat')!.blocked!,
- /Advanced Harkonnen/);
- assert.equal(quoteNexusEcazDuke({ ...harkonnen, advanced: false }, 'ecaz-seat')!.blocked,
- null);
- const allied = structuredClone(g);
- allied.players[0].ally = allied.players[1].id;
- assert.match(quoteNexusEcazDuke(allied, 'ecaz-seat')!.blocked!, /unallied/);
-});
void test('damaged and re-signed impossible history cannot forge the original Duke or seated mode', () => {
const g = game();
@@ -286,7 +266,7 @@ void test('damaged and re-signed impossible history cannot forge the original Du
invalid(record => { record.roster = ['ecaz', 'ecaz']; });
invalid(record => { record.advanced = true; });
invalid(record => { record.turn = 4; });
- invalid(record => { record.phase = 5 as never; });
+ invalid(record => { record.phase = 7; });
invalid(record => { record.sequence = 1; });
invalid(record => { record.event = nexusEcazDukeEvent(3, 6, 'ecaz-seat', 0, null, null); });
invalid(record => { (record as NexusEcazDukeReceipt & { extra: boolean }).extra = true; });
@@ -295,3 +275,19 @@ void test('damaged and re-signed impossible history cannot forge the original Du
assert.throws(() => validateNexusEcazDuke({ ...g, phase: 5 }, original, 0));
assert.throws(() => validateNexusEcazDuke({ ...g, nexusCards: null }, original, 0));
});
+
+void test('legacy Battle-only records cannot be re-signed as earlier-phase Cunning', () => {
+ const g = game();
+ const fields = structuredClone(createNexusEcazDukeReceipt(g, 'ecaz-seat')) as unknown as Record;
+ delete fields.version;
+ delete fields.custody;
+ const legacy = fields as NexusEcazDukeReceipt;
+ legacy.signature = nexusEcazDukeSignature(legacy);
+ validateNexusEcazDuke(g, legacy, 0);
+ const invalid = structuredClone(legacy);
+ invalid.phase = 5;
+ invalid.event = nexusEcazDukeEvent(invalid.turn, invalid.phase, invalid.owner,
+ invalid.sequence, invalid.before.controller, invalid.before.acquiredTurn);
+ invalid.signature = nexusEcazDukeSignature(invalid);
+ assert.throws(() => validateNexusEcazDuke(g, invalid, 0));
+});