Compose selected E1/E2 independent cards with Nexus rules

Retain selected decks and original optional modules through normal setup, distinguish native Ixian Betrayal from absent-faction replacement, route explicit CHOAM fuel before printed effects, and preserve native No-Field invoices and pair continuation. Connect canonical native cancellation stock and existing human/minimal AI consumers.

Validation: 194 affected tests across 19 files, types/lint/build, bounded native invoice review and seven finite original-action/rendered-control programmes. Runtime ec5ad6c18996d8d958ec0d01132ef3e3ea6d23d36188902eca9a63d4db2efbf8; documentation-only proof overlay a5892004937434429916496497c07a3838a324b4a5d64d4a0eb804ab5212d5bd. Other envelopes, full combinations, comprehensive assurance and deployed acceptance remain open. No saved-room/provider changes.
This commit is contained in:
Quintine committed 2026-10-18 10:46:21 +11:00
1 parent 999edb67e6
commit 8422cf1f2d
20 files changed
+1413 -97

No files matched your search

+103 -44
View File
@@ -1,5 +1,5 @@
import { classicNexusModulesProfile, mixedE1E2NexusProfile, mixedE3NexusProfile, pairedE3NexusProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
import { classicIndependentNexusProfile, classicNexusCoreProfile, nativeFactionTechProfile } from './faction-module-profile';
import { independentNexusProfile, nexusCoreProfile, nativeFactionTechProfile } from './faction-module-profile';
import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, mixedE1E2DiscoveryNexusProfile, mixedE3DiscoveryNexusProfile, pairedDiscoveryNexusProfile, pairedE3DiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile';
import { advancedMoritaniLeaderSkillsProfile, selectedDeckLeaderSkillsProfile, homeworldLeaderSkillsProfile, independentTreacheryLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeEcazLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedE3LeaderSkillsProfile, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile';
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
@@ -9674,7 +9674,7 @@ export function initializeHarkonnenNexusBetrayalGameForAudit(state: Game): Game
return initializeNexusGameForAudit(state);
}
function harkonnenNexusBetrayalModeSupported(g: Game): boolean {
if (classicNexusCoreProfile(g)) return !!byFaction(g, 'harkonnen');
if (nexusCoreProfile(g)) return !!byFaction(g, 'harkonnen');
return typeof g.advanced === 'boolean' && !!g.nexusCards && g.expansions.length === 0 &&
g.players.length >= 2 && g.players.length <= 6 && !!byFaction(g,'harkonnen') &&
new Set(g.players.map(p => p.faction)).size === g.players.length &&
@@ -9883,6 +9883,7 @@ export function initializeIxianNexusBetrayalGameForAudit(state: Game): Game {
return g;
}
function ixianNexusBetrayalModeSupported(g: Game): boolean {
if (nexusCoreProfile(g)) return !!byFaction(g, 'ixians');
return typeof g.advanced === 'boolean' && !!g.nexusCards &&
g.expansions.length === 1 && g.expansions[0] === 'ix' &&
g.players.length >= 2 && g.players.length <= 6 &&
@@ -9898,8 +9899,12 @@ function ixianNexusBetrayalModeSupported(g: Game): boolean {
function ixianNexusBetrayalContext(g: Game) {
return {status:g.status,phase:g.phase,turn:g.turn,advanced:g.advanced,
sequence:g.nexusIxianBetrayalCursor!.sequence,players:g.players,
...(g.ecazTreachery ? {expansions:g.expansions,ecazTreachery:true} : {}),
cards:g.nexusCards!.cards!,physicalCards:physicalTreacheryCards(g)};
}
function ixianNexusCardConfiguration(g: Game) {
return g.ecazTreachery ? {expansions:g.expansions,ecazTreachery:true,richese:!!byFaction(g,'richese')} : undefined;
}
function ixianNexusBetrayalAuthority(g: Game, response: ResponseWindow) {
requireRule(response.kind === 'ixAuction' || response.kind === 'ixTechnology',
'The saved Nexus continuation is not an original native Ixian window.');
@@ -9926,6 +9931,9 @@ function ixianNexusBetrayalParent(g: Game, response: ResponseWindow): string {
forces:p.forces,reserves:p.reserves,tanks:p.tanks,elites:p.elites,advisors:p.advisors})),
deck:g.deck,discard:g.discard,aid:g.aid,nexus:g.nexusCards,decision:g.decision,response:g.response,
phaseOpening:g.phaseOpening,truthtrance:g.truthtrance,pendingKarama:g.pendingKarama,
...(g.ecazTreachery ? {selectedDecks:g.expansions,ecazTreachery:true,
richeseCache:g.richeseCache,richeseRemoved:g.richeseRemoved,richeseAuction:g.richeseAuction,
homeworlds:g.homeworlds} : {}),
pendingNullentropy:g.pendingNullentropy,pendingTreacheryDiscard:g.pendingTreacheryDiscard});
}
function ixianNexusBetrayalIntegrity(g: Game): void {
@@ -9940,7 +9948,7 @@ function ixianNexusBetrayalIntegrity(g: Game): void {
Array.isArray(g.nexusIxianBetrayalHistory), 'The native Ixian Nexus profile lost its history or cursor.');
nexusCardsIntegrity(g);
nexusRule(() => validateIxianBetrayalHistory(g.nexusIxianBetrayalHistory!,
g.nexusIxianBetrayalCursor!,physicalTreacheryCards(g)));
g.nexusIxianBetrayalCursor!,physicalTreacheryCards(g),ixianNexusCardConfiguration(g)));
if (!frame) {
if (g.ixAuction?.cards.length === 0 || g.pendingIxTechnology) {
const continuation = g.pendingTreacheryDiscard?.continuation;
@@ -9978,7 +9986,7 @@ function offerIxianNexusBetrayal(g: Game,response: ResponseWindow): boolean {
// finish. That spent attempt fizzles without exposing a Nexus offer.
if (!getPlayer(g, response.owner).hand.some(c => c.id === g.pendingIxTechnology!.card)) return false;
}
const required = nexusRule(() => ixianBetrayalResponders(g.nexusCards!.cards!,g.players));
const required = nexusRule(() => ixianBetrayalResponders(g.nexusCards!.cards!,g.players,ixianNexusCardConfiguration(g)));
if (!required.length) return false;
const source = nexusRule(() => createIxianBetrayalSource(ixianNexusBetrayalContext(g),
ixianNexusBetrayalAuthority(g,response),ixianNexusBetrayalParent(g,response)));
@@ -9989,7 +9997,7 @@ function ixianNexusBetrayalView(g: Game,id: string) {
const frame = g.pendingNexusIxianBetrayal;
if (!frame) return null;
const hasPassed = frame.passed.includes(id), canPass = frame.required.includes(id) && !hasPassed;
const eligible = canPass && nexusRule(() => ixianBetrayalEligible(g.nexusCards!.cards!,g.players,id));
const eligible = canPass && nexusRule(() => ixianBetrayalEligible(g.nexusCards!.cards!,g.players,id,ixianNexusCardConfiguration(g)));
return {event:frame.source.event,kind:frame.source.kind,provider:frame.source.provider,
canPass,hasPassed,canUse:eligible,blocked:null as string | null};
}
@@ -10002,7 +10010,7 @@ function decideIxianNexusBetrayal(g: Game,id: string,action: Action): void {
frame.required.includes(id) && !frame.passed.includes(id),
'A current required seat must acknowledge only the original Ixian event.');
const use = action.type === 'nexusIxianBetrayalUse';
if (use) requireRule(nexusRule(() => ixianBetrayalEligible(g.nexusCards!.cards!,g.players,id)),
if (use) requireRule(nexusRule(() => ixianBetrayalEligible(g.nexusCards!.cards!,g.players,id,ixianNexusCardConfiguration(g))),
'You do not own an eligible unallied Ixian Nexus card.');
if (!use) {
frame.passed.push(id);
@@ -10037,7 +10045,7 @@ export function initializeIxianNexusReplacementGameForAudit(state: Game): Game {
return g;
}
function ixianNexusReplacementModeSupported(g: Game): boolean {
if (classicNexusCoreProfile(g)) return true;
if (nexusCoreProfile(g)) return !byFaction(g, 'ixians');
return !!g.nexusCards && g.players.length >= 2 && g.players.length <= 6 &&
new Set(g.players.map(p => p.faction)).size === g.players.length &&
((!g.expansions.length && g.players.every(p => CLASSIC_FACTIONS[p.faction] === true)) ||
@@ -10221,7 +10229,7 @@ export function initializeGuildBetrayalLeaderSkillsGameForAudit(state: Game): Ga
return initializeLeaderSkillsGameForAudit(g);
}
function guildBetrayalModeSupported(g: Game): boolean {
if (classicNexusCoreProfile(g)) return !!byFaction(g, 'guild');
if (nexusCoreProfile(g)) return !!byFaction(g, 'guild');
return !!g.nexusCards && g.expansions.length === 0 && g.players.length >= 2 &&
g.players.length <= 6 && !!byFaction(g, 'guild') &&
g.players.every(p => CLASSIC_FACTIONS[p.faction] === true) &&
@@ -10248,6 +10256,11 @@ function guildBetrayalParentSignature(g: Game, continuation: GuildBetrayalContin
pendingGuildTransport: g.pendingGuildTransport,
...(cunning ? { nexusGuildCunning: cunning } : {}),
...(richese ? { nexusRichese: richese } : {}),
...(g.ecazTreachery && g.expansions.length ? {
nativeMarkers:g.players.map(p => ({id:p.id,noField:p.noField,noFieldEvent:p.noFieldEvent,
noFieldBlockedTurn:p.noFieldBlockedTurn})),
richeseCunning:g.nexusRicheseCunningHistory, richeseAllyOffer:g.richeseAllyOffer,
} : {}),
...(g.techTokens ? { techTokens: g.techTokens } : {}),
...(g.strongholdCards ? { strongholdCards: g.strongholdCards } : {}),
});
@@ -10273,6 +10286,7 @@ function guildBetrayalAuthority(g: Game, continuation: GuildBetrayalContinuation
Object.keys(continuation).sort().join(',') === producerKeys[continuation.source],
'The saved Guild shipment producer contains foreign action fields.');
let shipper: string, price: number, allyPayment: number, originalReceiver: string | null;
let approvedSupplier: {donor:string; amount:number} | null = null;
const guild = byFaction(g, 'guild')!;
switch (continuation.source) {
case 'reserve': {
@@ -10283,19 +10297,44 @@ function guildBetrayalAuthority(g: Game, continuation: GuildBetrayalContinuation
'smuggler', 'smugglerCompanion', 'guildSecretEvent', 'guildNexusEvent', 'nexusEvent',
'richesePair', 'noField', 'alliedNoField', 'noFieldSkillProof'].includes(key)),
'The saved reserve shipment contains foreign source fields.');
requireRule(!shipment.source && !shipment.noField && !shipment.alliedNoField &&
!shipment.richesePair && !shipment.guildSecretEvent &&
((shipment.smuggler && classicNexusCoreProfile(g)) || (!shipment.smuggler && !shipment.smugglerCompanion)) &&
((!shipment.guildNexusEvent && !shipment.nexusEvent) || classicNexusCoreProfile(g)),
requireRule(!shipment.source && !shipment.guildSecretEvent &&
((!shipment.noField && !shipment.alliedNoField && !shipment.richesePair) || nexusCoreProfile(g)) &&
(nexusCoreProfile(g) || (!shipment.smuggler && !shipment.smugglerCompanion)) &&
((!shipment.guildNexusEvent && !shipment.nexusEvent) || nexusCoreProfile(g)),
'This Guild Betrayal profile requires a supported original physical shipment source.');
validatePhysicalShipment(g, shipment);
validateShipmentArrival(g, shipment);
checkShipmentIncomeFunding(g, getPlayer(g, shipment.player), shipment.cost, shipment.allyPayment);
const p = getPlayer(g, shipment.player);
if (shipment.noField) {
requireRule(!shipment.alliedNoField && !shipment.nexusEvent && !shipment.guildNexusEvent &&
p.faction === 'richese' && p.noField && p.noFieldEvent === shipment.noField.event &&
!p.noField.deployed && g.active === p.id && shipmentAvailable(g,p) && shipment.amount === 1 &&
shipment.elite === 0 && shipment.advisors === false,
'The funded No-Field invoice needs its original unused native marker.');
noFieldRule(() => deployRicheseNoField(p.noField!, {tokenId:shipment.noField!.tokenId,
controller:p.id,location:{territory:shipment.territory,sector:shipment.sector}}));
const cost = reserveShipmentCost({faction:p.faction,halfRate:shipmentHalfRate(g,p)},
territory(shipment.territory).type,1);
requireRule(shipment.cost === cost, 'The native No-Field invoice changed its one-marker price.');
} else if (shipment.alliedNoField) {
requireRule(!shipment.nexusEvent && !shipment.guildNexusEvent && !shipment.richesePair &&
!shipment.smuggler && !shipment.smugglerCompanion && shipment.allyPayment === 0 &&
g.active === p.id && shipmentAvailable(g,p),
'An allied No-Field invoice needs its original supplier-approved source.');
const quote = alliedNoFieldQuote(g,shipment.alliedNoField);
requireRule(quote.amount === shipment.amount && quote.cost === shipment.cost &&
shipment.elite >= quote.eliteMin && shipment.elite <= quote.eliteMax &&
shipment.advisors === quote.advisors,
'The allied No-Field allocation changed before funded payment.');
approvedSupplier = {donor:shipment.alliedNoField.owner,amount:quote.ownerPayment};
}
if (!approvedSupplier) checkShipmentIncomeFunding(g, p, shipment.cost, shipment.allyPayment);
checkShipmentPromises(g, getPlayer(g, shipment.player), shipment);
shipper = shipment.player; price = shipment.cost; allyPayment = shipment.allyPayment;
const p = getPlayer(g, shipper);
shipper = shipment.player; price = shipment.cost;
allyPayment = approvedSupplier?.amount ?? shipment.allyPayment;
originalReceiver = guildShipmentIncome({guild: guild.id, shipper, ally: p.ally,
cost: price, allyPayment, bankOnly: g.karamaShipping?.player === shipper}) > 0 ? guild.id : null;
cost: price, allyPayment, bankOnly: g.karamaShipping?.player === shipper ||
(!!shipment.alliedNoField && alliedNoFieldPricing(g,p).bankOnly)}) > 0 ? guild.id : null;
break;
}
case 'guildTransport': {
@@ -10317,7 +10356,7 @@ function guildBetrayalAuthority(g: Game, continuation: GuildBetrayalContinuation
['player', 'destination', 'sources', 'route', 'event', 'turn', 'amount', 'elite', 'cost',
'allyPayment', 'pools', 'guildSecretEvent', 'guildNexusEvent'].includes(key)),
'The saved Homeworld shipment contains foreign source fields.');
requireRule(!shipment.guildSecretEvent && (!shipment.guildNexusEvent || classicNexusCoreProfile(g)),
requireRule(!shipment.guildSecretEvent && (!shipment.guildNexusEvent || nexusCoreProfile(g)),
'This Guild Betrayal profile requires a supported original Homeworld shipment source.');
const quote = validateHomeworldShipment(g, shipment);
shipper = shipment.player; price = quote.cost; allyPayment = shipment.allyPayment;
@@ -10341,10 +10380,11 @@ function guildBetrayalAuthority(g: Game, continuation: GuildBetrayalContinuation
default: throw new RuleError('The saved Guild Betrayal producer is unsupported.');
}
const p = getPlayer(g, shipper);
contribution(g, p, price, allyPayment);
if (!approvedSupplier) contribution(g, p, price, allyPayment);
return {source: continuation.source, sourceSignature: guildBetrayalParentSignature(g, continuation),
shipper, price, allyPayment, ownSpice: p.spice, allyEscrow: aidFor(g, p)?.amount ?? 0,
donor: allyPayment ? p.ally! : null, originalReceiver};
shipper, price, allyPayment, ownSpice: p.spice,
allyEscrow: approvedSupplier?.amount ?? aidFor(g, p)?.amount ?? 0,
donor: allyPayment ? (approvedSupplier?.donor ?? p.ally!) : null, originalReceiver};
}
function guildBetrayalFrameSignature(frame: NonNullable<Game['pendingGuildBetrayal']>): string {
return JSON.stringify([frame.invoice.signature, frame.invoice.sourceSignature, frame.required, frame.passed]);
@@ -10755,7 +10795,7 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
const homeworlds = homeworldLeaderSkillsProfile(state);
const independent = independentTreacheryLeaderSkillsProfile(state);
if (state.ecazTreachery) {
requireRule(independent, 'Independent Skills cards require supported selected decks with original World/phase modules, or classic Nexus with original World/phase modules; unrelated preview contexts remain gated.');
requireRule(independent, 'Independent Skills cards require supported selected decks with original World/phase modules and supported Nexus selections; unrelated preview contexts remain gated.');
requireFreshBaseRuntime(state);
requireFreshFactionInventory(state);
}
@@ -10775,7 +10815,7 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
const classicNexus = classicNexusLeaderSkillsProfile(g);
const pairedE3 = pairedE3NexusLeaderSkillsProfile(g);
const mixedE3 = mixedE3NexusLeaderSkillsProfile(g);
const initialized = initializeSetupGameForAudit(g, homeworlds, classicNexus || pairedNexusLeaderSkillsProfile(g) ||
const initialized = initializeSetupGameForAudit(g, homeworlds, nexusCoreProfile(g) || classicNexus || pairedNexusLeaderSkillsProfile(g) ||
singleE1E2NexusLeaderSkillsProfile(g) || mixedE1E2NexusLeaderSkillsProfile(g) ||
mixedE3 || standaloneE3NexusLeaderSkillsProfile(g) || pairedE3 || guildBetrayalNexusLeaderSkillsProfile(g), false,
classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g) || (g.discoveryEnabled === true && classicNexus), true,
@@ -10802,7 +10842,7 @@ function independentCardModuleRoster(g: Game): boolean {
(g.expansions.length === 1 && g.expansions[0] === 'ecaz'));
}
function ecazHomeworldProfile(g: Game): boolean {
if (g.homeworlds && classicIndependentNexusProfile(g)) return true;
if (g.homeworlds && independentNexusProfile(g)) return true;
if (g.homeworlds && g.leaderSkills && !g.sandtrout && independentTreacheryLeaderSkillsProfile(g)) return true;
return !!g.homeworlds && g.ecazTreachery === true && independentCardModuleRoster(g) &&
g.players.length >= 2 && g.players.length <= 6 &&
@@ -10818,7 +10858,7 @@ function ecazHomeworldProfile(g: Game): boolean {
}
/** Independent cards compose original phase modules, not unrelated previews. */
function independentCardModulesProfile(g: Game): boolean {
if ((g.techTokens || g.strongholdCards) && classicIndependentNexusProfile(g)) return true;
if ((g.techTokens || g.strongholdCards) && independentNexusProfile(g)) return true;
if ((g.techTokens || g.strongholdCards) && g.leaderSkills && !g.sandtrout &&
independentTreacheryLeaderSkillsProfile(g)) return true;
if (!g.ecazTreachery || (!g.techTokens && !g.strongholdCards) || !independentCardModuleRoster(g) ||
@@ -10842,8 +10882,8 @@ export function initializeEcazTreacheryGameForAudit(state: Game): Game {
g.ecazTreachery = true;
const homeworlds = !!g.homeworlds;
requireRule(!homeworlds || ecazHomeworldProfile(g),
'Independent World cards need a supported selected-deck roster and original phase modules, or classic Nexus with original phase modules.');
const initialized = initializeSetupGameForAudit(g, homeworlds, classicIndependentNexusProfile(g), false, false, false, false, true, true);
'Independent World cards need a supported selected-deck roster with original phase modules and supported Nexus selections.');
const initialized = initializeSetupGameForAudit(g, homeworlds, independentNexusProfile(g), false, false, false, false, true, true);
if (byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true;
if (moritaniAssassinateModeSupported(initialized)) initializeMoritaniAssassinateState(initialized);
return initialized;
@@ -10865,7 +10905,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
classicDiscoveryNexusProfile(g) || pairedDiscoveryNexusProfile(g) ||
mixedE3DiscoveryNexusProfile(g) || mixedE1E2DiscoveryNexusProfile(g) || pairedE3DiscoveryNexusProfile(g) ||
(leaderSkills && (classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g) || classicNexusLeaderSkillsProfile(g))));
const nexusModules = nexus && (classicNexusModulesProfile(g) || pairedNexusModulesProfile(g) ||
const nexusModules = nexus && (nexusCoreProfile(g) || classicNexusModulesProfile(g) || pairedNexusModulesProfile(g) ||
mixedE3NexusProfile(g) || singleE1E2NexusProfile(g) || mixedE1E2NexusProfile(g) || pairedE3NexusProfile(g) || standaloneE3NexusProfile(g) ||
(leaderSkills && (classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(g) ||
mixedE3NexusLeaderSkillsProfile(g) || singleE1E2NexusLeaderSkillsProfile(g) || mixedE1E2NexusLeaderSkillsProfile(g) ||
@@ -10915,7 +10955,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
g.spiceBankerIncomePreview = true;
g.spiceBankerIncome = bankerIncomeRule(() => createBankerIncomeState(bankerIncomeContext(g)));
}
if (classicNexusCoreProfile(g)) {
if (nexusCoreProfile(g)) {
requireRule(g.nexusIxianReplacementPreview === undefined &&
g.nexusIxianReplacementHistory === undefined && g.nexusIxianReplacementCursor === undefined &&
!g.pendingNexusIxianReplacement && !g.pendingNexusIxianSpecialPurchase &&
@@ -10925,11 +10965,22 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
g.guildBetrayalPreview === undefined && g.guildBetrayal === undefined &&
g.nexusGuildBetrayalHistory === undefined && g.guildBetrayalSourceReceipts === undefined &&
!g.pendingGuildBetrayal,
'Native Nexus reactions initialize only with fresh classic setup.');
g.nexusIxianReplacementPreview = true;
g.pendingNexusIxianReplacement = null;
g.nexusIxianReplacementHistory = [];
g.nexusIxianReplacementCursor = initialIxianReplacementCursor();
'Native Nexus reactions initialize only with fresh supported core setup.');
requireRule(g.nexusIxianBetrayalPreview === undefined && g.nexusIxianBetrayalHistory === undefined &&
g.nexusIxianBetrayalCursor === undefined && !g.pendingNexusIxianBetrayal,
'Native Ixian Nexus reactions require original undealt effect state.');
if (!byFaction(g, 'ixians')) {
g.nexusIxianReplacementPreview = true;
g.pendingNexusIxianReplacement = null;
g.nexusIxianReplacementHistory = [];
g.nexusIxianReplacementCursor = initialIxianReplacementCursor();
}
if (byFaction(g, 'ixians')) {
g.nexusIxianBetrayalPreview = true;
g.pendingNexusIxianBetrayal = null;
g.nexusIxianBetrayalHistory = [];
g.nexusIxianBetrayalCursor = initialIxianBetrayalCursor();
}
if (byFaction(g, 'harkonnen')) {
g.nexusHarkonnenBetrayalPreview = true;
g.pendingNexusHarkonnenBetrayal = null;
@@ -13942,7 +13993,7 @@ function recruitsModeSupported(g: Game) {
const modules = independentCardModulesProfile(g);
return (
(!g.homeworlds || ecazHomeworldProfile(g)) &&
(!g.nexusCards || classicIndependentNexusProfile(g)) &&
(!g.nexusCards || independentNexusProfile(g)) &&
(!g.leaderSkills || independentTreacheryLeaderSkillsProfile(g)) &&
(!g.techTokens || modules) &&
(!g.strongholdCards || modules) &&
@@ -28136,7 +28187,7 @@ function projectedNexusGuildCunning(g: Game, owner: string) {
hajrAvailable:record.stage !== 'pending' && !g.hajr.includes(owner)}};
if (g.nexusCards?.cards?.hands[owner] !== 'guild') return null;
let blocked: string | null = null;
if (g.guildBetrayalPreview && !classicNexusCoreProfile(g)) blocked = 'This payment audit profile supports the original native shipment, not a Cunning second-shipment overlay.';
if (g.guildBetrayalPreview && !nexusCoreProfile(g)) blocked = 'This payment audit profile supports the original native shipment, not a Cunning second-shipment overlay.';
else if (p.ally) blocked = 'Guild Cunning requires an unallied native Guild player.';
else if (g.status !== 'playing' || g.phase !== 5 || g.active !== owner)
blocked = 'Use Cunning when finishing your own Shipment and Movement turn.';
@@ -28338,7 +28389,7 @@ function nexusRicheseOffer(g: Game, owner: string) {
const p = g.players.find(p => p.id === owner);
if (!p || g.nexusCards?.cards?.hands[owner] !== 'richese' || byFaction(g,'richese')) return null;
let blocked: string | null = null;
if (g.guildBetrayalPreview && !classicNexusCoreProfile(g)) blocked = 'This payment audit profile supports the original native shipment, not a Richese Secret Ally shipment overlay.';
if (g.guildBetrayalPreview && !nexusCoreProfile(g)) blocked = 'This payment audit profile supports the original native shipment, not a Richese Secret Ally shipment overlay.';
else if (p.ally) blocked = 'Richese Secret Ally requires an unallied holder.';
else if (g.status !== 'playing' || g.phase !== 5 || g.active !== owner || p.shipped)
blocked = 'Use Secret Ally for your unused reserve shipment before moving.';
@@ -28353,6 +28404,7 @@ const RICHESE_CUNNING_ROSTER: Partial<Record<FactionId, true>> = {
beneGesserit: true, guild: true, richese: true, choam: true,
};
function richeseCunningModeSupported(g: Game) {
if (nexusCoreProfile(g)) return !!byFaction(g, 'richese');
const mixed = mixedE3NexusProfile(g) || mixedE1E2NexusProfile(g) ||
(!!g.leaderSkills && (mixedE3NexusLeaderSkillsProfile(g) || mixedE1E2NexusLeaderSkillsProfile(g)));
if (mixed) return !g.sandtrout;
@@ -28441,7 +28493,10 @@ function finishRichesePairShipment(g: Game, shipment: PendingShipment, stage: 's
}
function richesePairIntegrity(g: Game) {
const history = g.nexusRicheseCunningHistory;
const pending = g.pendingShipment;
const invoice = g.pendingGuildBetrayal?.continuation;
const invoicedPair = invoice?.source === 'reserve' && invoice.shipment.richesePair ? invoice.shipment : null;
const pending = g.pendingShipment ?? invoicedPair;
requireRule(!g.pendingShipment || !invoicedPair, 'Richese Cunning cannot own two shipment continuations.');
if (history === undefined) {
requireRule(!g.nexusRicheseCunningLast && !pending?.richesePair,
'Richese Cunning lost its saved shipment history.');
@@ -28483,8 +28538,8 @@ function richesePairIntegrity(g: Game) {
currentRichesePairShipment(g, pending);
const responses = savedNoFieldResponses(g).filter(response => response.kind === 'richeseNoField');
const decisions = homeworldSavedDecisions(g).filter(decision => decision.kind === 'guildShipment');
requireRule(responses.length + decisions.length === 1,
'Richese Cunning lost its unique Karama response or Guild interception decision.');
requireRule(responses.length + decisions.length + Number(!!invoicedPair) === 1,
'Richese Cunning lost its unique Karama, native Guild interception or funded invoice continuation.');
for (const response of responses)
requireRule(response.owner === pending.player && response.noFieldSkillProof === undefined,
'Richese Cunning changed its native No-Field response.');
@@ -30606,6 +30661,12 @@ function applyActionInner(
decideNexusEmperorAmbassadorPurchase(g, p, action);
return g;
}
// Conversion spends the selected card as fuel; its printed effect must not
// intercept that explicit mode (including Recruits and other special faces).
if (t === 'card' && action.mode === 'choam') {
playChoamWorthless(g, p, action);
return g;
}
if (t === 'card' && action.card === RECRUITS_CARD_ID) {
requireRule(
Object.keys(action).sort().join(',') === 'card,type',
@@ -30845,10 +30906,6 @@ function applyActionInner(
}
return g;
}
if (t === 'card' && action.mode === 'choam') {
playChoamWorthless(g, p, action);
return g;
}
if (t === 'card' && action.mode === 'special') {
requireRule(
!g.choamMarket,
@@ -31196,7 +31253,9 @@ function applyActionInner(
// Verify the entire arrival continuation before the recipient commits.
// Unsupported simultaneous reactions leave this offer declineable.
const trial = structuredClone(g);
commitShipment(trial, trial.pendingShipment!);
// Preflight the native arrival, not an invoice inside this still-owned
// acceptance decision. The real commit opens its invoice after counters.
commitShipment(trial, trial.pendingShipment!, {recipient:null});
g.richeseAllyOffer = null;
g.response = { kind: 'richeseNoField', owner: offer.owner, passed: [] };
log(