Allow Ecaz Cunning at original quiet phase boundaries

This commit is contained in:
Quintine committed 2026-10-15 13:48:02 +11:00
1 parent a24600ae1d
commit 60f338e2e1
14 files changed
+342 -80

No files matched your search

+1
View File
@@ -17,6 +17,7 @@ export function NexusEcazDuke({ game, act, busy }: Props) {
<h3>Ecaz Nexus · Cunning · Duke Prad Vidal</h3>
<p>Known controller: {offer.dukeController ? controller?.name ?? 'another player' : 'none'}.</p>
<p>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.</p>
<p className="fine">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.</p>
<p className="fine">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.</p>
{offer.blocked && <p className="notice">{offer.blocked}</p>}
<Button className="game-action min-h-11 whitespace-normal" disabled={busy || !action}
+17
View File
@@ -29,6 +29,23 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
**Original quiet-phase Ecaz Cunning — 14 October.** Printed current-turn
tenure no longer has an artificial Battle-only fence. Existing quiet
boundaries preserve committed decisions, native chooser/phase, costs and
one-battle/turn expiry. New version3 binds actual phase/custody; historical
ordinary/version2 Battle records remain strict and actual prior QA receipts
are accepted unchanged. No roster, shared-training or foreign-Ghola widening.
Affected19-file union189/189, types/lint/build and thirty-one native scenarios pass,
including six original phases, all four legal profiles in Revival/Shipment,
both native expiry sources and actual battle release. Actual styled390px
Revival10→10, other-player Shipment and returned Tanks preserve original
allowances/turn; Ambassador placement and Guild timing keep their own controls.
No overflow/browser errors or saved/deployed room touched. Obsolete phase/
blanket Harkonnen and held-hand pins are removed, not re-pinned.
[Canonical timing/source proof](NEXUS_ECAZ_RULES.md#original-quiet-phase-cunning-timing--14-october-2026).
Broad assurance, calibration and complete-mode/deployed gates remain.
Existing build deprecation/chunk/route warnings remain; no release gate opens.
**Original captured Duke and legal Ghola composition — 14 October.**
Standalone Ecaz/classic no-Skills Nexus now connects the actual Advanced
Harkonnen lottery, including the surviving just-used losing Duke, Keep/Execute,
+43
View File
@@ -1,5 +1,48 @@
# Dune implementation status
## 14 October 2026 — Original quiet-phase Ecaz Cunning
Removed the artificial Battle-only availability fence from printed
current-turn Cunning. The original shared no-phase Nexus guard still blocks
auctions, Battle, responses, Ambassador placement/arrival, revival and
pending physical continuations; no public pause or interrupt is introduced.
Private owner action, minimal legal bot adapter and actual table now reuse
that source quote in any original quiet phase. Current chooser, phase,
wallet, forces, ordinary leader slot and native continuation stay unchanged.
New physical plays use strict version3 ordinary/captured custody with actual
phase-bound events/history. Earlier unversioned/version2 Battle-only records
retain exact keys, signature and phase grammar. Two actual prior native QA
receipt formats are accepted unchanged. This is contract compatibility, not
a new saved-room/recovery assurance campaign. Current-turn unused expiry,
one-battle release, provisional return policy and Ecaz-only revival remain;
no new roster/Skills/foreign-Ghola gate is opened.
The actual Revival rejection fails before and passes after. Affected19-file
union189/189, types/lint/build and thirty-one native API scenarios pass: both
captured/returned-Tanks sources at actual Storm, Charity, Revival, Shipment,
Collection and Mentat boundaries, all four minimal profiles in Revival/
Shipment, native unused expiries and real Revival→Shipment→Battle release.
Actual canonical-styled390px controls spend in Revival10→10, revive the
executed Duke at deaths2 without an ordinary slot, and act during Harkonnen's
Shipment without stealing it. Original Ecaz placement and Guild timing must
finish through their own controls first. Native suffix reaches Collection
or unused turn expiry once. No overflow/browser errors or saved/deployed
room mutation.
Deleted obsolete phase-only/blanket Harkonnen and incidental retained-hand
pins rather than preventing newly legal early AI play. Initial smoke
placement expectation was corrected to original end-Revival, not an invented
Shipment decision. Isolated QA host mirrors real PostCSS/public assets and
registers routing before the SPA fallback; initial host omissions were
corrected, not application rules suppressed. Proof JSON remains outside Git.
Final shared-Duke/capture/Ambassador internal guidance matches the bounded
support. Final build retains existing module-deprecation, chunk-size and
route-classification warnings.
Broader combination/recovery review, calibration and deployed/publication
acceptance remain open.
[Canonical source and actual proof](NEXUS_ECAZ_RULES.md#original-quiet-phase-cunning-timing--14-october-2026).
## 14 October 2026 — Original captured Duke and legal Ecaz Ghola composition
Connected the existing standalone Ecaz/classic no-Skills Nexus profile to
+12
View File
@@ -2,6 +2,18 @@
Original source audit, 10 September 2026, with dated bounded runtime follow-ups below. This document establishes the printed component inventory, source protocol and implementation boundaries; none lifts expansion release gates. The ordinary worm-triggered alliance Nexus already in the engine is distinct from this optional card module.
## Original quiet-phase Ecaz Cunning — 14 October 2026
The artificial Battle-only restriction is removed: printed Cunning lasts
for the current turn and may now be played at any original quiet boundary
in supported profiles. Existing in-flight decisions, auctions, Battle,
responses and physical continuations still block it. No chooser or phase
is stolen. New version3 records bind actual phase and ordinary/captured
custody; old ordinary/version2 Battle history stays strict and unchanged.
[Source, native scenarios and actual controls](NEXUS_ECAZ_RULES.md#original-quiet-phase-cunning-timing--14-october-2026).
Historical Battle-only boundaries below are superseded, not roster,
shared-training or foreign-Ghola exclusions.
## Original captured Duke Cunning — 14 October 2026
Standalone Ecaz/classic no-Skills Nexus now connects the real Advanced
+50
View File
@@ -82,6 +82,56 @@ historical save authenticity. The bounded Duke Cunning path below is separate;
Secret Ally's exceptional leader scope and audience remain unsettled beyond
the bounded runtime below.
## Original quiet-phase Cunning timing — 14 October 2026
Printed Cunning says to take Duke **for this turn**, without a Battle-only
restriction. The original supported profiles now permit the private action
at a quiet boundary in any phase. This removes the artificial phase-six
fence; it does not open new rosters, modules, shared training, foreign-Ghola
revival or an interrupt inside a committed parent.
The existing shared no-phase Nexus guard still blocks original auctions,
Battle, responses, Ambassador placement/arrival, revival, pending physical
returns and other in-flight interactions. A new public pause is not created.
The current chooser, phase, payment, force counters and native continuation
remain unchanged by Cunning. One battle or unused **current-turn** expiry
still sets Duke aside, without restoring an old captor.
New physical plays use strict **version3**, with ordinary/captured custody
and the actual phase in their private event/history. A command carried
across phases is stale. Earlier unversioned ordinary and version2 captured
records keep their exact Battle-only grammar and signature, rather than
being reinterpreted as earlier-phase permission. Existing actual native QA
receipts in both historical formats remain accepted. Future-phase history,
invalid custody and invalid physical sources still reject.
The original Revival rejection was reproduced, then the real same-disc
Revival → Shipment → Battle/use path passes. The frozen affected nineteen-file
union passes **189/189**, with types/lint/build. **Thirty-one native API scenarios**
pass: captured/returned-Tanks Cunning in actual Storm, Charity, Revival,
Shipment, Collection and Mentat windows; all four minimal legal profiles
in Revival/Shipment; both native turn expiries; and original battle release.
Actual production GameTable at **390px**, using the canonical stylesheet
pipeline, spends Cunning in Revival and during another faction's Shipment,
and returns a truly executed Duke from Tanks before Battle. Real Ambassador
placement and Guild timing remain blocking until their own controls resolve.
Wallet10→10, ordinary leader slot and Harkonnen's shipment turn remain;
native battle release and unused expiry follow. No overflow/browser errors
were observed in those working surfaces.
The final production build retains existing module-deprecation, chunk-size
and route-classification warnings. Shared-Duke, capture and Ambassador
internal guidance now identifies this bounded support rather than a blanket
capture exclusion.
Obsolete phase-only and blanket Advanced-Harkonnen rejection pins, SSR
wording and an incidental retained-hand assumption are deleted, not re-pinned.
An ordinary legal AI may now spend the card earlier; that is not a printed
requirement to retain it until Battle. No saved/deployed room is touched.
This is not broader recovery, combination or complete-mode acceptance.
[Actual timing regressions](../tests/nexus-duke-timing-runtime.test.ts).
Older quiet-Battle timing below is historical and superseded by this section;
the captured-return and Ecaz-only revival policies themselves are unchanged.
## Original captured Duke and Ecaz Ghola composition — 14 October 2026
The existing standalone Ecaz/classic, no-Skills Nexus envelope now connects
+16 -1
View File
@@ -66,6 +66,21 @@ These decisions do not open public module/faction or complete-mode gates.
These decisions do not open public module/faction or complete-mode gates.
## Recommended quiet-phase Duke Cunning — 14 October 2026
Remove the prototype's Battle-only fence: the printed face specifies
current-turn tenure, not a phase. Reuse the existing no-phase Nexus clean
boundary and keep all committed native decisions, auctions, Battle,
responses and physical continuation guards. No new public pause or
interrupt priority is inferred. This quiet ordering is the conservative
product composition, not a new publisher priority ruling.
New version3 history binds the actual phase; old unversioned/version2
Battle-only records remain strict. Preserve one-battle/current-turn tenure,
the provisional captured return policy and the binding Ecaz-only revival
exclusion. No roster or shared-training gate is widened.
[Current source and proof](NEXUS_ECAZ_RULES.md#original-quiet-phase-cunning-timing--14-october-2026).
## Recommended shared-Duke capture composition — 14 October 2026
Use original Advanced Harkonnen capture for the existing standalone
@@ -672,7 +687,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| 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) |
| Ecaz native Nexus Cunning timing | Printed Cunning takes Duke for this turn even from capture, Tanks or Ghola, without a phase. Quiet Battle remains a product boundary. Original standalone Ecaz/no-Skills Advanced-Harkonnen capture and liberation now use provisional capture-before-final-release and no-captor-restoration policies. Tanks preserve deaths and independently reset use. Actual Ecaz Ghola returns set aside before separate Cunning; foreign-Ghola revival remains forbidden by Ecaz-only permission. Paired/Skills and broader timing stay guarded. | [Current source and bounded runtime](NEXUS_ECAZ_RULES.md#original-captured-duke-and-ecaz-ghola-composition--14-october-2026) |
| Ecaz native Nexus Cunning timing | Printed Cunning takes Duke for this turn even from capture, Tanks or Ghola, without a phase. Original quiet boundaries in any phase now replace the Battle-only prototype fence, without interrupting committed parents or changing native chooser/continuation. Capture ordering/no-captor-restoration and independent Tanks resurrection remain provisional compositions. Actual Ecaz Ghola returns set aside before separate Cunning; foreign revival stays forbidden. Legacy ordinary/version2 Battle history remains strict; roster/Skills exclusions remain. | [Current timing and bounded runtime](NEXUS_ECAZ_RULES.md#original-quiet-phase-cunning-timing--14-october-2026) |
| Emperor Secret Ally purchase timing | Uniform buyer/committed-beneficiary confirmation prevents a holder-only pause disclosing hidden eligibility; no beneficiary veto. Original classic/native bank admission, Richese cache/Advanced Black Market, Ambassador and joint special Karama keep distinct sources. Uniform timing and seller/direct compositions remain visibly provisional recommendations, not publisher timing rulings. Old bank receipt stays unchanged; strict legacy physical revival never broadens. Full combinations remain open; CHOAM closing sales/trades are not direct purchases. | [Native bank source](NEXUS_EMPEROR_RULES.md#original-native-bank-auction-admission--14-october-2026), [Ambassador](NEXUS_EMPEROR_RULES.md#recommended-original-ambassador-purchase--14-october-2026), [seller](NEXUS_EMPEROR_RULES.md#recommended-richese-seller-unpaid-purchase--14-october-2026), [joint special](NEXUS_EMPEROR_RULES.md#recommended-joint-richese-special-purchase--14-october-2026) |
| 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 |
+1 -1
View File
@@ -5,7 +5,7 @@ import type { Action, GameView } from './engine';
export function nexusEcazDukeAction(game: GameView): Action | null {
const offer = game.nexusEcazDuke;
if (!offer || !offer.event || offer.blocked || game.status !== 'playing' ||
game.phase !== 6 || game.phaseOpening || game.battle ||
game.phaseOpening || game.battle ||
game.automaticContinuationPending || game.truthtrance || game.response ||
game.decision || game.nexusTraitors?.pending || game.nexusCards?.waiting.length ||
game.roomControl?.paused || game.roomControl?.closed ||
+53 -22
View File
@@ -1,4 +1,4 @@
import type { FactionId } from './catalog';
import { PHASES, type FactionId } from './catalog';
import type { Game } from './engine';
import { acquireDuke, dukeController, DUKE_VIDAL_ID, type DukeSource } from './duke-vidal';
import { nexusCardMode } from './nexus-cards';
@@ -58,7 +58,22 @@ export type NexusEcazDukeCapturedReceipt = Omit<NexusEcazDukeLivingReceipt, 'bef
};
};
};
export type NexusEcazDukeReceipt = NexusEcazDukeLivingReceipt | NexusEcazDukeCapturedReceipt;
/** New plays bind their actual quiet phase; historical Battle-only formats stay strict. */
type NexusEcazDukeTimedReceipt = {
version: 3;
phase: number;
} & (
| (Omit<NexusEcazDukeLivingReceipt, 'phase'> & { custody: 'ordinary' })
| (Omit<NexusEcazDukeCapturedReceipt, 'version' | 'phase'> & { custody: 'captured' })
);
type CapturedReceipt = NexusEcazDukeCapturedReceipt |
Extract<NexusEcazDukeTimedReceipt, { custody: 'captured' }>;
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<NexusEcazDukeTimedReceipt, { custody: 'captured' }> = {
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<string, unknown>, 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 ||
+11 -8
View File
@@ -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-dukLine truncated
],
},
{
+1 -13
View File
@@ -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);
});
@@ -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) {
+97
View File
@@ -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);
});
+23 -13
View File
@@ -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<NexusEcazDukeReceipt, { custody: 'captured' }>;
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<string, unknown>;
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));
});
+17 -21
View File
@@ -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<string, unknown>;
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));
});