Prototype native Ecaz Nexus Duke Cunning
This commit is contained in:
1 parent
1bda2ec31e
commit
1b36402dd6
19 files changed
+829
-23
No files matched your search
+13
-4
@@ -17,6 +17,7 @@ import { nexusMoritaniBotActions } from './nexus-moritani-options';
|
||||
import { moritaniBetrayalBotActions } from './nexus-moritani-betrayal-options';
|
||||
import { ecazBetrayalBotActions } from './nexus-ecaz-betrayal-options';
|
||||
import { ecazInquiryBotActions } from './nexus-ecaz-inquiry-options';
|
||||
import { nexusEcazDukeBotActions } from './nexus-ecaz-duke-options';
|
||||
import { ecazBetrayalOffer } from './nexus-ecaz-betrayal';
|
||||
import { ECAZ_START_FORCES, ECAZ_START_LOCATIONS, quoteEcazStartingForces } from './ecaz-setup';
|
||||
import { choamPowerAction, choamPowerBotPlay } from './choam-power-options';
|
||||
@@ -3909,6 +3910,8 @@ export function botActions(g: GameView): Action[] {
|
||||
if (faceDancers.length) return faceDancers;
|
||||
const traitorExchange = nexusTraitorBotActions(g);
|
||||
if (traitorExchange.length) return traitorExchange;
|
||||
const ecazDuke = nexusEcazDukeBotActions(g);
|
||||
if (ecazDuke.length) return ecazDuke;
|
||||
const nexus = g.nexusAtreides;
|
||||
if (
|
||||
g.status === 'playing' && g.phase === 6 && nexus && !nexus.blocked &&
|
||||
@@ -4020,14 +4023,20 @@ export function runBots(state: Game, limit = 96): Game {
|
||||
for (let step = 0; step < limit; step++) {
|
||||
let next: Game | undefined;
|
||||
const actors = g.players.filter((p) => p.bot ?? p.autopilot);
|
||||
// A card holder's only pre-shipment declaration must run before an
|
||||
// earlier-seated movement bot consumes the shared opening.
|
||||
// Private Nexus plays must run before an earlier-seated bot consumes
|
||||
// their shared shipment or pre-battle opening.
|
||||
const ecazDukeHolder = g.phase === 6 && !g.battle && !g.response &&
|
||||
!g.decision && !g.phaseOpening && !g.truthtrance && g.nexusCards?.cards
|
||||
? actors.find(actor => actor.faction === 'ecaz' && !actor.ally &&
|
||||
g.nexusCards!.cards!.hands[actor.id] === 'ecaz')
|
||||
: undefined;
|
||||
const ecazHolder = g.phase === 5 && g.nexusCards?.cards
|
||||
? actors.find(actor => g.nexusCards!.cards!.hands[actor.id] === 'ecaz' &&
|
||||
ecazBetrayalOffer(g, actor.id)?.blocked === null)
|
||||
: undefined;
|
||||
if (ecazHolder)
|
||||
actors.sort((a, b) => Number(b.id === ecazHolder.id) - Number(a.id === ecazHolder.id));
|
||||
const priorityEcaz = ecazDukeHolder ?? ecazHolder;
|
||||
if (priorityEcaz)
|
||||
actors.sort((a, b) => Number(b.id === priorityEcaz.id) - Number(a.id === priorityEcaz.id));
|
||||
else if (g.phase === 5 && junctionSponsor(g) && !currentJunctionOffer(g))
|
||||
actors.sort((a, b) => Number(b.faction === 'guild') - Number(a.faction === 'guild'));
|
||||
for (const p of actors) {
|
||||
|
||||
+5
-4
@@ -1,7 +1,7 @@
|
||||
import type { Leader } from './cards';
|
||||
|
||||
export const DUKE_VIDAL_ID = 'duke-vidal';
|
||||
export type DukeSource = 'moritani' | 'ecaz' | 'ally';
|
||||
export type DukeSource = 'moritani' | 'ecaz' | 'ecazNexus' | 'ally';
|
||||
export type DukeState = {
|
||||
leader: Leader;
|
||||
controller: string | null;
|
||||
@@ -53,7 +53,7 @@ export function acquireDuke(
|
||||
validTurn(turn);
|
||||
if (typeof controller !== 'string' || !controller.trim())
|
||||
throw new Error('Choose a player to control Duke Vidal.');
|
||||
if (!['moritani', 'ecaz', 'ally'].includes(source))
|
||||
if (!['moritani', 'ecaz', 'ecazNexus', 'ally'].includes(source))
|
||||
throw new Error('Choose a valid source of Duke Vidal custody.');
|
||||
if (state.leader.dead)
|
||||
throw new Error('Duke Vidal is in the Tanks and cannot be acquired.');
|
||||
@@ -75,13 +75,14 @@ export function consumeDuke(state: DukeState): DukeState {
|
||||
}
|
||||
|
||||
/**
|
||||
* End-turn custody only. Ecaz's ordinary acquisition lasts until battle use.
|
||||
* End-turn custody only. Ecaz's ordinary Ambassador acquisition lasts until
|
||||
* battle use; the distinct Ecaz Nexus Cunning acquisition lasts this turn.
|
||||
* Allied loans and captured/ghola release destinations need a verified return contract.
|
||||
*/
|
||||
export function expireDuke(state: DukeState, turn: number): DukeState {
|
||||
validTurn(turn);
|
||||
if (
|
||||
state.source === 'moritani' &&
|
||||
(state.source === 'moritani' || state.source === 'ecazNexus') &&
|
||||
state.acquiredTurn !== null &&
|
||||
state.acquiredTurn <= turn &&
|
||||
!state.leader.dead &&
|
||||
|
||||
@@ -432,6 +432,7 @@ import {
|
||||
DUKE_VIDAL_ID,
|
||||
type DukeState,
|
||||
} from './duke-vidal';
|
||||
import { createNexusEcazDukeReceipt, quoteNexusEcazDuke, validateNexusEcazDuke, type NexusEcazDukeReceipt } from './nexus-ecaz-duke';
|
||||
import {
|
||||
retentionReservesCard,
|
||||
type MoritaniRetention,
|
||||
@@ -1055,6 +1056,8 @@ export type Game = {
|
||||
nexusChoamBetrayalLast?: { event: string; stage: NexusChoamBetrayal['stage'] };
|
||||
nexusEcazInquiries?: EcazInquiryReceipt[];
|
||||
nexusEcazInquiryLast?: { event: string };
|
||||
nexusEcazDukeHistory?: NexusEcazDukeReceipt[];
|
||||
nexusEcazDukeEvents?: string[];
|
||||
nexusEmperorSecretHistory?: NexusEmperorRevival[];
|
||||
nexusEmperorSecretEvents?: string[];
|
||||
nexusEmperorPurchaseHistory?: EmperorNexusPurchase[];
|
||||
@@ -1983,6 +1986,24 @@ function nexusEcazInquiryIntegrity(g: Game) {
|
||||
requireRule(g.nexusEcazInquiryLast?.event === history.at(-1)!.event,
|
||||
'The last Ecaz Nexus inquiry lost its original answer.');
|
||||
}
|
||||
function nexusEcazDukeIntegrity(g: Game) {
|
||||
const history = g.nexusEcazDukeHistory, events = g.nexusEcazDukeEvents;
|
||||
if (history === undefined) {
|
||||
requireRule(events === undefined, 'Ecaz Nexus Duke lost its saved acquisition history.');
|
||||
return;
|
||||
}
|
||||
requireRule(Array.isArray(history) && history.length > 0 && !!g.nexusCards?.cards &&
|
||||
Array.isArray(events) && events.length === history.length &&
|
||||
new Set(events).size === events.length,
|
||||
'Ecaz Nexus Duke lost its physical card or independent use markers.');
|
||||
for (const [index, receipt] of history.entries()) {
|
||||
nexusRule(() => validateNexusEcazDuke(g, receipt, index));
|
||||
requireRule(events[index] === receipt.event, 'Ecaz Nexus Duke acquired from a changed event.');
|
||||
}
|
||||
if (history.at(-1)!.turn === g.turn)
|
||||
requireRule(g.nexusCards.cards.discard.includes('ecaz'),
|
||||
'Ecaz Nexus Duke has reopened its spent physical card.');
|
||||
}
|
||||
|
||||
function currentNexusChoamTrade(g: Game, owner: string) {
|
||||
return nexusRule(() => quoteNexusChoamTrade(g, owner,
|
||||
@@ -2449,6 +2470,21 @@ function playNexusEcazInquiry(g: Game, p: Player, action: Action) {
|
||||
log(g, `${p.name} spent Ecaz Nexus Secret Ally to ask whether ${target.name} holds any of their native leaders as Traitor Cards. The yes/no answer was delivered privately.`,
|
||||
{ faction: p.faction, name: 'Ecaz Nexus inquiry' });
|
||||
}
|
||||
function playNexusEcazDuke(g: Game, p: Player, action: Action) {
|
||||
const offer = nexusRule(() => quoteNexusEcazDuke(g, p.id));
|
||||
requireRule(offer && !offer.blocked && action.event === offer.event &&
|
||||
Object.keys(action).sort().join(',') === 'event,type',
|
||||
offer?.blocked ?? 'Choose the current Ecaz Nexus Duke acquisition.');
|
||||
const index = g.nexusEcazDukeHistory?.length ?? 0;
|
||||
const receipt = nexusRule(() => createNexusEcazDukeReceipt(g, p.id, index));
|
||||
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
|
||||
g.dukeVidal = nexusRule(() => acquireDuke(g.dukeVidal!, p.id, g.turn, 'ecazNexus'));
|
||||
nexusRule(() => validateNexusEcazDuke(g, receipt, index));
|
||||
(g.nexusEcazDukeHistory ??= []).push(receipt);
|
||||
(g.nexusEcazDukeEvents ??= []).push(receipt.event);
|
||||
log(g, `${p.name} spent Ecaz Nexus Cunning to take Duke Prad Vidal for this turn. The shared leader remains physically unique and returns after use or at turn end.`,
|
||||
{ faction: p.faction, name: 'Ecaz Nexus Cunning' });
|
||||
}
|
||||
|
||||
function playMoritaniBetrayal(g: Game, p: Player, action: Action) {
|
||||
const offer = moritaniBetrayalOffer(g, p.id,
|
||||
@@ -21990,6 +22026,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
nexusMoritaniRetentionIntegrity(state);
|
||||
nexusChoamBetrayalIntegrity(state);
|
||||
nexusEcazInquiryIntegrity(state);
|
||||
nexusEcazDukeIntegrity(state);
|
||||
richesePairIntegrity(state);
|
||||
marketGholaIntegrity(state);
|
||||
homeworldRule(() => homeworldGameIntegrity(state));
|
||||
@@ -22153,6 +22190,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
nexusMoritaniRetentionIntegrity(g);
|
||||
nexusChoamBetrayalIntegrity(g);
|
||||
nexusEcazInquiryIntegrity(g);
|
||||
nexusEcazDukeIntegrity(g);
|
||||
richesePairIntegrity(g);
|
||||
marketGholaIntegrity(g);
|
||||
homeworldRule(() => homeworldGameIntegrity(g));
|
||||
@@ -22270,6 +22308,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
nexusMoritaniRetentionIntegrity(state);
|
||||
nexusChoamBetrayalIntegrity(state);
|
||||
nexusEcazInquiryIntegrity(state);
|
||||
nexusEcazDukeIntegrity(state);
|
||||
richesePairIntegrity(state);
|
||||
marketGholaIntegrity(state);
|
||||
homeworldRule(() => homeworldGameIntegrity(state));
|
||||
@@ -22305,6 +22344,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
nexusMoritaniRetentionIntegrity(g);
|
||||
nexusChoamBetrayalIntegrity(g);
|
||||
nexusEcazInquiryIntegrity(g);
|
||||
nexusEcazDukeIntegrity(g);
|
||||
richesePairIntegrity(g);
|
||||
marketGholaIntegrity(g);
|
||||
homeworldRule(() => homeworldGameIntegrity(g));
|
||||
@@ -22406,6 +22446,7 @@ function applyActionInner(
|
||||
if (t === 'nexusChoamTrade') { playNexusChoamTrade(g, p, action); return g; }
|
||||
if (t === 'nexusChoamBetrayal') { playNexusChoamBetrayal(g, p, action); return g; }
|
||||
if (t === 'nexusEcazInquiry') { playNexusEcazInquiry(g, p, action); return g; }
|
||||
if (t === 'nexusEcazDuke') { playNexusEcazDuke(g, p, action); return g; }
|
||||
if (t === 'nexusEmperorRevive') { playNexusEmperorRevive(g,p,action); return g; }
|
||||
if (t === 'nexusEmperorPurchase') { playNexusEmperorPurchase(g,p,action); return g; }
|
||||
if (t === 'nexusEmperorBetrayal') { playNexusEmperorBetrayal(g,p,action); return g; }
|
||||
@@ -26092,6 +26133,7 @@ export function viewGame(state: Game, id: string) {
|
||||
nexusMoritaniRetentionIntegrity(state);
|
||||
nexusChoamBetrayalIntegrity(state);
|
||||
nexusEcazInquiryIntegrity(state);
|
||||
nexusEcazDukeIntegrity(state);
|
||||
richesePairIntegrity(state);
|
||||
marketGholaIntegrity(state);
|
||||
homeworldRule(() => homeworldGameIntegrity(state));
|
||||
@@ -26414,6 +26456,7 @@ export function viewGame(state: Game, id: string) {
|
||||
g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)),
|
||||
history: ecazInquiryHistory(g, id),
|
||||
},
|
||||
nexusEcazDuke: nexusRule(() => quoteNexusEcazDuke(g, id)),
|
||||
nexusEmperorSecretAlly: nexusEmperorSecretAllyOffer(g,id),
|
||||
nexusEmperorBetrayal: nexusEmperorBetrayalOffer(g, id),
|
||||
nexusBgBetrayal: nexusBgBetrayalOffer(g, id),
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Action, GameView } from './engine';
|
||||
|
||||
/** Only the owner's private offer authorizes this physical Nexus play. The
|
||||
* public Duke controller is informational, never a substitute for the offer. */
|
||||
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.automaticContinuationPending || game.truthtrance || game.response ||
|
||||
game.decision || game.nexusTraitors?.pending || game.nexusCards?.waiting.length ||
|
||||
game.roomControl?.paused || game.roomControl?.closed ||
|
||||
game.nexusCards?.card !== 'ecaz') return null;
|
||||
const owner = game.players.find(player => player.id === game.me);
|
||||
if (!owner || owner.faction !== 'ecaz' || owner.ally) return null;
|
||||
return { type: 'nexusEcazDuke', event: offer.event };
|
||||
}
|
||||
|
||||
export function nexusEcazDukeBotActions(game: GameView): Action[] {
|
||||
const action = nexusEcazDukeAction(game);
|
||||
return action ? [action] : [];
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { FactionId } from './catalog';
|
||||
import type { Game } from './engine';
|
||||
import { acquireDuke, DUKE_VIDAL_ID, type DukeSource } from './duke-vidal';
|
||||
import { nexusCardMode } from './nexus-cards';
|
||||
import { nexusCleanPlayBlocked } from './nexus-play-boundary';
|
||||
|
||||
const SUPPORTED_FACTIONS: Partial<Record<FactionId, true>> = {
|
||||
ecaz: true, moritani: true, atreides: true, harkonnen: true,
|
||||
emperor: true, fremen: true, guild: true, beneGesserit: true,
|
||||
};
|
||||
|
||||
type DukeBefore = {
|
||||
controller: string | null;
|
||||
acquiredTurn: number | null;
|
||||
source: DukeSource | null;
|
||||
leader: { id: typeof DUKE_VIDAL_ID; dead: false; capturedBy: null; gholaBy: null };
|
||||
};
|
||||
|
||||
/** A completed physical card play, not a second copy of the Duke's current custody. */
|
||||
export type NexusEcazDukeReceipt = {
|
||||
event: string;
|
||||
owner: string;
|
||||
ownerFaction: 'ecaz';
|
||||
card: 'ecaz';
|
||||
mode: 'cunning';
|
||||
roster: FactionId[];
|
||||
advanced: boolean;
|
||||
turn: number;
|
||||
phase: 6;
|
||||
sequence: number;
|
||||
before: DukeBefore;
|
||||
after: { controller: string; acquiredTurn: number; source: 'ecazNexus' };
|
||||
signature: string;
|
||||
};
|
||||
|
||||
export function nexusEcazDukeEvent(
|
||||
turn: number, phase: number, owner: string, sequence: number,
|
||||
beforeController: string | null, beforeAcquiredTurn: number | null,
|
||||
): string {
|
||||
return JSON.stringify(['nexusEcazDuke', turn, phase, owner, sequence,
|
||||
beforeController, beforeAcquiredTurn]);
|
||||
}
|
||||
|
||||
export function nexusEcazDukeSignature(receipt: NexusEcazDukeReceipt): string {
|
||||
return JSON.stringify(['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,
|
||||
receipt.before.leader.id, receipt.before.leader.dead,
|
||||
receipt.before.leader.capturedBy, receipt.before.leader.gholaBy,
|
||||
receipt.after.controller, receipt.after.acquiredTurn, receipt.after.source]);
|
||||
}
|
||||
|
||||
/** Only the owner can see that the held card is Ecaz Cunning. The boundary is
|
||||
* deliberately quiet: this action must not create or interrupt a public pause. */
|
||||
export function quoteNexusEcazDuke(g: Game, owner: string, automaticPending = false):
|
||||
{ event: string; blocked: string | null; dukeController: string | null } | null {
|
||||
const holder = g.players.find(player => player.id === owner);
|
||||
if (!holder || holder.faction !== 'ecaz' ||
|
||||
g.nexusCards?.cards?.hands[owner] !== 'ecaz') return null;
|
||||
const duke = g.dukeVidal;
|
||||
const roster = g.players.map(player => player.faction);
|
||||
let blocked: string | null = null;
|
||||
if (g.advanced && g.players.some(player => player.faction === 'harkonnen'))
|
||||
blocked = 'Duke Vidal battle use is unavailable with Advanced Harkonnen.';
|
||||
else if (!nexusEcazDukeModeSupported(g) || nexusCardMode('ecaz', holder.faction, roster) !== 'cunning')
|
||||
blocked = 'Ecaz Nexus Cunning currently requires paired Ecaz and Moritani with Nexus alone.';
|
||||
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 (nexusCleanPlayBlocked(g, automaticPending))
|
||||
blocked = 'Finish the current interaction before using Ecaz Nexus Cunning.';
|
||||
else if (!duke || duke.leader.id !== DUKE_VIDAL_ID || duke.leader.dead ||
|
||||
duke.leader.capturedBy || duke.leader.gholaBy)
|
||||
blocked = 'Only a living, uncaptured, non-Ghola Duke Vidal can be acquired.';
|
||||
else if (duke.leader.usedAt)
|
||||
blocked = 'Duke Vidal has already been used in battle this turn.';
|
||||
else if (duke.controller === owner)
|
||||
blocked = 'Ecaz already controls Duke Vidal.';
|
||||
return {
|
||||
event: nexusEcazDukeEvent(g.turn, 6, owner, g.nexusEcazDukeHistory?.length ?? 0,
|
||||
duke?.controller ?? null, duke?.acquiredTurn ?? null),
|
||||
blocked,
|
||||
dukeController: duke?.controller ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function nexusEcazDukeModeSupported(g: Game): boolean {
|
||||
return !!g.nexusCards?.cards && g.players.length >= 3 && g.players.length <= 6 &&
|
||||
g.players.some(player => player.faction === 'ecaz') &&
|
||||
g.players.some(player => player.faction === 'moritani') &&
|
||||
g.players.every(player => SUPPORTED_FACTIONS[player.faction] === true) &&
|
||||
new Set(g.players.map(player => player.faction)).size === g.players.length &&
|
||||
!(g.advanced && g.players.some(player => player.faction === 'harkonnen')) &&
|
||||
!g.homeworlds && !g.leaderSkills && !g.discoveryEnabled &&
|
||||
!g.discoveries && !g.ecazTreachery && !g.sandtrout &&
|
||||
!g.techTokens && !g.strongholdCards &&
|
||||
g.expansions.every(expansion => expansion === 'ecaz');
|
||||
}
|
||||
|
||||
/** Call before changing the shared disc or discarding the held card. */
|
||||
export function createNexusEcazDukeReceipt(
|
||||
g: Game, owner: string, sequence = g.nexusEcazDukeHistory?.length ?? 0,
|
||||
): NexusEcazDukeReceipt {
|
||||
const offer = quoteNexusEcazDuke(g, owner);
|
||||
if (!offer || offer.blocked || sequence !== (g.nexusEcazDukeHistory?.length ?? 0))
|
||||
throw new Error(offer?.blocked ?? 'Ecaz Nexus Cunning is unavailable.');
|
||||
const duke = g.dukeVidal!;
|
||||
const after = acquireDuke(duke, owner, g.turn, 'ecazNexus');
|
||||
const receipt: NexusEcazDukeReceipt = {
|
||||
event: offer.event, owner, ownerFaction: 'ecaz', card: 'ecaz', mode: 'cunning',
|
||||
roster: g.players.map(player => player.faction), advanced: g.advanced,
|
||||
turn: g.turn, phase: 6, sequence,
|
||||
before: {
|
||||
controller: duke.controller, acquiredTurn: duke.acquiredTurn, source: duke.source,
|
||||
leader: { id: DUKE_VIDAL_ID, dead: false, capturedBy: null, gholaBy: null },
|
||||
},
|
||||
after: { controller: after.controller!, acquiredTurn: after.acquiredTurn!, source: 'ecazNexus' },
|
||||
signature: '',
|
||||
};
|
||||
receipt.signature = nexusEcazDukeSignature(receipt);
|
||||
return receipt;
|
||||
}
|
||||
|
||||
const object = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
const keys = (value: Record<string, unknown>, names: string): boolean =>
|
||||
Object.keys(value).sort().join(',') === names;
|
||||
const turn = (value: unknown): value is number =>
|
||||
Number.isSafeInteger(value) && (value as number) > 0;
|
||||
|
||||
/** 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 {
|
||||
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') ||
|
||||
!object(before) || !keys(before, 'acquiredTurn,controller,leader,source') ||
|
||||
!object(leader) || !keys(leader, 'capturedBy,dead,gholaBy,id') ||
|
||||
!object(after) || !keys(after, 'acquiredTurn,controller,source') ||
|
||||
!nexusEcazDukeModeSupported(g) ||
|
||||
!g.players.some(player => player.id === receipt.owner && player.faction === 'ecaz') ||
|
||||
receipt.ownerFaction !== 'ecaz' || receipt.card !== 'ecaz' || receipt.mode !== 'cunning' ||
|
||||
!Array.isArray(receipt.roster) || receipt.roster.length !== g.players.length ||
|
||||
receipt.roster.some((faction, position) => faction !== g.players[position]?.faction) ||
|
||||
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) ||
|
||||
!Number.isSafeInteger(index) || index < 0 || receipt.sequence !== index ||
|
||||
leader.id !== DUKE_VIDAL_ID || leader.dead !== false ||
|
||||
leader.capturedBy !== null || leader.gholaBy !== null ||
|
||||
!(before.controller === null ||
|
||||
g.players.some(player => player.id === before.controller)) ||
|
||||
(before.controller === null
|
||||
? before.acquiredTurn !== null || before.source !== null
|
||||
: !turn(before.acquiredTurn) || before.acquiredTurn > receipt.turn ||
|
||||
!['moritani', 'ecaz', 'ecazNexus', 'ally'].includes(before.source as string) ||
|
||||
(before.source === 'moritani' &&
|
||||
(g.players.find(player => player.id === before.controller)?.faction !== 'moritani' ||
|
||||
before.acquiredTurn !== receipt.turn)) ||
|
||||
((before.source === 'ecaz' || before.source === 'ecazNexus') &&
|
||||
g.players.find(player => player.id === before.controller)?.faction !== 'ecaz') ||
|
||||
(before.source === 'ecazNexus' && before.acquiredTurn !== receipt.turn)) ||
|
||||
before.controller === receipt.owner ||
|
||||
after.controller !== receipt.owner || after.source !== 'ecazNexus' ||
|
||||
after.acquiredTurn !== receipt.turn ||
|
||||
receipt.event !== nexusEcazDukeEvent(receipt.turn, receipt.phase,
|
||||
receipt.owner, receipt.sequence, before.controller, before.acquiredTurn) ||
|
||||
receipt.signature !== nexusEcazDukeSignature(receipt))
|
||||
throw new Error('Ecaz Nexus Cunning lost its original Duke custody or physical play.');
|
||||
}
|
||||
+7
-6
@@ -4041,7 +4041,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Moritani Cunning modifies its normal Mentat placement: choose one available supply token and a territory on the printed Arrakis board, including a territory already containing Terror. The explicit Cunning toggle keeps ordinary placement and relocation unchanged. Homeworlds, the Hidden Mobile Stronghold, Cunning relocation and combined Grumman use are unavailable.',
|
||||
'With Moritani seated, an unallied holder of its Nexus card may spend Betrayal at a clean play boundary to return one placed Terror token to hidden Moritani supply without revealing its face. Choose the public location and token, not its secret effect; this does not use Moritani’s Mentat placement or trigger that token.',
|
||||
'When Moritani is absent, a defeated combatant with an eligible publicly played card receives the same postbattle choice whether or not they hold Moritani Nexus. An unallied holder may spend it to retain one played card that a winner could keep; other played cards follow ordinary discard. Without a publicly eligible card there is no prompt. The choice is after the battle result and before later income/capture; the public prompt protects hidden custody and is an interface inference. This Secret Ally effect has no invented ordinary Karama response. Native Moritani allied retention remains a different, cancelable ability.',
|
||||
'Ecaz Betrayal is available to an unallied holder with Ecaz seated and reciprocally allied in a shared Arrakis territory. At the opening of Shipment and Movement, choose one such territory; the ally returns its entire normal and starred force group from every sector there to reserves, leaving Ecaz forces in place. The Nexus card is spent before a Karama response. Cancellation preserves the board but not the card. Only the base/Ecaz Nexus roster without combined modules is supported; Duke Cunning remains unavailable.',
|
||||
'Ecaz Betrayal is available to an unallied holder with Ecaz seated and reciprocally allied in a shared Arrakis territory. At the opening of Shipment and Movement, choose one such territory; the ally returns its entire normal and starred force group from every sector there to reserves, leaving Ecaz forces in place. The Nexus card is spent before a Karama response. Cancellation preserves the board but not the card. Only the base/Ecaz Nexus roster without combined modules is supported.',
|
||||
'Ecaz Cunning lets an unallied native holder spend the physical Nexus card at a quiet Battle boundary to take the existing living Duke Vidal for the turn, even from Moritani. The Duke can be selected through ordinary Battle plans and is set aside after use or at turn end. This first version is limited to paired Ecaz/Moritani with other classic factions and no combined optional modules; Advanced Harkonnen is excluded because the existing battle path cannot select Duke there. Taking Duke from the Tanks, capture or Ghola custody remains unimplemented. The Battle-only timing is a product boundary, not printed text.',
|
||||
'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.',
|
||||
'Native Richese Cunning has a bounded two-No-Field shipment in genuine Richese/CHOAM Nexus games without other optional modules. Choose distinct unused tokens: one reveals immediately into reserve-capped public forces, the other remains concealed; pay for one marker and spend the Nexus. The previous shipment token cannot be either selection; the concealed token becomes next last-used. Karama or Guild special prevention preserves physical tokens and unpaid spice, with the ordinary stopped-shipment allowance policy. Other module combinations and the full faceup-component display remain gated.',
|
||||
@@ -4052,11 +4053,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
],
|
||||
related: ['ecaz-modules', 'spice-blow', 'alliance-funding'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, revealed Face Dancer refresh, turn-wide Suboid strength, whole-territory advisor conversion and native Voice Betrayal suppression, temporary Emperor Sardaukar and actual Sardaukar Betrayal suppression, bounded Fremen Secret Ally free-three revival and native empty-worm Cunning, five source-aware CHOAM Cunning effects, Moritani Cunning placement and Betrayal token return, Ecaz Betrayal allied-force return and bounded native-leader Secret Ally inquiry, and Richese Secret Ally reserve shipping priced as one. Ecaz inquiry has a private holder control, legal AI action and saved historical yes/no; native scope and audience remain product interpretations. Borrowed Voice, other Betrayal reactions, Emperor purchase effects, Tleilaxu revival, Fremen worm protection and combined Cunning interactions remain unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'components/nexus-ecaz-inquiry.tsx', 'tests/nexus-ecaz-inquiry.test.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation, advisor selection, temporary Sardaukar and CHOAM effects with native Karama responses, Moritani Betrayal token selection, Ecaz Betrayal shared-territory choice and owner-only Secret Ally inquiry, Richese shipment pricing and Fremen Cunning offer/remote ride. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/game-table.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All profiles handle integrated effects through private offers, including Moritani Betrayal public-token selection, Ecaz Betrayal public-territory selection and legal Secret Ally inquiry, and Fremen Cunning offer/ride decisions. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-richese-options.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and integrated effect families retain explicit unresolved boundaries, including Ecaz Duke/inquiry, a second Cunning offer during an interrupted Fremen control, Great Maker and combined modules, fresh advisors, storm and borrowed Voice.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and in-memory SQLite recovery regressions include Moritani Betrayal, bounded Fremen Cunning with a genuine special summon, Ecaz Betrayal and private Secret Ally inquiry. Local natural and summoned Fremen offer/ride, Ecaz Betrayal holder/Karama and Ecaz inquiry owner/rival browser surfaces were checked with isolated fixture views without a room write. Full module games and combined interactions remain separate.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, Face Dancers, Suboids, advisors, native Voice, Emperor Sardaukar and Betrayal, bounded Fremen revival/Cunning, CHOAM effects, Moritani Cunning/Betrayal, Ecaz Betrayal, native Duke Cunning and private Secret Ally inquiry, Richese shipment, and bounded Emperor bank-auction purchase are integrated. Ecaz Cunning transfers the existing Duke at a quiet Battle boundary with a real card and distinct turn-end expiry. Ecaz inquiry scope/audience and Emperor purchase timing are provisional. Borrowed Voice, exceptional Duke custody, other reactions, Tleilaxu revival, Fremen worm protection and combined Cunning interactions remain unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation, advisor selection, temporary Sardaukar and CHOAM effects, Moritani Betrayal, Ecaz Betrayal and owner-only Secret Ally inquiry, native Duke Cunning, Richese shipment, Emperor purchase and Fremen Cunning controls are connected. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/game-table.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four profiles choose legal actions through private offers, including Moritani/Ecaz Betrayal, Ecaz Secret Ally inquiry and native Duke Cunning, Fremen Cunning and Emperor purchase. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and bounded runtime contracts retain explicit unresolved Ecaz Duke capture/Tanks/Ghola and inquiry audience/scope, Fremen nested Cunning, Great Maker, combined modules, fresh advisors, storm and borrowed Voice.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, controls, legal bots, Basic/Advanced and in-memory SQLite recovery regressions include native Ecaz Duke override and private Secret Ally inquiry. A read-only local browser fixture showed the owner-only Duke choice and no rival offer; no room was written. Full module games, exceptional Duke custody and combined interactions remain separate.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user