Integrate exact Ixian Nexus purchased-card replacement

This commit is contained in:
Quintine committed 2026-10-01 17:12:46 +10:00
1 parent c43ab64872
commit a2b04dbfba
26 files changed
+4588 -34

No files matched your search

+1
View File
@@ -86,6 +86,7 @@ const decisions = {
capturedLeader: true,
guildTiming: true,
auctionPayment: true,
nexusIxianReplacement: true,
advisor: true,
intrusion: true,
advisorBattle: true,
+21 -1
View File
@@ -1513,6 +1513,9 @@ function policyActions(g: GameView): Action[] {
if (g.decision) {
if (g.decision.player !== me.id) return [];
const d = g.decision;
// The dedicated pending-purchase policy owns event-only choices.
// A decision without its private view offer must not become an ordinary action.
if (d.kind === 'nexusIxianReplacement') return [];
if (d.kind === 'leaderSkillVisibility' || d.kind === 'leaderSkillRevival' || d.kind === 'mentatQuestion' || d.kind === 'bureaucratPayment') return [];
if (d.kind === 'harassWithdraw') {
const offer = g.battle?.harassAllocation;
@@ -2669,7 +2672,7 @@ function policyActions(g: GameView): Action[] {
),
},
];
return [
if (d.kind === 'wormRide') return [
...destinations(g)
.filter(
(to) =>
@@ -3834,6 +3837,23 @@ export function botActions(g: GameView): Action[] {
if (g.kullCounterEvent)
return botChoamKullCounterActions(g);
if (g.semutaReaction) return botSemutaActions(g);
const replacement = g.nexusIxianReplacement;
if (replacement) {
const own = g.players.find((player) => player.id === g.me);
if (!(own?.bot ?? own?.autopilot) || g.status !== 'playing' || g.phase !== 3 ||
g.roomControl?.paused || g.roomControl?.closed || g.automaticContinuationPending ||
g.truthtrance || g.response || g.phaseOpening || g.nexusCards?.waiting.length ||
g.nexusTraitors?.pending || !replacement.event || replacement.buyer !== g.me || !replacement.canPass ||
(g.decision && (g.decision.kind !== 'nexusIxianReplacement' ||
g.decision.player !== g.me || g.decision.event !== replacement.event))) return [];
// Value only the authorized purchased card; the runtime owns custody,
// eligibility, original payment and the native continuation.
const use = replacement.canUse && !replacement.blocked && replacement.purchased &&
(replacement.purchased.kind === 'worthless' ||
technologyCardValue(g, replacement.purchased) < (rank(g) === 0 ? 0.5 : 4));
return [{ type: use ? 'nexusIxianReplacementUse' : 'nexusIxianReplacementPass', event: replacement.event }];
}
if (g.decision?.kind === 'nexusIxianReplacement') return [];
const fremenRevival = g.nexusFremenRevival;
if (fremenRevival && !fremenRevival.blocked && fremenRevival.eliteOptions.length) {
const own = g.players.find(player => player.id === g.me);
+151 -4
View File
@@ -47,6 +47,7 @@ import { createMoritaniAssassinateOpportunity, moritaniAssassinateTrigger, morit
import { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGuildSecretShipment, type NexusGuildSecretAllyReceipt } from './nexus-guild-secret-ally';
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 { 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';
@@ -806,6 +807,7 @@ export type Decision =
| { kind: 'ixTechnology'; player: string }
| { kind: 'ixRicheseTechnology'; player: string; event: string; source: 'cache' | 'blackMarket' }
| { kind: 'ixAllyCard'; player: string }
| { kind: 'nexusIxianReplacement'; player: string; event: string }
| { kind: 'mobileStronghold'; player: string; placement: boolean }
| {
kind: 'ixSubstitution';
@@ -1121,6 +1123,11 @@ export type Game = {
/** Explicit development profile; never enabled by a public start or player action. */
kullPreview?: boolean;
nexusKullPreview?: boolean;
/** Fresh bounded classic Nexus purchased-card prototype; never inferred for old saves. */
nexusIxianReplacementPreview?: true;
pendingNexusIxianReplacement?: IxianReplacementSource | null;
nexusIxianReplacementHistory?: IxianReplacementReceipt[];
nexusIxianReplacementCursor?: IxianReplacementCursor;
kullSequence?: number;
kullRestrictions?: KullPhaseRestriction[];
pendingKull?: {
@@ -8107,6 +8114,122 @@ export function initializeNexusGameForAudit(state: Game): Game {
'Enable Nexus cards in a fresh audit lobby first.');
return initializeSetupGameForAudit(state, !!state.homeworlds, true);
}
/** Fresh local classic profile only; no public start or existing-game retrofit. */
export function initializeIxianNexusReplacementGameForAudit(state: Game): Game {
requireRule(!state.nexusIxianReplacementPreview && !state.pendingNexusIxianReplacement &&
state.nexusIxianReplacementHistory === undefined && state.nexusIxianReplacementCursor === undefined &&
ixianNexusReplacementModeSupported(state),
'Ixian Nexus replacement needs a fresh classic lobby with only base Treachery and Nexus cards.');
const g = initializeNexusGameForAudit(state);
g.nexusIxianReplacementPreview = true;
g.pendingNexusIxianReplacement = null;
g.nexusIxianReplacementHistory = [];
g.nexusIxianReplacementCursor = initialIxianReplacementCursor();
return g;
}
function ixianNexusReplacementModeSupported(g: Game): boolean {
return !!g.nexusCards && g.expansions.length === 0 && g.players.length >= 2 && g.players.length <= 6 &&
new Set(g.players.map(p => p.faction)).size === g.players.length &&
g.players.every(p => CLASSIC_FACTIONS[p.faction] === true) &&
!g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && !g.discoveries &&
!g.techTokens && !g.strongholdCards && !g.ecazTreachery && !g.mobileStronghold && !g.sandtrout &&
!g.kullPreview && !g.nexusKullPreview && !g.semutaPreview && !g.richeseBetrayalPreview &&
!g.guildBetrayalPreview && !g.moritaniAssassinatePreview;
}
function ixianNexusReplacementParent(g: Game): string {
return JSON.stringify({status:g.status,turn:g.turn,phase:g.phase,advanced:g.advanced,
order:g.order,active:g.active,auction:g.auction,sale:g.currentAuctionSale,
players:g.players.map(p => ({id:p.id,faction:p.faction,ally:p.ally,spice:p.spice,hand:p.hand})),
deck:g.deck,discard:g.discard,aid:g.aid,nexus:g.nexusCards,
decision:g.decision,response:g.response,phaseOpening:g.phaseOpening});
}
function ixianNexusReplacementContext(g: Game) {
requireRule(g.auction, 'The Nexus purchased-card choice lost its normal auction.');
return {status:g.status,phase:g.phase,turn:g.turn,sequence:g.nexusIxianReplacementCursor!.sequence,
auction:g.auction,sale:g.currentAuctionSale ?? null,
players:g.players.map(p => ({id:p.id,hand:p.hand,handLimit:handLimit(p)})),
physicalCards:physicalTreacheryCards(g)};
}
function ixianNexusReplacementIntegrity(g: Game): void {
const pending = g.pendingNexusIxianReplacement;
if (!g.nexusIxianReplacementPreview) {
requireRule(g.nexusIxianReplacementPreview === undefined && !pending &&
g.nexusIxianReplacementHistory === undefined && g.nexusIxianReplacementCursor === undefined &&
g.decision?.kind !== 'nexusIxianReplacement',
'The Ixian Nexus replacement lost its explicit fresh profile.');
return;
}
requireRule(ixianNexusReplacementModeSupported(g) && Array.isArray(g.nexusIxianReplacementHistory) &&
g.nexusIxianReplacementCursor, 'The Ixian Nexus replacement profile or history is invalid.');
nexusRule(() => validateIxianReplacementHistory(g.nexusIxianReplacementHistory!,
g.nexusIxianReplacementCursor!,physicalTreacheryCards(g)));
if (!pending) {
requireRule(g.decision?.kind !== 'nexusIxianReplacement', 'The purchased-card choice lost its source.');
return;
}
const buyer = getPlayer(g,pending.buyer);
requireRule(!buyer.ally && buyer.faction !== 'harkonnen' && !!g.nexusCards?.cards?.hands[buyer.id] &&
g.decision?.kind === 'nexusIxianReplacement' && g.decision.player === buyer.id &&
g.decision.event === pending.event &&
Object.keys(g.decision).every(key => ['kind','player','event'].includes(key)) &&
!g.response && !g.phaseOpening && !g.truthtrance && !g.pendingKarama && !g.pendingNullentropy &&
!g.pendingTreacheryDiscard && !g.richeseAuction,
'The purchased-card choice lost its buyer or native decision priority.');
nexusRule(() => validateIxianReplacementSource(ixianNexusReplacementContext(g),pending,
ixianNexusReplacementParent(g)));
}
function offerIxianNexusReplacement(g: Game, printedKarama: boolean): boolean {
if (!g.nexusIxianReplacementPreview) return false;
const sale = g.currentAuctionSale;
const buyer = sale && getPlayer(g,sale.winner);
if (!sale || !buyer || sale.origin !== 'normal' || (sale.free && !printedKarama) ||
buyer.faction === 'harkonnen' || buyer.ally || !g.nexusCards?.cards?.hands[buyer.id]) return false;
requireRule(!g.response && !g.phaseOpening && !g.truthtrance && !g.pendingKarama,
'Finish the original auction priority before offering the purchased-card choice.');
const event = ixianReplacementEvent(g.turn,buyer.id,g.nexusIxianReplacementCursor!.sequence,g.auction!.index);
g.decision = {kind:'nexusIxianReplacement',player:buyer.id,event};
g.active = buyer.id;
g.pendingNexusIxianReplacement = nexusRule(() => createIxianReplacementSource(
ixianNexusReplacementContext(g),event,ixianNexusReplacementParent(g)));
return true;
}
function ixianNexusReplacementView(g: Game,id: string) {
const source = g.pendingNexusIxianReplacement;
if (!source) return null;
const own = source.buyer === id;
return {event:source.event,buyer:source.buyer,canPass:own,
canUse:own && g.nexusCards?.cards?.hands[id] === 'ixians',
blocked:null as string | null,purchased:own ? structuredClone(source.card) : null};
}
function decideIxianNexusReplacement(g: Game,id: string,action: Action): void {
ixianNexusReplacementIntegrity(g);
const source = g.pendingNexusIxianReplacement!;
requireRule(source.buyer === id && action.event === source.event &&
['nexusIxianReplacementPass','nexusIxianReplacementUse'].includes(action.type) &&
Object.keys(action).length === 2 && Object.keys(action).every(key => ['type','event'].includes(key)),
'Choose only the current owned purchased-card event with Use or Pass.');
let replacement: Card | null = null;
const use = action.type === 'nexusIxianReplacementUse';
if (use) {
requireRule(g.nexusCards?.cards?.hands[id] === 'ixians', 'You do not hold the eligible Ixian Nexus card.');
// All original-source/custody checks precede either the singleton cost or RNG.
g.nexusCards.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,id,g.players));
const buyer = getPlayer(g,id);
discard(g,buyer,source.card.id);
replacement = draw(g) ?? null;
requireRule(replacement, 'The purchased-card discard must supply a replacement draw.');
buyer.hand.push(replacement);
}
const closed = nexusRule(() => closeIxianReplacementSource(source,use ? 'use' : 'pass',
replacement,g.nexusIxianReplacementCursor!));
g.nexusIxianReplacementHistory!.push(closed.receipt);
g.nexusIxianReplacementCursor = closed.cursor;
g.pendingNexusIxianReplacement = null;
g.decision = null;
if (use) log(g,`${getPlayer(g,id).name} spent Ixian Nexus Secret Ally to discard the purchased Treachery Card and draw a private replacement.`,
{faction:getPlayer(g,id).faction,name:'Nexus purchased-card replacement'});
continueAuctionSale(g,source.free);
}
/** Opt-in fresh classic setup through the real physical Nexus/Homeworld setup. */
export function initializeGuildBetrayalGameForAudit(state: Game): Game {
requireRule(!state.guildBetrayalPreview && !state.guildBetrayal && !state.pendingGuildBetrayal &&
@@ -10735,7 +10858,7 @@ function auctionNext(g: Game) {
a.active = next.player;
g.active = a.active;
}
function settleAuction(g: Game, free = false, automatic = false, emperorNexus = false) {
function settleAuction(g: Game, free = false, automatic = false, emperorNexus = false, printedKarama = false) {
const a = g.auction!;
const winner = getPlayer(g, a.bidder!);
if (!free) payWithAlly(g, winner, a.bid, a.allyPayment ?? 0);
@@ -10759,7 +10882,7 @@ function settleAuction(g: Game, free = false, automatic = false, emperorNexus =
if (ixians && winner.ally === ixians.id) {
g.pendingIxAlly = { player: winner.id, card: a.cards[a.index].id, free };
g.decision = { kind: 'ixAllyCard', player: winner.id };
} else continueAuctionSale(g, free);
} else if (!offerIxianNexusReplacement(g,printedKarama)) continueAuctionSale(g, free);
}
function currentAuctionContinuationQuote(
g: Game,
@@ -12030,7 +12153,7 @@ function completeKarama(g: Game, p: Player, use: KaramaUse, card?: Card) {
);
} else {
if (use.kind === 'purchase') g.auction!.bidder = p.id;
settleAuction(g, true);
settleAuction(g, true, false, false, card?.effect === 'karama');
}
}
function auctionSpicePaymentFunded(g: Game, p: Player) {
@@ -25179,6 +25302,7 @@ function normalizeCardNames(g: Game) {
]);
}
export function applyAction(state: Game, id: string, action: Action): Game {
ixianNexusReplacementIntegrity(state);
guildBetrayalIntegrity(state);
state = migratePrintedKullCounter(state);
richeseBetrayalIntegrity(state);
@@ -25265,6 +25389,8 @@ export function applyAction(state: Game, id: string, action: Action): Game {
return normalizeAutomaticGame(state);
}
const g = applyActionInner(state, id, action);
ixianNexusReplacementIntegrity(g);
if (g.pendingNexusIxianReplacement) return g;
if (g.pendingGuildBetrayal) {guildBetrayalIntegrity(g); return g;}
if (g.pendingRicheseBetrayal) {richeseBetrayalIntegrity(g);return g;}
if (g.pendingKull) {
@@ -25462,6 +25588,7 @@ function finishActionContinuations(g: Game) {
finishMoritaniPlacement(g);
}
function settleAutomaticContinuations(g: Game) {
if (g.pendingNexusIxianReplacement) return;
if (g.pendingGuildBetrayal) return;
if (g.pendingRicheseBetrayal) return;
if (g.pendingKull?.stage === 'offer') return;
@@ -25469,6 +25596,7 @@ function settleAutomaticContinuations(g: Game) {
if (g.pendingNullentropy) return;
for (let iteration = 0; iteration < 128; iteration++) {
if (g.pendingGuildBetrayal) return;
if (g.pendingNexusIxianReplacement) return;
if (g.pendingRicheseBetrayal) return;
if (g.truthtrance || g.phaseOpening || g.status === 'finished') return;
const response = g.response;
@@ -25508,6 +25636,7 @@ function settleAutomaticContinuations(g: Game) {
}
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
export function normalizeAutomaticGame(state: Game): Game {
ixianNexusReplacementIntegrity(state);
guildBetrayalIntegrity(state);
state = migratePrintedKullCounter(state);
richeseBetrayalIntegrity(state);
@@ -25544,6 +25673,7 @@ export function normalizeAutomaticGame(state: Game): Game {
ecazCollectionIntegrity(state);
ecazAllianceIntegrity(state);
const g = structuredClone(state);
if (g.pendingNexusIxianReplacement) return g;
if (g.pendingGuildBetrayal) return g;
if (g.pendingRicheseBetrayal) return g;
if (g.pendingKull?.stage === 'offer') return g;
@@ -25596,6 +25726,12 @@ function applyActionInner(
): Game {
ecazTreacheryIntegrity(state);
const g = structuredClone(state);
if (g.pendingNexusIxianReplacement) {
getPlayer(g,id);
if (action?.type === 'advanceBots') return g;
decideIxianNexusReplacement(g,id,action);
return g;
}
if (g.pendingGuildBetrayal) {
const player = getPlayer(g, id);
if (action?.type === 'advanceBots') return g;
@@ -25670,6 +25806,8 @@ function applyActionInner(
);
requireRule(g.status !== 'finished', 'This game has ended.');
const t = action.type;
requireRule(t !== 'nexusIxianReplacementPass' && t !== 'nexusIxianReplacementUse',
'This purchased-card choice is no longer pending.');
requireRule(t !== 'guildBetrayalPass' && t !== 'guildBetrayalUse',
'This Guild Betrayal opportunity is no longer pending.');
requireRule(t !== 'richeseBetrayalPass' && t !== 'richeseBetrayalUse',
@@ -26063,6 +26201,9 @@ function applyActionInner(
}
if (g.decision) {
const decision = g.decision;
if (decision.kind === 'nexusIxianReplacement') {
requireRule(false, 'Use only the current purchased-card event with Use or Pass.');
}
requireRule(
t === 'decision' && decision.player === id,
'Waiting for the player with the pending decision.',
@@ -27372,7 +27513,7 @@ function applyActionInner(
return g;
} else log(g, `${p.name} declined the sandworm ride.`);
nextWormRide(g);
} else {
} else if (decision.kind === 'battleCards') {
requireRule(
Array.isArray(action.discard) &&
action.discard.every(
@@ -27405,6 +27546,9 @@ function applyActionInner(
played: [...decision.cards],
});
else finishBattle(g);
} else {
const unhandled: never = decision;
throw new Error(`Unsupported pending decision: ${String(unhandled)}`);
}
return g;
}
@@ -29435,6 +29579,7 @@ function applyActionInner(
throw new RuleError('That action is not available.');
}
export function viewGame(state: Game, id: string) {
ixianNexusReplacementIntegrity(state);
guildBetrayalIntegrity(state);
state = migratePrintedKullCounter(state);
richeseBetrayalIntegrity(state);
@@ -29808,6 +29953,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),
nexusIxianReplacementPreview: !!g.nexusIxianReplacementPreview,
nexusIxianReplacement: ixianNexusReplacementView(g,id),
nexusChoamTrade: currentNexusChoamTrade(g, id),
nexusChoamInspection: quoteNexusChoamInspection(g, id),
nexusChoamBetrayal: quoteNexusChoamBetrayal(g, id,
+272
View File
@@ -0,0 +1,272 @@
import { baseDeck, type Card } from './cards';
export class IxianReplacementError extends Error {}
export type IxianReplacementContext = {
status: string;
phase: number;
turn: number;
sequence: number;
auction: {
cards: readonly Card[];
index: number;
bidder: string | null;
bid: number;
} | null;
sale: {
winner: string;
amount: number;
free: boolean;
origin: 'normal' | 'cache' | 'blackMarket';
seller: string | null;
} | null;
players: readonly {
id: string;
hand: readonly Card[];
handLimit: number;
}[];
/** Live ownership only: delivered auction descriptors are receipt aliases. */
physicalCards: readonly Card[];
};
export type IxianReplacementSource = {
version: 1;
event: string;
buyer: string;
turn: number;
sequence: number;
auctionIndex: number;
amount: number;
free: boolean;
card: Card;
parent: string;
signature: string;
};
export type IxianReplacementCursor = { sequence: number; signature: string };
export type IxianReplacementReceipt = {
source: IxianReplacementSource;
outcome: 'pass' | 'use';
replacement: Card | null;
previous: string;
signature: string;
};
const canonical: Readonly<Record<string, Card>> =
Object.fromEntries(baseDeck().map(card => [card.id, card]));
const genesis = 'ixian-replacement:0';
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;
const whole = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
const array = (value: unknown): boolean => Array.isArray(value);
function requireReplacement(value: unknown, message: string): asserts value {
if (!value) throw new IxianReplacementError(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 canonicalCard(value: unknown): value is Card {
if (!plain(value) || !text(value.id)) return false;
const expected = Object.hasOwn(canonical, value.id) ? canonical[value.id] : undefined;
return !!expected &&
exact(value, ['id', 'name', 'kind', ...(expected.effect === undefined ? [] : ['effect'])]) &&
value.name === expected.name && value.kind === expected.kind &&
value.effect === expected.effect;
}
function sameCard(one: Card, two: Card): boolean {
return one.id === two.id && one.name === two.name &&
one.kind === two.kind && one.effect === two.effect;
}
function cardTuple(card: Card): unknown[] {
return [card.id, card.name, card.kind, card.effect ?? null];
}
/** Public identity contains no hidden face, price, parent or deck order. */
export function ixianReplacementEvent(
turn: number, buyer: string, sequence: number, auctionIndex: number,
): string {
return JSON.stringify(['ixianReplacement', turn, buyer, sequence, auctionIndex]);
}
function sourceSignature(source: IxianReplacementSource): string {
return JSON.stringify([
'ixianReplacementSource', source.version, source.event, source.buyer,
source.turn, source.sequence, source.auctionIndex, source.amount,
source.free, cardTuple(source.card), source.parent,
]);
}
function savedSource(value: unknown): value is IxianReplacementSource {
return exact(value, [
'version', 'event', 'buyer', 'turn', 'sequence', 'auctionIndex',
'amount', 'free', 'card', 'parent', 'signature',
]) && value.version === 1 && text(value.event) && text(value.buyer) &&
whole(value.turn) && value.turn > 0 && whole(value.sequence) &&
whole(value.auctionIndex) && whole(value.amount) &&
typeof value.free === 'boolean' && (value.free || value.amount > 0) &&
canonicalCard(value.card) &&
text(value.parent) && text(value.signature);
}
function validateSavedSource(value: unknown): asserts value is IxianReplacementSource {
requireReplacement(savedSource(value), 'The replacement lost its canonical normal purchase receipt.');
requireReplacement(
value.event === ixianReplacementEvent(value.turn, value.buyer, value.sequence, value.auctionIndex) &&
value.signature === sourceSignature(value),
'The replacement purchase receipt changed its original source.',
);
}
function validatePhysical(physical: readonly Card[]): void {
requireReplacement(
Array.isArray(physical) && physical.every(canonicalCard) &&
new Set(physical.map(card => card.id)).size === physical.length,
'Replacement requires canonical cards with single physical custody.',
);
}
/** Authorize the original paid/Karama NORMAL sale, not an arbitrary held card.
* The engine supplies its current source-specific parent and live custody census.
* Neither payment nor draw allocation is reconstructed here. */
export function validateIxianReplacementSource(
ctx: IxianReplacementContext, source: IxianReplacementSource, parent: string,
): void {
validateSavedSource(source);
requireReplacement(
ctx.status === 'playing' && ctx.phase === 3 && whole(ctx.turn) &&
source.turn === ctx.turn && whole(ctx.sequence) && source.sequence === ctx.sequence &&
text(parent) && source.parent === parent,
'The purchased card choice has lost its current parent or cursor.',
);
const auction = ctx.auction, sale = ctx.sale;
requireReplacement(
auction && sale && sale.origin === 'normal' && sale.seller === null &&
sale.winner === source.buyer && sale.amount === source.amount && sale.free === source.free &&
auction.bidder === source.buyer && auction.bid === source.amount &&
whole(auction.index) && auction.index === source.auctionIndex && Array.isArray(auction.cards) &&
canonicalCard(auction.cards[auction.index]) && sameCard(auction.cards[auction.index], source.card),
'Replacement must retain its exact original normal auction purchase.',
);
validatePhysical(ctx.physicalCards);
requireReplacement(
array(ctx.players) && ctx.players.every(player =>
plain(player) && text(player.id) && whole(player.handLimit) && player.handLimit > 0 &&
Array.isArray(player.hand) && player.hand.every(canonicalCard)) &&
new Set(ctx.players.map(player => player.id)).size === ctx.players.length,
'Replacement needs its original buyer and canonical hand custody.',
);
const buyer = ctx.players.find(player => player.id === source.buyer);
requireReplacement(
buyer && buyer.hand.length <= buyer.handLimit &&
buyer.hand.filter(card => card.id === source.card.id).length === 1 &&
buyer.hand.some(card => sameCard(card, source.card)),
'The exact purchased card must remain owned in a legal hand.',
);
const held = ctx.players.flatMap(player => player.hand);
requireReplacement(
new Set(held.map(card => card.id)).size === held.length &&
held.every(card => ctx.physicalCards.some(physical => sameCard(card, physical))),
'The purchased card and hands must retain unique physical custody.',
);
}
export function createIxianReplacementSource(
ctx: IxianReplacementContext, event: string, parent: string,
): IxianReplacementSource {
const auction = ctx.auction, sale = ctx.sale;
requireReplacement(
auction && sale && Array.isArray(auction.cards) && whole(auction.index) &&
canonicalCard(auction.cards[auction.index]),
'Replacement needs an actual canonical purchased auction card.',
);
const source: IxianReplacementSource = {
version: 1, event, buyer: sale.winner, turn: ctx.turn, sequence: ctx.sequence,
auctionIndex: auction.index, amount: sale.amount, free: sale.free,
card: { ...auction.cards[auction.index] }, parent, signature: '',
};
source.signature = sourceSignature(source);
validateIxianReplacementSource(ctx, source, parent);
return source;
}
export function initialIxianReplacementCursor(): IxianReplacementCursor {
return { sequence: 0, signature: genesis };
}
function validateCursor(value: unknown): asserts value is IxianReplacementCursor {
requireReplacement(
exact(value, ['sequence', 'signature']) && whole(value.sequence) && text(value.signature) &&
(value.sequence !== 0 || value.signature === genesis),
'The replacement history lost its independent cursor.',
);
}
function receiptSignature(receipt: IxianReplacementReceipt): string {
return JSON.stringify([
'ixianReplacementReceipt', receipt.source.signature, receipt.outcome,
receipt.replacement === null ? null : cardTuple(receipt.replacement), receipt.previous,
]);
}
/** Link to one completed event, not a recursively serialized prior receipt. */
function receiptHead(receipt: IxianReplacementReceipt): string {
return JSON.stringify(['ixianReplacementHead', receipt.source.event,
receipt.outcome, receipt.replacement?.id ?? null]);
}
/** Close AFTER native discard/draw. Same-card redraw is legal after replenishment.
* No source/history object aliases are returned and no input is mutated. */
export function closeIxianReplacementSource(
source: IxianReplacementSource,
outcome: 'pass' | 'use',
replacement: Card | null,
previous: IxianReplacementCursor,
): { receipt: IxianReplacementReceipt; cursor: IxianReplacementCursor } {
validateSavedSource(source);
validateCursor(previous);
requireReplacement(
source.sequence === previous.sequence && previous.sequence < Number.MAX_SAFE_INTEGER &&
(outcome === 'pass' ? replacement === null : outcome === 'use' && canonicalCard(replacement)),
'The replacement cannot close twice or lose its actual draw outcome.',
);
const receipt: IxianReplacementReceipt = {
source: { ...source, card: { ...source.card } }, outcome,
replacement: replacement === null ? null : { ...replacement },
previous: previous.signature, signature: '',
};
receipt.signature = receiptSignature(receipt);
return { receipt, cursor: { sequence: previous.sequence + 1, signature: receiptHead(receipt) } };
}
/** Historical evidence is independent of present-day card locations: cards may
* be transferred, discarded, recycled and purchased again in a later event.
* Signatures prove consistency, not authentication of arbitrarily rewritten saves. */
export function validateIxianReplacementHistory(
history: readonly IxianReplacementReceipt[],
cursor: IxianReplacementCursor,
physical?: readonly Card[],
): void {
validateCursor(cursor);
requireReplacement(array(history) && history.length === cursor.sequence,
'The replacement history has missing or duplicate closed choices.');
if (physical !== undefined) validatePhysical(physical);
let previous = genesis, turn = 0;
const events = new Set<string>();
for (const [index, receipt] of history.entries()) {
requireReplacement(
exact(receipt, ['source', 'outcome', 'replacement', 'previous', 'signature']),
'The replacement history has a malformed outcome.',
);
validateSavedSource(receipt.source);
requireReplacement(
receipt.source.sequence === index && receipt.source.turn >= turn &&
!events.has(receipt.source.event) && receipt.previous === previous &&
(receipt.outcome === 'pass' ? receipt.replacement === null :
receipt.outcome === 'use' && canonicalCard(receipt.replacement)) &&
receipt.signature === receiptSignature(receipt),
'The replacement history changed its purchase, outcome or continuation chain.',
);
events.add(receipt.source.event);
previous = receiptHead(receipt);
turn = receipt.source.turn;
}
requireReplacement(cursor.signature === previous,
'The replacement history no longer matches its closed-choice cursor.');
}
+34 -2
View File
@@ -1654,7 +1654,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'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 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'],
related: ['bidding', 'setup', 'ix-forces', 'nexus-ixian-replacement'],
},
{
id: 'mobile-stronghold',
@@ -4180,6 +4180,37 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Verification', status: 'Partial', detail: '147 selected offline cases and 6,114 full offline checks cover four native adapters, funded own refunds, original escrow, full low-Junction override, face-neutral acknowledgement, four legal bot profiles, actual handlers, authenticated restart/races and independent native-grant/orphan rejection. Eight real backed-up CLI entries and 390-pixel controls prove own/other/escrow/Guild-return/Homeworld/Junction Use, all-pass, free Fremen and refresh with exact saved force/card/spice custody. Local HTTP 48/55 has five 503s and two timeouts; broader and deployed acceptance remain open.', evidence: ['tests/nexus-guild-betrayal.test.ts', 'tests/nexus-guild-betrayal-engine.test.ts', 'tests/nexus-guild-betrayal-controls.test.tsx', 'tests/bot-nexus-guild-betrayal.test.ts', 'tests/nexus-guild-betrayal-recovery.test.ts', 'docs/NEXUS_GUILD_RULES.md'] },
],
},
{
id: 'nexus-ixian-replacement',
title: 'Ixian Nexus Secret Ally: replace the just-purchased card',
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Verified',
summary: 'In the fresh local ixian-replacement preview, an unallied holder with Ixians absent may spend the physical Ixian Nexus to discard exactly the card just bought in a normal auction and draw the next Treachery deck card privately.',
searchText: 'Ixian Nexus Secret Ally purchased card replacement normal auction Karama full hand empty deck same card',
steps: [
'This is a fresh local development profile, not a public Nexus start or an upgrade to saved games. It admits two to six unique classic-faction seats in Basic or Advanced with the base Treachery deck and Nexus alone. Native Ixians, expansion factions/decks, Leader Skills, Homeworlds, Semuta, Discoveries, Ecaz variants and other optional overlays stay outside the boundary.',
'Nexus has no starting deal. A draw requires a qualifying closing Nexus with an alliance. A two-seat profile can continue its base game but cannot naturally create an unallied Secret Ally holder; actual replacement needs at least three seats, or four for two simultaneous unallied recipients.',
'The photographed publisher face grants an independent Secret Ally operation when Ixians are absent. Remain unallied and hold the actual singleton Ixian Nexus. Native Ixian allied replacement is a different cancelable advantage and is not removed or used as a fallback. The adopted unofficial Advanced rulebook supplies relevant core rules, not this absent Nexus face or a new Nexus ruling.',
'Wait for a genuine normal-auction purchase, paid with spice or through an actual printed Karama purchase, including direct printed acquisition before a positive bid. Native Bene Gesserit Worthless/Truthtrance substitutions continue unchanged without this bounded offer; that producer limit is not a ruling that they are not purchases. A Harkonnen buyer and cache, Black Market or special-purchase origins are guarded pending bonus priority and independent Nexus cache-scope rulings. A Harkonnen rival remains allowed. Gifts, bonus draws, Technology swaps and old hand cards are not eligible purchases.',
'Every publicly unallied supported buyer with public held-Nexus presence receives the same neutral Purchased card choice, irrespective of the secret face. An irrelevant-face buyer may Pass but cannot Use. This is an inferred development privacy convention, not publisher-prescribed timing or a general resolution of pending anytime and response-priority questions.',
'Only the buyer may inspect its already-owned purchased card in this choice. You may inspect your own actually held Nexus through the existing authorized inspector; the prompt does not identify anybody else’s Nexus. Rivals receive no purchased or replacement face, private price, receipt, deck order or eligibility reason. Earlier Atreides auction inspection does not grant a peek of the replacement.',
'Pass keeps your purchased card and Nexus and closes this purchase opportunity. Use spends your physical Ixian Nexus once and discards exactly that just-purchased Treachery Card before drawing the actual next deck card. You cannot select another held card, replacement face, price or recipient; both actions bind only the offered event.',
'A full legal hand can replace because the discard opens its slot first. If the draw deck is empty, recycle eligible Treachery discards including the card just discarded: the same physical card can legally return. The unsold auction row is not the draw deck, and no different-card guarantee or new auction is created.',
'Keep the original payment, funded contribution and free/paid source. There is no refund or second debit. Original income and the native auction continuation finish once after the choice, preserving existing response and decision priority. Other lot, card, transfer and payment actions cannot bypass the pending source lock; independent own-seat autopilot changes remain available.',
'Refresh restores the same pending event or closed outcome without another payment, draw, shuffle or Nexus spend. A later genuine purchase may create a new event; closed, stale, foreign or malformed events cannot reopen an old one. Later transfers and reshuffles do not invalidate history merely because the old physical card has moved again. Receipt links are nonrecursive so saved history stays linear across retained purchases.',
'Easy, Medium, Hard and Brutal use their own projected legal Use/Pass choices. These are minimal legal policies, not calibrated Nexus strategy. Frozen native, private controls, actual local CLI/phone and authenticated recovery evidence verifies only this bounded path. Full faction/module games, wider compositions, public starts and deployed acceptance remain separate gates.',
],
example: 'You buy a card for four spice and inspect it privately. Pass keeps it and the Nexus. Use keeps the original four-spice purchase settled, spends Ixian Nexus, discards that exact card and draws the next deck card. If the draw deck is empty, its new discard joins the actual recycled pile and can be drawn again.',
related: ['nexus-cards', 'bidding', 'ix-technology', 'card-karama', 'alliance-funding', 'privacy', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'The fresh bounded profile binds the exact canonical normal paid/printed Karama purchase, physical replacement and closed-event history to native payment/discard/draw/auction continuation. Harkonnen buyers, special origins and combined modules remain guarded.', evidence: ['game/nexus-ixian-replacement.ts', 'game/engine.ts', 'tools/prototype-room.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Neutral Purchased card choice separates buyer-only purchased-card inspection and legal Use/Pass from public waiting; own-held Nexus inspection grants no rival entitlement. Broader presentation acceptance remains open.', evidence: ['components/nexus-ixian-replacement.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four minimal legal policies consume only their own projected event and eligibility while the underlying auction waits. Full strategy and difficulty calibration remain unfinished.', evidence: ['game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Photographed face, exact physical target, full-hand/empty-deck rules, original settlement once, privacy convention and unresolved cache/Harkonnen/priority boundaries are recorded without PDF Nexus authority or full-family claims.', evidence: ['docs/NEXUS_IXIAN_REPLACEMENT_RULES.md', 'docs/NEXUS_CARD_RULES.md', 'docs/RULE_DECISIONS.md'] },
{ 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-cards',
title: 'Nexus Cards',
@@ -4204,6 +4235,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'The Harkonnen exchange spends the Nexus Card when you draw and shuffles the returned cards into the Traitor Deck after your selection. Your original hand size is restored. Other players see that the exchange is pending, without learning your drawn or returned identities.',
'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.',
'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.',
@@ -4234,7 +4266,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'],
related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal', 'nexus-guild-betrayal', 'nexus-ixian-replacement'],
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'] },
+1
View File
@@ -67,6 +67,7 @@ const DECISIONS = {
capturedLeader: true,
guildTiming: true,
auctionPayment: true,
nexusIxianReplacement: true,
advisor: true,
intrusion: true,
advisorBattle: true,
+2 -1
View File
@@ -12,7 +12,7 @@ type TurnView = Pick<
| 'phaseOpening'
| 'automaticContinuationPending'
> &
Partial<Pick<GameView, 'biddingEnd' | 'kullReaction' | 'richeseBetrayalReaction' | 'guildBetrayalReaction'>>;
Partial<Pick<GameView, 'biddingEnd' | 'kullReaction' | 'richeseBetrayalReaction' | 'guildBetrayalReaction' | 'nexusIxianReplacement'>>;
/** 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,7 @@ export function tableActionOwner(g: TurnView): string | null {
return null;
const seated = (id: string | null | undefined) =>
g.players.some((p) => p.id === id) ? id! : null;
if (g.nexusIxianReplacement) return seated(g.nexusIxianReplacement.buyer);
if (g.decision) return seated(g.decision.player);
if (g.biddingEnd) {
const pending = g.biddingEnd.owners.filter(