Integrate Ixian Nexus Betrayal at original native attempts

This commit is contained in:
Quintine committed 2026-10-01 21:58:07 +10:00
1 parent 49c99de3e2
commit cdfe0e5370
25 files changed
+2824 -63

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
import type { Action, GameView } from './engine';
/** All profiles consume only their own legal projection. No source card,
* rival hand or Nexus face is needed to acknowledge the native attempt. */
export function botNexusIxianBetrayalActions(game: GameView): Action[] {
const reaction = game.nexusIxianBetrayalReaction;
if (game.status !== 'playing' || game.phase !== 3 || !reaction?.event ||
!reaction.canPass || reaction.hasPassed || game.roomControl?.paused || game.roomControl?.closed ||
game.automaticContinuationPending || game.truthtrance || game.response || game.decision ||
game.phaseOpening || game.nexusCards?.waiting.length || game.nexusTraitors?.pending) return [];
const own = game.players.find((player) => player.id === game.me);
if (!(own?.bot ?? own?.autopilot)) return [];
return [{
type: reaction.canUse && !reaction.blocked ? 'nexusIxianBetrayalUse' : 'nexusIxianBetrayalPass',
event: reaction.event,
}];
}
+8
View File
@@ -105,6 +105,7 @@ import { botSemutaActions } from './bot-semuta';
import { botChoamKullActions, botChoamKullCounterActions } from './bot-choam-kull';
import { botNexusRicheseBetrayalActions } from './bot-nexus-richese-betrayal';
import { botNexusGuildBetrayalActions } from './bot-nexus-guild-betrayal';
import { botNexusIxianBetrayalActions } from './bot-nexus-ixian-betrayal';
import { validateCohortSelection, type OrnithopterMode } from './ornithopter';
import { fighterCount, isAdvisor } from './advisors';
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat';
@@ -3831,6 +3832,13 @@ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null {
/** Obligations apply across policy branches, including choosing to move first. */
export function botActions(g: GameView): Action[] {
if (g.nexusIxianBetrayalReaction) {
if (g.status !== 'playing' || g.roomControl?.paused || g.roomControl?.closed ||
g.automaticContinuationPending) return [];
// Original native counters and existing private decisions retain priority.
if (g.response || g.truthtrance || g.decision) return policyActions(g);
return botNexusIxianBetrayalActions(g);
}
if (g.guildBetrayalReaction) return botNexusGuildBetrayalActions(g);
if (g.richeseBetrayalReaction) return botNexusRicheseBetrayalActions(g);
if (g.kullReaction) return botChoamKullActions(g);
+232 -50
View File
@@ -48,6 +48,7 @@ import { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGui
import { shipmentAvailable } from './shipment-opportunity';
import { createGuildBetrayalInvoice, createGuildBetrayalReceipt, guildBetrayalEligible, guildBetrayalResponders, validateGuildBetrayalInvoice, validateGuildBetrayalHistory, nexusGuildBetrayalSourceReceipt, type GuildBetrayalInvoice, type GuildBetrayalCursor, type GuildBetrayalReceipt, type GuildBetrayalAuthority } from './nexus-guild-betrayal';
import { createIxianReplacementSource, validateIxianReplacementSource, closeIxianReplacementSource, validateIxianReplacementHistory, initialIxianReplacementCursor, ixianReplacementEvent, type IxianReplacementSource, type IxianReplacementReceipt, type IxianReplacementCursor } from './nexus-ixian-replacement';
import { createIxianBetrayalSource, validateIxianBetrayalFrame, initialIxianBetrayalCursor, closeIxianBetrayalSource, validateIxianBetrayalHistory, ixianBetrayalResponders, ixianBetrayalEligible, type IxianBetrayalSource, type IxianBetrayalCursor, type IxianBetrayalReceipt } from './nexus-ixian-betrayal';
import { createNexusGuildCunning, validateNexusGuildCunning, nexusGuildCunningMoves, type NexusGuildCunningReceipt } from './nexus-guild-cunning';
import { createNexusRichese, validateNexusRichese, quoteNexusRicheseShipment, type NexusRicheseReceipt } from './nexus-richese';
import { createNexusInspection, allowNexusInspection, answerNexusInspection, reopenNexusInspection, cancelNexusInspection, reopenNexusNative, answerNexusNative, validateNexusInspection, committedPlanElements, type NexusInspection, type BattleInspectionContext } from './battle-inspections';
@@ -1128,6 +1129,16 @@ export type Game = {
pendingNexusIxianReplacement?: IxianReplacementSource | null;
nexusIxianReplacementHistory?: IxianReplacementReceipt[];
nexusIxianReplacementCursor?: IxianReplacementCursor;
/** Fresh native Ixian attempt prevention; never inferred from held cards. */
nexusIxianBetrayalPreview?: true;
pendingNexusIxianBetrayal?: {
source: IxianBetrayalSource;
continuation: ResponseWindow;
required: string[];
passed: string[];
} | null;
nexusIxianBetrayalHistory?: IxianBetrayalReceipt[];
nexusIxianBetrayalCursor?: IxianBetrayalCursor;
kullSequence?: number;
kullRestrictions?: KullPhaseRestriction[];
pendingKull?: {
@@ -8114,6 +8125,159 @@ export function initializeNexusGameForAudit(state: Game): Game {
'Enable Nexus cards in a fresh audit lobby first.');
return initializeSetupGameForAudit(state, !!state.homeworlds, true);
}
/** Offline-only, fresh native Ixian/Ix47 + Nexus profile. */
export function initializeIxianNexusBetrayalGameForAudit(state: Game): Game {
requireRule(ixianNexusBetrayalModeSupported(state) &&
state.nexusIxianBetrayalPreview === undefined && state.pendingNexusIxianBetrayal === undefined &&
state.nexusIxianBetrayalHistory === undefined && state.nexusIxianBetrayalCursor === undefined &&
state.nexusCards?.cards === null && state.nexusCards.phase === null,
'Ixian Nexus Betrayal needs a fresh native Ixian lobby with only Ix Treachery and Nexus Cards.');
requireFreshBaseRuntime(state);
const g = initializeSetupGameForAudit(state, false, true, false, false, false, false, true);
g.nexusIxianBetrayalPreview = true;
g.pendingNexusIxianBetrayal = null;
g.nexusIxianBetrayalHistory = [];
g.nexusIxianBetrayalCursor = initialIxianBetrayalCursor();
// This profile does not opt into the optional Sandtrout Spice card.
g.spiceDeck = shuffle(spiceDeck());
return g;
}
function ixianNexusBetrayalModeSupported(g: Game): boolean {
return typeof g.advanced === 'boolean' && !!g.nexusCards &&
g.expansions.length === 1 && g.expansions[0] === 'ix' &&
g.players.length >= 2 && g.players.length <= 6 &&
new Set(g.players.map(p => p.faction)).size === g.players.length &&
!!byFaction(g, 'ixians') &&
g.players.every(p => CLASSIC_FACTIONS[p.faction] || p.faction === 'ixians' || p.faction === 'tleilaxu') &&
!g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && !g.discoveries &&
!g.discoveryStash && !g.greatMaker && !g.techTokens && !g.strongholdCards &&
!g.ecazTreachery && !g.semutaPreview && !g.kullPreview && !g.nexusKullPreview &&
!g.richeseBetrayalPreview && !g.guildBetrayalPreview && !g.nexusIxianReplacementPreview &&
!g.moritaniAssassinatePreview;
}
function ixianNexusBetrayalContext(g: Game) {
return {status:g.status,phase:g.phase,turn:g.turn,advanced:g.advanced,
sequence:g.nexusIxianBetrayalCursor!.sequence,players:g.players,
cards:g.nexusCards!.cards!,physicalCards:physicalTreacheryCards(g)};
}
function ixianNexusBetrayalAuthority(g: Game, response: ResponseWindow) {
requireRule(response.kind === 'ixAuction' || response.kind === 'ixTechnology',
'The saved Nexus continuation is not an original native Ixian window.');
requireRule(Object.keys(response).sort().join(',') === 'kind,owner,passed',
'The native Ixian continuation changed its original response shape.');
if (response.kind === 'ixAuction') currentIxAuctionDrawQuote(g,response,false);
else {
auctionTechnologyQuote(() => quoteIxTechnologyCancellation(g,response));
requireRule(getPlayer(g,response.owner).hand.filter(c => c.id === g.pendingIxTechnology!.card).length === 1,
'The original Ixian Technology declaration lost its selected hand card.');
}
return {kind:response.kind === 'ixAuction' ? 'bidding' as const : 'technology' as const,
provider:response.owner,nativeWindow:response.kind,
nativeContext:JSON.stringify({ixAuction:g.ixAuction,pendingIxTechnology:g.pendingIxTechnology,
ixTechnologyTurn:g.ixTechnologyTurn,auction:g.auction}),
nativeRequired:g.players.filter(p => responseCancelCards(g,p,response).length > 0).map(p => p.id),
nativePassed:response.passed};
}
function ixianNexusBetrayalParent(g: Game, response: ResponseWindow): string {
return JSON.stringify({continuation:response,status:g.status,phase:g.phase,turn:g.turn,advanced:g.advanced,
active:g.active,order:g.order,auction:g.auction,sale:g.currentAuctionSale,ixAuction:g.ixAuction,
ixKnown:g.ixAuctionKnown,technology:g.pendingIxTechnology,technologyTurn:g.ixTechnologyTurn,
players:g.players.map(p => ({id:p.id,faction:p.faction,ally:p.ally,spice:p.spice,hand:p.hand,
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,
pendingNullentropy:g.pendingNullentropy,pendingTreacheryDiscard:g.pendingTreacheryDiscard});
}
function ixianNexusBetrayalIntegrity(g: Game): void {
const frame = g.pendingNexusIxianBetrayal;
if (!g.nexusIxianBetrayalPreview) {
requireRule(g.nexusIxianBetrayalPreview === undefined && !frame &&
g.nexusIxianBetrayalHistory === undefined && g.nexusIxianBetrayalCursor === undefined,
'Ixian Nexus Betrayal lost its explicit fresh profile.');
return;
}
requireRule(ixianNexusBetrayalModeSupported(g) && g.nexusIxianBetrayalCursor &&
Array.isArray(g.nexusIxianBetrayalHistory), 'The native Ixian Nexus profile lost its history or cursor.');
nexusCardsIntegrity(g);
nexusRule(() => validateIxianBetrayalHistory(g.nexusIxianBetrayalHistory!,
g.nexusIxianBetrayalCursor!,physicalTreacheryCards(g)));
if (!frame) {
if (g.ixAuction?.cards.length === 0 || g.pendingIxTechnology) {
const continuation = g.pendingTreacheryDiscard?.continuation;
const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume,
g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume,
g.summonedWorm?.resume, continuation && 'resume' in continuation ? continuation.resume : null];
const provider = byFaction(g, 'ixians')!.id;
const ownsWindow = (kind: 'ixAuction' | 'ixTechnology') => contexts.some(context => {
const pending = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null;
const original = pending?.use.kind === 'cancel' ? pending.use.response : null;
return (context?.response?.kind === kind && context.response.owner === provider) ||
(original?.kind === kind && original.owner === provider);
});
requireRule((!g.ixAuction || g.ixAuction.cards.length > 0 || ownsWindow('ixAuction')) &&
(!g.pendingIxTechnology || ownsWindow('ixTechnology')),
'The native Ixian attempt lost its original counter or Nexus acknowledgement.');
}
return;
}
requireRule(Object.keys(frame).sort().join(',') === 'continuation,passed,required,source' &&
Array.isArray(frame.required) && Array.isArray(frame.passed) && frame.required.length > 0 &&
frame.required.some(id => !frame.passed.includes(id)) &&
!g.response && !g.decision && !g.phaseOpening && !g.truthtrance &&
!g.pendingKarama && !g.pendingNullentropy && !g.pendingTreacheryDiscard,
'The Ixian Nexus acknowledgement lost its original native priority.');
nexusRule(() => validateIxianBetrayalFrame(ixianNexusBetrayalContext(g),frame.source,
ixianNexusBetrayalAuthority(g,frame.continuation),ixianNexusBetrayalParent(g,frame.continuation),
frame.required,frame.passed));
}
function offerIxianNexusBetrayal(g: Game,response: ResponseWindow): boolean {
if (!g.nexusIxianBetrayalPreview) return false;
if (response.kind === 'ixTechnology') {
auctionTechnologyQuote(() => quoteIxTechnologyCancellation(g, response));
// Native interruptions may legally move the declared card before counters
// 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));
if (!required.length) return false;
const source = nexusRule(() => createIxianBetrayalSource(ixianNexusBetrayalContext(g),
ixianNexusBetrayalAuthority(g,response),ixianNexusBetrayalParent(g,response)));
g.pendingNexusIxianBetrayal = {source,continuation:structuredClone(response),required,passed:[]};
return true;
}
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));
return {event:frame.source.event,kind:frame.source.kind,provider:frame.source.provider,
canPass,hasPassed,canUse:eligible,blocked:null as string | null};
}
function decideIxianNexusBetrayal(g: Game,id: string,action: Action): void {
ixianNexusBetrayalIntegrity(g);
const frame = g.pendingNexusIxianBetrayal!;
requireRule(['nexusIxianBetrayalPass','nexusIxianBetrayalUse'].includes(action?.type) &&
action.event === frame.source.event && Object.keys(action).length === 2 &&
Object.keys(action).every(key => key === 'type' || key === 'event') &&
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)),
'You do not own an eligible unallied Ixian Nexus card.');
if (!use) {
frame.passed.push(id);
if (frame.required.some(seat => !frame.passed.includes(seat))) return;
}
// Validation above precedes the one physical Nexus cost and native RNG.
const closed = nexusRule(() => closeIxianBetrayalSource(frame.source,use ? 'use' : 'pass',
use ? id : null,g.nexusIxianBetrayalCursor!));
if (use) g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,id,g.players));
g.nexusIxianBetrayalHistory!.push(closed.receipt);
g.nexusIxianBetrayalCursor = closed.cursor;
g.pendingNexusIxianBetrayal = null;
if (use) log(g,`${getPlayer(g,id).name} spent Ixian Nexus Betrayal to prevent the original native ${frame.source.kind} attempt.`);
finishIxianNativeAttempt(g,frame.continuation,use);
}
/** Fresh local classic profile only; no public start or existing-game retrofit. */
export function initializeIxianNexusReplacementGameForAudit(state: Game): Game {
requireRule(!state.nexusIxianReplacementPreview && !state.pendingNexusIxianReplacement &&
@@ -11504,6 +11668,50 @@ function currentIxAuctionDrawQuote(
throw error;
}
}
/** The original native suffix, shared by native Karama and Nexus closure.
* It consumes the accepted attempt directly; no declaration is replayed. */
function finishIxianNativeAttempt(g: Game, response: ResponseWindow, canceled: boolean): void {
if (response.kind === 'ixAuction') {
const request = currentIxAuctionDrawQuote(g, response, canceled)!;
const pending = g.ixAuction!;
const cards: Card[] = [];
for (let i = 0; i < request.drawCount; i++) {
const card = draw(g);
if (card) cards.push(card);
}
if (!canceled && cards.length > 1) {
pending.cards = cards;
g.decision = { kind: 'ixAuction', player: response.owner };
} else {
g.ixAuction = null;
setAuction(g, cards);
}
return;
}
requireRule(response.kind === 'ixTechnology', 'Only an original Ixian native attempt may resume.');
const cancellation = auctionTechnologyQuote(() => quoteIxTechnologyCancellation(g, response))!;
if (canceled) {
g.pendingIxTechnology = cancellation.pendingIxTechnology;
g.auction!.peekKnown = cancellation.peek.peekKnown;
g.response = cancellation.peek.response;
log(g, 'Ixian auction substitution did not occur.');
return;
}
const pending = g.pendingIxTechnology!;
const ixians = getPlayer(g, response.owner);
const replacement = ixians.hand.find(c => c.id === pending.card);
if (replacement) {
const a = g.auction!, taken = a.cards[a.index];
ixians.hand = ixians.hand.filter(c => c.id !== replacement.id);
ixians.hand.push(taken);
a.cards[a.index] = replacement;
if (g.ixAuctionKnown?.turn === g.turn)
g.ixAuctionKnown.cards = [...g.ixAuctionKnown.cards.filter(c => c.id !== taken.id && c.id !== replacement.id), replacement];
log(g, `${ixians.name} exchanged a hand card for the upcoming auction card.`);
}
g.pendingIxTechnology = null;
offerAuctionPeek(g);
}
function ixSubstitutionCancellationQuote(
g: Game,
response: ResponseWindow,
@@ -21588,9 +21796,6 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
const auctionCancellation = canceled
? validateAuctionContinuationCancellation(g, response)
: null;
const technologyCancellation = canceled
? auctionTechnologyQuote(() => quoteIxTechnologyCancellation(g, response))
: null;
const richeseCancellation = canceled
? currentRicheseCancellationQuote(g, response)
: null;
@@ -22044,52 +22249,9 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
};
else settleRicheseLot(g);
}
} else if (response.kind === 'ixAuction') {
const request = currentIxAuctionDrawQuote(g, response, canceled)!;
const pending = g.ixAuction!;
const cards: Card[] = [];
for (let i = 0; i < request.drawCount; i++) {
const card = draw(g);
if (card) cards.push(card);
}
if (!canceled && cards.length > 1) {
pending.cards = cards;
g.decision = { kind: 'ixAuction', player: response.owner };
} else {
g.ixAuction = null;
setAuction(g, cards);
}
} else if (response.kind === 'ixTechnology') {
if (technologyCancellation) {
g.pendingIxTechnology = technologyCancellation.pendingIxTechnology;
g.auction!.peekKnown = technologyCancellation.peek.peekKnown;
g.response = technologyCancellation.peek.response;
log(g, 'Ixian auction substitution did not occur.');
return;
}
const pending = g.pendingIxTechnology!;
const ixians = getPlayer(g, response.owner);
const replacement = ixians.hand.find((c) => c.id === pending.card);
if (!canceled && replacement) {
const a = g.auction!,
taken = a.cards[a.index];
ixians.hand = ixians.hand.filter((c) => c.id !== replacement.id);
ixians.hand.push(taken);
a.cards[a.index] = replacement;
if (g.ixAuctionKnown?.turn === g.turn)
g.ixAuctionKnown.cards = [
...g.ixAuctionKnown.cards.filter(
(c) => c.id !== taken.id && c.id !== replacement.id,
),
replacement,
];
log(
g,
`${ixians.name} exchanged a hand card for the upcoming auction card.`,
);
} else log(g, 'Ixian auction substitution did not occur.');
g.pendingIxTechnology = null;
offerAuctionPeek(g);
} else if (response.kind === 'ixAuction' || response.kind === 'ixTechnology') {
if (!canceled && offerIxianNexusBetrayal(g, response)) return;
finishIxianNativeAttempt(g, response, canceled);
} else if (response.kind === 'ixAllyCard') {
const pending = g.pendingIxAlly!;
const buyer = getPlayer(g, pending.player);
@@ -25302,6 +25464,7 @@ function normalizeCardNames(g: Game) {
]);
}
export function applyAction(state: Game, id: string, action: Action): Game {
ixianNexusBetrayalIntegrity(state);
ixianNexusReplacementIntegrity(state);
guildBetrayalIntegrity(state);
state = migratePrintedKullCounter(state);
@@ -25389,6 +25552,8 @@ export function applyAction(state: Game, id: string, action: Action): Game {
return normalizeAutomaticGame(state);
}
const g = applyActionInner(state, id, action);
ixianNexusBetrayalIntegrity(g);
if (g.pendingNexusIxianBetrayal) return g;
ixianNexusReplacementIntegrity(g);
if (g.pendingNexusIxianReplacement) return g;
if (g.pendingGuildBetrayal) {guildBetrayalIntegrity(g); return g;}
@@ -25524,6 +25689,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
return g;
}
function finishActionContinuations(g: Game) {
if (g.pendingNexusIxianBetrayal) return;
if (g.pendingGuildBetrayal) return;
if (g.pendingRicheseBetrayal) return;
if (g.pendingKull) return;
@@ -25588,6 +25754,7 @@ function finishActionContinuations(g: Game) {
finishMoritaniPlacement(g);
}
function settleAutomaticContinuations(g: Game) {
if (g.pendingNexusIxianBetrayal) return;
if (g.pendingNexusIxianReplacement) return;
if (g.pendingGuildBetrayal) return;
if (g.pendingRicheseBetrayal) return;
@@ -25595,6 +25762,7 @@ function settleAutomaticContinuations(g: Game) {
if (pendingNexusTraitors(g)) return;
if (g.pendingNullentropy) return;
for (let iteration = 0; iteration < 128; iteration++) {
if (g.pendingNexusIxianBetrayal) return;
if (g.pendingGuildBetrayal) return;
if (g.pendingNexusIxianReplacement) return;
if (g.pendingRicheseBetrayal) return;
@@ -25619,6 +25787,7 @@ function settleAutomaticContinuations(g: Game) {
finishResponse(g, false);
} else if (!finishAutomaticDecision(g)) return;
finishActionContinuations(g);
if (g.pendingNexusIxianBetrayal) return;
if (g.pendingGuildBetrayal) return;
if (g.pendingRicheseBetrayal) return;
// Opposing/automatic consequences can release a now-impossible promise;
@@ -25636,6 +25805,7 @@ function settleAutomaticContinuations(g: Game) {
}
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
export function normalizeAutomaticGame(state: Game): Game {
ixianNexusBetrayalIntegrity(state);
ixianNexusReplacementIntegrity(state);
guildBetrayalIntegrity(state);
state = migratePrintedKullCounter(state);
@@ -25673,6 +25843,7 @@ export function normalizeAutomaticGame(state: Game): Game {
ecazCollectionIntegrity(state);
ecazAllianceIntegrity(state);
const g = structuredClone(state);
if (g.pendingNexusIxianBetrayal) return g;
if (g.pendingNexusIxianReplacement) return g;
if (g.pendingGuildBetrayal) return g;
if (g.pendingRicheseBetrayal) return g;
@@ -25726,6 +25897,12 @@ function applyActionInner(
): Game {
ecazTreacheryIntegrity(state);
const g = structuredClone(state);
if (g.pendingNexusIxianBetrayal) {
getPlayer(g,id);
if (action?.type === 'advanceBots') return g;
decideIxianNexusBetrayal(g,id,action);
return g;
}
if (g.pendingNexusIxianReplacement) {
getPlayer(g,id);
if (action?.type === 'advanceBots') return g;
@@ -29579,6 +29756,7 @@ function applyActionInner(
throw new RuleError('That action is not available.');
}
export function viewGame(state: Game, id: string) {
ixianNexusBetrayalIntegrity(state);
ixianNexusReplacementIntegrity(state);
guildBetrayalIntegrity(state);
state = migratePrintedKullCounter(state);
@@ -29953,6 +30131,8 @@ export function viewGame(state: Game, id: string) {
? {...nexusRule(() => quoteDiscoveryEntry({...g,discoveries:g.discoveries!},g.discoveryEntry!,id)),event:g.decision.event} : null,
greatMaker: g.greatMaker ? { event:g.greatMaker.event, turn:g.greatMaker.turn, stage:g.greatMaker.stage, votes:structuredClone(g.greatMaker.votes), order:[...g.greatMaker.order], ride:greatMakerRideOptions(g,id) } : null,
nexusCards: projectedNexusCards(g, id),
nexusIxianBetrayalPreview: !!g.nexusIxianBetrayalPreview,
nexusIxianBetrayalReaction: ixianNexusBetrayalView(g,id),
nexusIxianReplacementPreview: !!g.nexusIxianReplacementPreview,
nexusIxianReplacement: ixianNexusReplacementView(g,id),
nexusChoamTrade: currentNexusChoamTrade(g, id),
@@ -30022,7 +30202,9 @@ export function viewGame(state: Game, id: string) {
storm: g.status === 'lobby' ? STORM_START_SECTOR : g.storm,
playerPositions: normalizedPlayerPositions(g),
order: g.order,
active: g.active,
active: g.pendingNexusIxianBetrayal
? (() => {const remaining = g.pendingNexusIxianBetrayal.required.filter(seat => !g.pendingNexusIxianBetrayal!.passed.includes(seat)); return remaining.length === 1 ? remaining[0] : null;})()
: g.active,
movementRemaining: g.movementRemaining ?? null,
guildTimingLocked: g.guildTimingLocked ?? false,
ready: g.ready,
+288
View File
@@ -0,0 +1,288 @@
import { treacheryDeck, type Card } from './cards';
import {
nexusCardMode, validateNexusCards, validateNexusPlayers,
type NexusPlayer, type NexusState,
} from './nexus-cards';
export type IxianBetrayalKind = 'bidding' | 'technology';
/** Independently produced by the native adapter after its original counters.
* Native quotes, not the saved source, authorize draw/exchange continuations. */
export type IxianBetrayalAuthority = {
kind: IxianBetrayalKind;
provider: string;
nativeWindow: 'ixAuction' | 'ixTechnology';
nativeContext: string;
nativeRequired: readonly string[];
nativePassed: readonly string[];
};
export type IxianBetrayalContext = {
status: string;
phase: number;
turn: number;
advanced: boolean;
sequence: number;
players: readonly NexusPlayer[];
cards: NexusState;
/** Every live physical Treachery location once; no auction receipt aliases. */
physicalCards: readonly Card[];
};
export type IxianBetrayalSource = IxianBetrayalAuthority & {
version: 1;
event: string;
turn: number;
phase: 3;
advanced: boolean;
sequence: number;
required: readonly string[];
eligible: string | null;
parent: string;
signature: string;
};
export type IxianBetrayalCursor = { sequence: number; signature: string };
export type IxianBetrayalReceipt = {
source: IxianBetrayalSource;
outcome: 'pass' | 'use';
holder: string | null;
previous: string;
signature: string;
};
export class IxianBetrayalError extends Error {
constructor(message: string) {
super(message);
this.name = 'IxianBetrayalError';
}
}
const canonical = new Map(treacheryDeck(['ix']).map(card => [card.id, card]));
const genesis = 'ixian-betrayal:0';
const permitted: Readonly<Record<string, true>> = {
atreides: true, harkonnen: true, emperor: true, fremen: true, guild: true,
beneGesserit: true, ixians: true, tleilaxu: true,
};
const plain = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value) &&
[Object.prototype, null].includes(Object.getPrototypeOf(value));
const text = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0 &&
!['__proto__', 'constructor', 'prototype'].includes(value);
const whole = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
const array = (value: unknown): boolean => Array.isArray(value);
function requireBetrayal(value: unknown, message: string): asserts value {
if (!value) throw new IxianBetrayalError(message);
}
function exact(value: unknown, keys: readonly string[]): value is Record<string, unknown> {
return plain(value) && Object.keys(value).sort().join(',') === [...keys].sort().join(',');
}
function members(value: unknown): value is readonly string[] {
return Array.isArray(value) && value.every(text) && new Set(value).size === value.length;
}
function sameMembers(one: readonly string[], two: readonly string[]): boolean {
return one.length === two.length && one.every((id, index) => id === two[index]);
}
function canonicalCard(value: unknown): value is Card {
if (!plain(value) || !text(value.id)) return false;
const expected = canonical.get(value.id);
return !!expected && exact(value, ['id', 'name', 'kind',
...(expected.effect === undefined ? [] : ['effect'])]) &&
value.name === expected.name && value.kind === expected.kind && value.effect === expected.effect;
}
function validatePhysical(cards: readonly Card[]): void {
requireBetrayal(Array.isArray(cards) && cards.length === canonical.size &&
cards.every(canonicalCard) && new Set(cards.map(card => card.id)).size === canonical.size,
'Ixian Betrayal requires the complete canonical Ixian deck in unique physical custody.');
}
function validateRoster(players: readonly NexusPlayer[]): void {
validateNexusPlayers(players);
requireBetrayal(players.every(player => Object.hasOwn(permitted, player.faction)) &&
players.filter(player => player.faction === 'ixians').length === 1,
'Ixian Betrayal needs its native Ixian and permitted classic or Tleilaxu seats.');
}
/** A neutral acknowledgment depends on public held-card presence, never its face. */
export function ixianBetrayalResponders(cards: NexusState, players: readonly NexusPlayer[]): string[] {
validateRoster(players);
validateNexusCards(cards, players);
return players.filter(player => !player.ally && player.faction !== 'ixians' &&
cards.hands[player.id] !== null).map(player => player.id);
}
export function ixianBetrayalEligible(
cards: NexusState, players: readonly NexusPlayer[], holder: string,
): boolean {
validateRoster(players);
validateNexusCards(cards, players);
const seat = players.find(player => player.id === holder);
return !!seat && !seat.ally && seat.faction !== 'ixians' && cards.hands[holder] === 'ixians' &&
nexusCardMode('ixians', seat.faction, players.map(player => player.faction)) === 'betrayal';
}
/** Only public original native attempt facts appear in the event. */
export function ixianBetrayalEvent(
turn: number, sequence: number, kind: IxianBetrayalKind, provider: string,
): string {
requireBetrayal(whole(turn) && turn > 0 && whole(sequence) && text(provider) &&
(kind === 'bidding' || kind === 'technology'), 'Ixian Betrayal needs its original public event.');
return JSON.stringify(['nexusIxianBetrayal', turn, sequence, kind, provider]);
}
function validateAuthority(authority: IxianBetrayalAuthority): void {
requireBetrayal(exact(authority, ['kind', 'provider', 'nativeWindow', 'nativeContext',
'nativeRequired', 'nativePassed']) && text(authority.provider) && text(authority.nativeContext) &&
members(authority.nativeRequired) && members(authority.nativePassed) &&
authority.nativeRequired.every(id => authority.nativePassed.includes(id)) &&
((authority.kind === 'bidding' && authority.nativeWindow === 'ixAuction') ||
(authority.kind === 'technology' && authority.nativeWindow === 'ixTechnology')),
'Ixian Betrayal must follow the settled original native counter window.');
}
function sourceAuthority(source: IxianBetrayalSource): IxianBetrayalAuthority {
return { kind: source.kind, provider: source.provider, nativeWindow: source.nativeWindow,
nativeContext: source.nativeContext, nativeRequired: source.nativeRequired, nativePassed: source.nativePassed };
}
function sourceSignature(source: IxianBetrayalSource): string {
return JSON.stringify(['ixianBetrayalSource', source.version, source.event, source.turn,
source.phase, source.advanced, source.sequence, source.kind, source.provider,
source.nativeWindow, source.nativeContext, source.nativeRequired, source.nativePassed,
source.required, source.eligible, source.parent]);
}
function savedSource(value: unknown): value is IxianBetrayalSource {
return exact(value, ['version', 'event', 'turn', 'phase', 'advanced', 'sequence',
'kind', 'provider', 'nativeWindow', 'nativeContext', 'nativeRequired', 'nativePassed',
'required', 'eligible', 'parent', 'signature']) && value.version === 1 && whole(value.turn) &&
value.turn > 0 && value.phase === 3 && whole(value.sequence) && typeof value.advanced === 'boolean' &&
text(value.event) && text(value.parent) && text(value.signature) && members(value.required) &&
(value.eligible === null || text(value.eligible)) && text(value.provider) && text(value.nativeContext) &&
members(value.nativeRequired) && members(value.nativePassed) &&
(value.kind === 'bidding' || value.kind === 'technology') &&
(value.nativeWindow === 'ixAuction' || value.nativeWindow === 'ixTechnology');
}
function validateSavedSource(value: unknown): asserts value is IxianBetrayalSource {
requireBetrayal(savedSource(value), 'Ixian Betrayal lost its original canonical source.');
const source = value;
validateAuthority(sourceAuthority(source));
requireBetrayal((source.kind !== 'technology' || source.advanced) &&
!source.required.includes(source.provider) &&
(source.eligible === null || source.required.includes(source.eligible)) &&
source.event === ixianBetrayalEvent(source.turn, source.sequence, source.kind, source.provider) &&
source.signature === sourceSignature(source),
'Ixian Betrayal changed its original event or native source.');
}
function validateContext(ctx: IxianBetrayalContext): void {
requireBetrayal(plain(ctx) && ctx.status === 'playing' && ctx.phase === 3 && whole(ctx.turn) &&
ctx.turn > 0 && whole(ctx.sequence) && typeof ctx.advanced === 'boolean',
'Ixian Betrayal needs its current Bidding phase and independent cursor.');
validateRoster(ctx.players);
validateNexusCards(ctx.cards, ctx.players);
validatePhysical(ctx.physicalCards);
}
export function validateIxianBetrayalSource(
ctx: IxianBetrayalContext, source: IxianBetrayalSource,
authority: IxianBetrayalAuthority, parent: string,
): void {
validateSavedSource(source);
validateContext(ctx);
validateAuthority(authority);
requireBetrayal(source.turn === ctx.turn && source.sequence === ctx.sequence &&
source.advanced === ctx.advanced && source.parent === parent && text(parent) &&
ctx.players.find(player => player.id === source.provider)?.faction === 'ixians' &&
source.nativeRequired.every(id => ctx.players.some(player => player.id === id)) &&
source.nativePassed.every(id => ctx.players.some(player => player.id === id)) &&
source.kind === authority.kind && source.provider === authority.provider &&
source.nativeWindow === authority.nativeWindow && source.nativeContext === authority.nativeContext &&
sameMembers(source.nativeRequired, authority.nativeRequired) &&
sameMembers(source.nativePassed, authority.nativePassed) &&
sameMembers(source.required, ixianBetrayalResponders(ctx.cards, ctx.players)) &&
source.eligible === (ctx.players.find(player => !player.ally && player.faction !== 'ixians' &&
ctx.cards.hands[player.id] === 'ixians')?.id ?? null),
'Ixian Betrayal no longer matches its current native attempt, parent or public responders.');
}
export function createIxianBetrayalSource(
ctx: IxianBetrayalContext, authority: IxianBetrayalAuthority, parent: string,
): IxianBetrayalSource {
validateContext(ctx);
validateAuthority(authority);
const source: IxianBetrayalSource = {
...authority, nativeRequired: [...authority.nativeRequired], nativePassed: [...authority.nativePassed],
version: 1, event: ixianBetrayalEvent(ctx.turn, ctx.sequence, authority.kind, authority.provider),
turn: ctx.turn, phase: 3, advanced: ctx.advanced, sequence: ctx.sequence,
required: ixianBetrayalResponders(ctx.cards, ctx.players),
eligible: ctx.players.find(player => !player.ally && player.faction !== 'ixians' &&
ctx.cards.hands[player.id] === 'ixians')?.id ?? null,
parent, signature: '',
};
source.signature = sourceSignature(source);
validateIxianBetrayalSource(ctx, source, authority, parent);
return source;
}
/** Validate before any Nexus cost or native continuation, including Pass. */
export function validateIxianBetrayalFrame(
ctx: IxianBetrayalContext, source: IxianBetrayalSource,
authority: IxianBetrayalAuthority, parent: string,
required: readonly string[], passed: readonly string[],
): void {
validateIxianBetrayalSource(ctx, source, authority, parent);
requireBetrayal(members(required) && members(passed) && sameMembers(required, source.required) &&
passed.every(id => required.includes(id)),
'Ixian Betrayal lost its exact public required or passed membership.');
}
export function initialIxianBetrayalCursor(): IxianBetrayalCursor {
return { sequence: 0, signature: genesis };
}
function validateCursor(value: unknown): asserts value is IxianBetrayalCursor {
requireBetrayal(exact(value, ['sequence', 'signature']) && whole(value.sequence) && text(value.signature) &&
(value.sequence !== 0 || value.signature === genesis),
'Ixian Betrayal lost its independent closed-event cursor.');
}
function copySource(source: IxianBetrayalSource): IxianBetrayalSource {
return { ...source, nativeRequired: [...source.nativeRequired], nativePassed: [...source.nativePassed],
required: [...source.required] };
}
function receiptSignature(receipt: IxianBetrayalReceipt): string {
return JSON.stringify(['ixianBetrayalReceipt', receipt.source.signature, receipt.outcome,
receipt.holder, receipt.previous]);
}
/** A bounded previous-event link, not the previous receipt's full signature. */
function receiptHead(receipt: IxianBetrayalReceipt): string {
return JSON.stringify(['ixianBetrayalHead', receipt.source.event, receipt.outcome, receipt.holder]);
}
export function closeIxianBetrayalSource(
source: IxianBetrayalSource, outcome: 'pass' | 'use', holder: string | null,
previous: IxianBetrayalCursor,
): { receipt: IxianBetrayalReceipt; cursor: IxianBetrayalCursor } {
validateSavedSource(source);
validateCursor(previous);
requireBetrayal(source.sequence === previous.sequence && previous.sequence < Number.MAX_SAFE_INTEGER &&
(outcome === 'pass' ? holder === null : outcome === 'use' && text(holder) && holder === source.eligible),
'Ixian Betrayal cannot close twice or lose its actual response outcome.');
const receipt: IxianBetrayalReceipt = {
source: copySource(source), outcome, holder, previous: previous.signature, signature: '',
};
receipt.signature = receiptSignature(receipt);
return { receipt, cursor: { sequence: previous.sequence + 1, signature: receiptHead(receipt) } };
}
/** Historical custody is not current ownership: the physical Nexus or Treachery
* cards may since have been discarded, transferred and recycled. These links
* check consistency, not authentication of arbitrary rewritten saved games. */
export function validateIxianBetrayalHistory(
history: readonly IxianBetrayalReceipt[], cursor: IxianBetrayalCursor,
physical?: readonly Card[],
): void {
validateCursor(cursor);
requireBetrayal(array(history) && history.length === cursor.sequence,
'Ixian Betrayal history has missing or duplicate closed attempts.');
if (physical !== undefined) validatePhysical(physical);
let previous = genesis, turn = 0;
const events = new Set<string>();
for (const [index, receipt] of history.entries()) {
requireBetrayal(exact(receipt, ['source', 'outcome', 'holder', 'previous', 'signature']),
'Ixian Betrayal history has a malformed receipt.');
validateSavedSource(receipt.source);
requireBetrayal(receipt.source.sequence === index && receipt.source.turn >= turn &&
!events.has(receipt.source.event) && receipt.previous === previous &&
(receipt.outcome === 'pass' ? receipt.holder === null : receipt.outcome === 'use' &&
text(receipt.holder) && receipt.holder === receipt.source.eligible) &&
receipt.signature === receiptSignature(receipt),
'Ixian Betrayal history changed its native attempt, outcome or continuation chain.');
events.add(receipt.source.event);
previous = receiptHead(receipt);
turn = receipt.source.turn;
}
requireBetrayal(cursor.signature === previous,
'Ixian Betrayal history no longer matches its independent cursor.');
}
+49 -7
View File
@@ -128,6 +128,7 @@ const phaseDetails = [
'The highlighted player may raise the bid or pass. A new high bid clears previous passes.',
'Holding Karama permits a bid above your available spice. If outbid, keep the card. A winning bid above your funds requires Karama.',
'After everyone else passes, the winner chooses spice payment or Karama. The table then resolves applicable Emperor-income and Harkonnen-bonus responses.',
'In the fresh local ixian-betrayal preview, native Ixian Bidding inspection and Advanced Technology first finish their original Karama responses, then pause for neutral Nexus acknowledgements before their effects. A legal rival Use prevents only the current attempt; all required passes allow it. See Ixian Nexus Betrayal for both alternatives, private-card limits and the unchanged public release gate.',
'In Homeworld development tables with Emperor, the last completed auction opens End of Bidding. High Kaitain paid discards and CHOAM closing sales or trades may occur in either order. Finish your opportunity when ready; another card action clears readiness before the phase advances.',
],
[
@@ -214,6 +215,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'ecaz-ambassadors',
'richese-cards',
'nexus-richese-betrayal',
'nexus-ixian-betrayal',
'richese-acquisition',
'richese-gift',
'distrans-transfer',
@@ -1652,9 +1654,10 @@ export const RULE_TOPICS: RuleTopic[] = [
'A full Ixian hand does not prevent its inspection advantage. The number of auction cards is determined by factions able to bid. The private pool record is unordered and includes cards already auctioned; it is not a view of another player’s hand or the upcoming card.',
'In advanced play, before bidding on a card and before Atreides gets its peek, Ixians may offer one hand card for the upcoming unseen card. Declining saves the option for a later card. Declaring an exchange spends the once-per-round attempt and opens a separate Karama response; cancellation keeps both cards where they are.',
'After a paid or Karama-paid purchase, an Ixian ally may keep the purchased card or request its replacement. A response resolves before discarding that exact card and drawing the deck’s top card privately. Payment still settles normally. Harkonnen’s separate bonus draw follows the replacement.',
'The separate fresh ixian-betrayal profile connects both Nexus Betrayal alternatives to those original native attempts. After native counters pass, a neutral acknowledgement precedes the draw or declared Advanced exchange. Use prevents just that attempt: ordinary Bidding count without Ixian inspection, or unchanged hand/lot custody with the Technology declaration still used. All-pass keeps the native advantage. This bounded ordering/privacy convention is an inference, not a universal publisher priority.',
'The controls preserve decisions and private cards across reconnects. Complete expansion starts, Richese/other auction modes, exact phase-opening ordering, short-deck behavior and interruption timing remain under audit.',
],
related: ['bidding', 'setup', 'ix-forces', 'nexus-ixian-replacement'],
related: ['bidding', 'setup', 'ix-forces', 'nexus-ixian-replacement', 'nexus-ixian-betrayal'],
},
{
id: 'mobile-stronghold',
@@ -2119,11 +2122,13 @@ export const RULE_TOPICS: RuleTopic[] = [
]
: phase === 4
? ['ecaz-ambassadors', 'tleilaxu-revival']
: phase === 1
? ['card-harvester', 'faction-fremen']
: phase === 0
? ['storm-cards', 'advanced-storm-spice', 'faction-fremen']
: undefined,
: phase === 3
? ['ix-technology', 'nexus-ixian-betrayal', 'nexus-ixian-replacement', 'richese-cards']
: phase === 1
? ['card-harvester', 'faction-fremen']
: phase === 0
? ['storm-cards', 'advanced-storm-spice', 'faction-fremen']
: undefined,
}),
),
{
@@ -4211,6 +4216,42 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Verification', status: 'Partial', detail: 'Genuine paid/printed Karama acquisition, exact target/custody, full-hand and a captured native depleted pool cover same-card redraw, unsold-row exclusion, original ledger once, privacy, replay and four legal bots. Authenticated SQLite covers restart, races and durable rejection. Actual backed-up CLI/phone Use, Keep, takeback and refresh preserve original seats and stock. Wider modes and deployment remain open.', evidence: ['tests/nexus-ixian-replacement.test.ts', 'tests/nexus-ixian-replacement-engine.test.ts', 'tests/fixtures/nexus-ixian-native-depleted.json', 'tests/fixture-nexus-ixian-replacement.ts', 'tests/nexus-ixian-replacement-controls.test.tsx', 'tests/bot-nexus-ixian-replacement.test.ts', 'tests/nexus-ixian-replacement-recovery.test.ts', 'tests/prototype-room.test.ts', 'docs/NEXUS_IXIAN_REPLACEMENT_RULES.md'] },
],
},
{
id: 'nexus-ixian-betrayal',
title: 'Ixian Nexus Betrayal: prevent Bidding or Technology',
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Verified',
summary: 'In the fresh local ixian-betrayal preview, an unallied rival may spend the physical Ixian Nexus to prevent one original native Bidding inspection/draw attempt or one Advanced Technology exchange before its effect.',
searchText: 'Ixian Nexus Betrayal Bidding extra card inspection draw Advanced Technology exchange native counter neutral acknowledgement',
steps: [
'This fresh local profile admits two to six unique Basic or Advanced seats with native Ixians and otherwise classic factions or Tleilaxu, the physical forty-seven-card Ix Treachery Deck and Nexus alone. CHOAM, Richese, Ecaz, Moritani, Homeworlds, Leader Skills, Semuta, Discoveries and other optional overlays are excluded. It is not a public start, reset or upgrade to an old save.',
'Nexus has no initial deal or automatic draw. Finish a genuine qualifying closing Nexus with an alliance before an unallied seat may draw. One natural receiver needs at least three actual seats; two simultaneous receivers need four. A two-seat profile can continue ordinary play without fabricating an unreachable Betrayal card.',
'The photographed publisher face grants both alternatives, but one card cannot prevent both. Remain unallied and hold the actual singleton Ixian Nexus while another seat is native Ixians. Use discards that physical Nexus once, without a Treachery Card, Karama, activation spice, hand slot or force cost. Cunning and absent-Ixian Secret Ally remain separate modes.',
'The current original native attempt determines the alternative. Bidding works in Basic and Advanced; Technology requires Advanced and an actual accepted exchange declaration before bidding or Atreides inspection. You cannot choose a different source, provider, draw count, hand card, lot, price or completed attempt.',
'Resolve the original native Karama window first, including legal Advanced Bene Gesserit substitutions. A successful native counter takes the original denied transition once without opening a Nexus gate or spending Nexus. After all native passes, pause before the actual draw, exchange or Atreides peek.',
'Original native response ownership survives legal BG Worthless-Karama conversion and hand-exchange suspension. If a legal pre-offer Harkonnen exchange removes the declared Technology card, the native attempt completes without an exchange or Nexus offer; the declared attempt stays used. An already-open Nexus source still rejects changed card custody or parent state.',
'All publicly possible unallied non-Ixian seats with public held-Nexus presence receive the same Native Ixian advantage acknowledgement, irrespective of secret face. An irrelevant-face seat may Pass; only the canonical privately eligible owner sees Use and their own unavailable reason. Native-counter-first and neutral acknowledgement are explicit bounded privacy/ordering inferences, not publisher-prescribed universal priority or a ruling for other pending stacks.',
'Pass · allow the original advantage spends nothing and retains Nexus. Your saved pass cannot be undone for this event. All required passes allow the original native effect once; no publicly possible responders allow it immediately. Use Ixian Nexus · prevent this Bidding advantage or Technology advantage spends the singleton card and denies just this source.',
'Bidding prevention draws exactly the normal allocated count, without an extra inspection card, Ixian private inspection or return selector. All-pass retains the native extra draw, private top/bottom return and auction continuation. The real native deck allocation still governs depleted stock; Nexus does not manufacture cards or rebuild the pool.',
'Technology prevention keeps the selected Ixian hand card and unseen lot where they were, preserves the declaration’s spent once-per-turn attempt, then resumes the original normal Atreides/auction continuation once. All-pass performs the actual declared exchange and its continuation once. A native decline before declaration still saves the option.',
'This is one current attempt, not a whole-phase ban, removal of all Ixian powers or undo of already completed draws, selections, exchanges, inspections or purchases. A later legally available native attempt may open a new event, including after later physical card recycling. General duration over other multiple-lot situations remains outside this bounded profile.',
'The acknowledgement exposes only its public event, Bidding/Technology kind and native provider. It reveals no selected Ixian hand card, unseen lot, extra inspection faces, deck order, rival Nexus identity, private source or rival eligibility reason. Inspect only your own actually held Nexus through its existing inspector; a neutral prompt grants no new card entitlement.',
'Both actions submit only the offered event. Refresh resumes the same source and passes without another declaration, native counter cost, Nexus spend, draw, swap or suffix. Closed events expire; nonrecursive saved history remains valid when cards later move or recycle. Stale, foreign, malformed or orphaned sources reject instead of silently allowing the native effect.',
'Finish the pending source before ordinary bidding, draws, exchanges or competing card mutation. Existing responses, decisions and Truthtrance retain priority; independent own-seat autopilot changes remain available to every seat. No additional Karama counter to direct Nexus prevention is invented, and no blanket Nexus immunity follows.',
'Easy, Medium, Hard and Brutal use their own projected legal Use/Pass choices for minimal continuation, not calibrated Nexus strategy. Frozen native, private controls, bots, authenticated recovery, exact-version CLI and phone evidence verify this bounded path. Raw administrator progress follows the same public unfinished acknowledgements; full modes and deployment remain separate gates.',
'Advanced Technology on Richese cache/Black Market lots remains unresolved and excluded; its separate saved decline elsewhere is not an exchange ruling. General competing-effect priority, combined modules, complete Ixian/Nexus games, strategic calibration, public release and live deployment remain separate gates. The adopted unofficial Advanced PDF supplies core defaults, not this Nexus face or a new Nexus ruling.',
],
example: 'After native counters pass, a rival spends Ixian Nexus on Bidding: only the ordinary allocated auction cards are drawn and Ixians inspect none. On an Advanced Technology declaration instead, Use keeps both cards in place and the declared attempt used; the normal Atreides/auction suffix follows. Passing permits the corresponding original advantage unchanged.',
related: ['nexus-cards', 'bidding', 'ix-technology', 'ix-deck', 'card-karama', 'advanced-beneGesserit', 'privacy', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Both current-attempt alternatives bind original native source and continuation, separate singleton Nexus cost, native counters first, uniform public membership and nonrecursive pending/closed history. Wider producers, duration and combined modules remain excluded.', evidence: ['game/nexus-ixian-betrayal.ts', 'game/engine.ts', 'game/ix-auction-draw-quote.ts', 'game/ix-technology-cancellation.ts', 'tools/prototype-room.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Neutral native-advantage acknowledgement separates public Pass/wait from private eligible Use and own-held Nexus inspection, with event-only actions, no rival card/source disclosure and pending native controls suspended. Broader presentation acceptance remains open.', evidence: ['components/nexus-ixian-betrayal.tsx', 'components/game-table.tsx', 'game/table-turn.ts'] },
{ area: 'AI', status: 'Partial', detail: 'All four minimal legal policies consume only canonical own-view Use/Pass eligibility and preserve pending priority. Full Nexus strategy and difficulty calibration remain unfinished.', evidence: ['game/bot-nexus-ixian-betrayal.ts', 'game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Photographed face, native source order, both alternatives, current-attempt duration, physical cost, inferred privacy/ordering, fresh natural Nexus rights and explicit Richese/module/public/deployment boundaries are recorded without PDF Nexus authority or full-family claims.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_CARD_RUNTIME.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Genuine native Basic/Advanced Bidding and Advanced Technology allow/prevent, printed/BG priority, exact stock/count/attempt/suffix, public face parity, legal bots and authenticated restart/races/rejection pass. Actual backed-up CLI/phone Use/Pass/partial refresh and saved stock prove both endpoints. Native Harkonnen/BG and public admin ownership regressions retain prior continuation; wider modes and deployment remain open.', evidence: ['tests/nexus-ixian-betrayal.test.ts', 'tests/nexus-ixian-betrayal-runtime.test.ts', 'tests/fixture-nexus-ixian-betrayal.ts', 'tests/nexus-ixian-betrayal-controls.test.tsx', 'tests/bot-nexus-ixian-betrayal.test.ts', 'tests/nexus-ixian-betrayal-recovery.test.ts', 'tests/prototype-room.test.ts', 'tests/ix-technology.test.ts', 'tests/admin-directory.test.ts'] },
],
},
{
id: 'nexus-cards',
title: 'Nexus Cards',
@@ -4236,6 +4277,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Tleilaxu Cunning sets aside all your revealed Face Dancers, draws their replacements secretly, and only then shuffles the set-aside cards into the Traitor Deck. Unrevealed dancers stay in your hand. This single operation does not use or renew your ordinary Mentat replacement; the native all-three-revealed reset keeps its separate shuffle-before-draw order.',
'Ixian Cunning is used in your battle before submitting your Battle Plan. Your Suboids then count as strength one without spice support in every battle for the rest of the turn, in Basic and Advanced. Cyborg strength and support are unchanged. Earlier inspected or promised plan elements remain binding; you cannot activate the card if the new strength would make them impossible.',
'Ixian Secret Ally has a fresh local ixian-replacement profile for normal paid or printed Karama-paid auction purchases in classic base-deck/Nexus Basic or Advanced games. A supported unallied buyer may spend its actual Nexus to discard exactly the just-purchased card and draw the real deck top privately; Pass keeps both cards. Full-hand replacement and empty-deck same-card redraw are allowed, and original payment/income/auction continuation occur once. Harkonnen buyers, cache/Black Market/special origins and combined modules remain guarded. The neutral buyer choice is an inferred privacy convention, not publisher timing. See the dedicated guide for bounded verified native, private, recovery and phone evidence; this is not full-module certification.',
'Ixian Betrayal has a separate fresh native-Ixian ixian-betrayal profile for both original Bidding inspection/draw and Advanced Technology attempts. Native counters resolve first; then every publicly possible unallied rival held-Nexus seat acknowledges uniformly before the effect. A legal Use spends one physical Ixian Nexus to deny only that attempt; all-pass preserves the native effect once. No completed-card undo or whole-phase ban is added. This bounded ordering/privacy is an inference, not universal publisher priority; see its bounded verified native/private/recovery/phone evidence and unchanged Richese/module/public/deployment gates.',
'Tleilaxu Secret Ally revival remains unavailable pending its ordinary-allowance, leader-eligibility and optional-leader rulings. The printed fixed force price does not resolve those questions.',
'In Advanced play, Bene Gesserit Cunning converts selected whole advisor territories during your own Shipment and Movement action. All your counters in each selected territory become fighters together across sectors. It spends the Nexus card once, with no spice, shipment or movement cost.',
'One Karama response covers the entire declared territory set. Cancellation keeps every selected group as advisors and leaves the Nexus card spent. Existing territory restrictions still apply; fresh accompanied advisors and storm-related conversion remain unavailable pending their rulings.',
@@ -4266,7 +4308,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Cunning spends its Nexus card and uses the native Mentat placement opportunity. A Karama response precedes placement; cancellation leaves the token where it was and the Nexus spent. Later Terror entry still uses the existing one-token stack selection and supported effect rules. If Extortion was revealed this turn, its bank award and payment choices follow that placement response; this does not implement Atomics or complete Moritani games.',
'Hidden reactive Betrayal timing remains unresolved for other effects. Complete module games are unfinished, so public module starts remain disabled.',
],
related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal', 'nexus-guild-betrayal', 'nexus-ixian-replacement'],
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: '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 auction Betrayal, 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-richese-betrayal.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 and bounded auction Betrayal, 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/nexus-richese-betrayal.tsx', 'components/game-table.tsx'] },
+4 -1
View File
@@ -12,7 +12,7 @@ type TurnView = Pick<
| 'phaseOpening'
| 'automaticContinuationPending'
> &
Partial<Pick<GameView, 'biddingEnd' | 'kullReaction' | 'richeseBetrayalReaction' | 'guildBetrayalReaction' | 'nexusIxianReplacement'>>;
Partial<Pick<GameView, 'biddingEnd' | 'kullReaction' | 'richeseBetrayalReaction' | 'guildBetrayalReaction' | 'nexusIxianReplacement' | 'nexusIxianBetrayalReaction'>>;
/** The single current decision owner for the table banner and seat highlight.
* Shared response windows have no single acting seat. Only public view fields
@@ -32,6 +32,9 @@ export function tableActionOwner(g: TurnView): string | null {
return null;
const seated = (id: string | null | undefined) =>
g.players.some((p) => p.id === id) ? id! : null;
// active is the runtime's public sole unfinished acknowledgement seat,
// never the private card holder or native advantage provider.
if (g.nexusIxianBetrayalReaction) return seated(g.active);
if (g.nexusIxianReplacement) return seated(g.nexusIxianReplacement.buyer);
if (g.decision) return seated(g.decision.player);
if (g.biddingEnd) {