Retain selected decks and original optional modules through normal setup, distinguish native Ixian Betrayal from absent-faction replacement, route explicit CHOAM fuel before printed effects, and preserve native No-Field invoices and pair continuation. Connect canonical native cancellation stock and existing human/minimal AI consumers. Validation: 194 affected tests across 19 files, types/lint/build, bounded native invoice review and seven finite original-action/rendered-control programmes. Runtime ec5ad6c18996d8d958ec0d01132ef3e3ea6d23d36188902eca9a63d4db2efbf8; documentation-only proof overlay a5892004937434429916496497c07a3838a324b4a5d64d4a0eb804ab5212d5bd. Other envelopes, full combinations, comprehensive assurance and deployed acceptance remain open. No saved-room/provider changes.
727 lines
34 KiB
TypeScript
727 lines
34 KiB
TypeScript
import { FACTIONS, type FactionId } from './catalog';
|
||
import { handLimit, type Game, type Player } from './engine';
|
||
import { emperorNexusEvent, emperorNexusLegacyRevivalModeSupported } from './nexus-emperor-secret-ally';
|
||
import { nexusCardMode, validateNexusCards } from './nexus-cards';
|
||
import { validAmbassadorResume } from './ambassador-resume';
|
||
import { ambassadorPhaseAllowed } from './ambassador-phase';
|
||
import {
|
||
pairedNexusModulesProfile, singleE1E2NexusProfile,
|
||
mixedE1E2NexusProfile, mixedE3NexusProfile, standaloneE3NexusProfile, pairedE3NexusProfile,
|
||
} from './nexus-module-profile';
|
||
import {
|
||
pairedNexusLeaderSkillsProfile,
|
||
singleE1E2NexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile,
|
||
mixedE3NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile,
|
||
pairedE3NexusLeaderSkillsProfile,
|
||
} from './leader-skill-profile';
|
||
import type { RicheseAuction } from './richese-auction';
|
||
import { quoteRicheseSettlement, type RicheseSettlementQuote } from './richese-settlement';
|
||
import { quoteRicheseBetrayalInvoice } from './nexus-richese-betrayal';
|
||
import { RICHESE_CARD_DEFINITIONS, richeseCardDefinition } from './richese-cards';
|
||
import { nexusCoreProfile } from './faction-module-profile';
|
||
|
||
export type EmperorNexusBankPurchase = {
|
||
kind: 'purchase';
|
||
event: string;
|
||
owner: string;
|
||
turn: number;
|
||
phase: 3;
|
||
sequence: number;
|
||
faction: FactionId;
|
||
advanced: boolean;
|
||
price: number;
|
||
auctionIndex: number;
|
||
card: string;
|
||
beforeSpice: number;
|
||
afterSpice: number;
|
||
signature: string;
|
||
};
|
||
|
||
export type EmperorNexusSellerPurchase = Omit<EmperorNexusBankPurchase, 'auctionIndex'> & {
|
||
version: 2;
|
||
origin: 'cache' | 'blackMarket';
|
||
seller: string;
|
||
round: string;
|
||
lot: string;
|
||
};
|
||
export type EmperorNexusAmbassadorPurchase = {
|
||
version: 3;
|
||
kind: 'purchase';
|
||
event: string;
|
||
owner: string;
|
||
turn: number;
|
||
/** Actual original entry phase, including source-bound Homeworld arrivals. */
|
||
phase: number;
|
||
sequence: number;
|
||
faction: FactionId;
|
||
advanced: boolean;
|
||
origin: 'ambassador';
|
||
price: 3;
|
||
card: string;
|
||
beforeSpice: number;
|
||
afterSpice: number;
|
||
signature: string;
|
||
entry: string;
|
||
token: string;
|
||
entrant: string;
|
||
};
|
||
export type EmperorNexusSpecialPurchase = {
|
||
version: 4;
|
||
kind: 'purchase';
|
||
event: string;
|
||
owner: string;
|
||
turn: number;
|
||
phase: number;
|
||
sequence: number;
|
||
faction: 'richese';
|
||
advanced: true;
|
||
origin: 'specialKarama';
|
||
price: 3;
|
||
card: string;
|
||
activation: string;
|
||
beforeSpice: number;
|
||
afterSpice: number;
|
||
signature: string;
|
||
};
|
||
export type EmperorNexusPurchase =
|
||
| EmperorNexusBankPurchase
|
||
| EmperorNexusSellerPurchase
|
||
| EmperorNexusAmbassadorPurchase
|
||
| EmperorNexusSpecialPurchase;
|
||
export type EmperorNexusSellerPurchaseInput = {
|
||
round: string;
|
||
lot: RicheseAuction;
|
||
quote: Extract<RicheseSettlementQuote, { kind: 'sold' }>;
|
||
bank: boolean;
|
||
};
|
||
|
||
/** Original public source identity, never the cache/Black Market card face. */
|
||
export function emperorNexusSellerPurchaseEvent(
|
||
turn: number, owner: string, sequence: number, round: string, lot: string,
|
||
source: 'cache' | 'blackMarket', price: number,
|
||
): string {
|
||
return JSON.stringify(['nexusEmperorSellerPurchase', turn, owner, sequence, round, lot, source, price]);
|
||
}
|
||
|
||
/** Bind an offer to its public auction position and final bid, never its hidden card face. */
|
||
export function emperorNexusPurchaseEvent(
|
||
turn: number, owner: string, sequence: number, auctionIndex: number, price: number,
|
||
): string {
|
||
return `${emperorNexusEvent(turn, 3, owner, sequence)}:${auctionIndex}:${price}`;
|
||
}
|
||
|
||
export class EmperorNexusPurchaseError extends Error {
|
||
constructor(message: string) {
|
||
super(message);
|
||
this.name = 'EmperorNexusPurchaseError';
|
||
}
|
||
}
|
||
|
||
/** Only a personally affordable, bank-paid winning bid can be reimbursed. */
|
||
export function quoteEmperorNexusPurchase(
|
||
price: number,
|
||
ownSpice: number,
|
||
allyPayment: number,
|
||
): void {
|
||
if (
|
||
!Number.isSafeInteger(price) ||
|
||
price < 1 ||
|
||
!Number.isSafeInteger(ownSpice) ||
|
||
ownSpice < price ||
|
||
allyPayment !== 0
|
||
)
|
||
throw new EmperorNexusPurchaseError(
|
||
'Emperor Nexus purchase requires a positive whole bid paid entirely from the winner\'s own spice.',
|
||
);
|
||
}
|
||
|
||
/** Bind a retained price to the original consumed Ambassador entry and its
|
||
* purchase sequence, never to the privately drawn card face. */
|
||
export function emperorNexusAmbassadorPurchaseEvent(
|
||
turn: number, phase: number, owner: string, sequence: number, entry: string,
|
||
): string {
|
||
return JSON.stringify(['nexusEmperorAmbassadorPurchase', turn, phase, owner, sequence, entry]);
|
||
}
|
||
|
||
/** Native once-per-game source identity; neither privately selected card face
|
||
* belongs in the offer shown before the original special purchase commits. */
|
||
export function emperorNexusSpecialPurchaseEvent(
|
||
turn: number, phase: number, owner: string, sequence: number,
|
||
): string {
|
||
return JSON.stringify([
|
||
'nexusEmperorSpecialPurchase', turn, phase, owner, sequence, 'richeseSpecialKarama',
|
||
]);
|
||
}
|
||
|
||
/** Consistency signature, not cryptographic authentication; field order is fixed. */
|
||
export function signEmperorNexusPurchase(record: EmperorNexusPurchase): string {
|
||
if ('version' in record && record.version === 4)
|
||
return JSON.stringify([
|
||
'emperorNexusSpecialPurchase', record.version, record.kind, record.event,
|
||
record.owner, record.turn, record.phase, record.sequence, record.faction,
|
||
record.advanced, record.origin, record.price, record.card, record.activation,
|
||
record.beforeSpice, record.afterSpice,
|
||
]);
|
||
if ('version' in record && record.version === 3)
|
||
return JSON.stringify([
|
||
'emperorNexusAmbassadorPurchase', record.version, record.kind, record.event,
|
||
record.owner, record.turn, record.phase, record.sequence, record.faction,
|
||
record.advanced, record.origin, record.price, record.card, record.beforeSpice,
|
||
record.afterSpice, record.entry, record.token, record.entrant,
|
||
]);
|
||
if ('version' in record)
|
||
return JSON.stringify([
|
||
'emperorNexusSellerPurchase', record.version, record.kind, record.event,
|
||
record.owner, record.turn, record.phase, record.sequence, record.faction,
|
||
record.advanced, record.origin, record.seller, record.round, record.lot,
|
||
record.price, record.card, record.beforeSpice, record.afterSpice,
|
||
]);
|
||
return JSON.stringify([
|
||
'emperorNexusPurchase',
|
||
record.kind,
|
||
record.event,
|
||
record.owner,
|
||
record.turn,
|
||
record.phase,
|
||
record.sequence,
|
||
record.faction,
|
||
record.advanced,
|
||
record.price,
|
||
record.auctionIndex,
|
||
record.card,
|
||
record.beforeSpice,
|
||
record.afterSpice,
|
||
]);
|
||
}
|
||
|
||
const whole = (value: unknown): value is number =>
|
||
Number.isSafeInteger(value) && (value as number) >= 0;
|
||
const text = (value: unknown): value is string =>
|
||
typeof value === 'string' && value.trim().length > 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));
|
||
|
||
/** Historical purchase evidence survives subsequent auctions and card transfers. */
|
||
export function validateEmperorNexusPurchase(
|
||
context: {
|
||
turn: number;
|
||
advanced: boolean;
|
||
players: readonly { id: string; faction: FactionId }[];
|
||
},
|
||
record: EmperorNexusPurchase,
|
||
): void {
|
||
const validContext =
|
||
context &&
|
||
whole(context.turn) &&
|
||
context.turn > 0 &&
|
||
typeof context.advanced === 'boolean' &&
|
||
Array.isArray(context.players) &&
|
||
context.players.length > 0 &&
|
||
context.players.every(
|
||
(player) =>
|
||
player &&
|
||
text(player.id) &&
|
||
FACTIONS.some((faction) => faction.id === player.faction),
|
||
) &&
|
||
new Set(context.players.map((player) => player.id)).size ===
|
||
context.players.length &&
|
||
new Set(context.players.map((player) => player.faction)).size ===
|
||
context.players.length;
|
||
if (plain(record) && 'version' in record && record.version === 4) {
|
||
if (
|
||
!validContext ||
|
||
Object.keys(record).sort().join(',') !==
|
||
'activation,advanced,afterSpice,beforeSpice,card,event,faction,kind,origin,owner,phase,price,sequence,signature,turn,version' ||
|
||
record.kind !== 'purchase' || record.origin !== 'specialKarama' ||
|
||
record.faction !== 'richese' || record.advanced !== true ||
|
||
record.advanced !== context.advanced ||
|
||
context.players.some(player => player.faction === 'emperor') ||
|
||
context.players.find(player => player.id === record.owner)?.faction !== 'richese' ||
|
||
!whole(record.turn) || record.turn < 1 || record.turn > context.turn ||
|
||
!whole(record.phase) || record.phase < 1 || record.phase > 8 ||
|
||
!whole(record.sequence) || !text(record.owner) ||
|
||
!text(record.activation) || record.activation === record.card ||
|
||
!RICHESE_CARD_DEFINITIONS.some(({ card }) => card.id === record.card) ||
|
||
record.price !== 3 ||
|
||
!whole(record.beforeSpice) || record.beforeSpice < record.price ||
|
||
!whole(record.afterSpice) || record.afterSpice !== record.beforeSpice ||
|
||
record.event !== emperorNexusSpecialPurchaseEvent(
|
||
record.turn, record.phase, record.owner, record.sequence,
|
||
) ||
|
||
record.signature !== signEmperorNexusPurchase(record)
|
||
)
|
||
throw new EmperorNexusPurchaseError(
|
||
'Emperor Nexus special purchase has lost its original Richese source, activation, price or retained spice.',
|
||
);
|
||
return;
|
||
}
|
||
if (plain(record) && 'version' in record && record.version === 3) {
|
||
if (
|
||
!validContext ||
|
||
Object.keys(record).sort().join(',') !==
|
||
'advanced,afterSpice,beforeSpice,card,entrant,entry,event,faction,kind,origin,owner,phase,price,sequence,signature,token,turn,version' ||
|
||
record.kind !== 'purchase' || record.origin !== 'ambassador' ||
|
||
record.price !== 3 || !whole(record.phase) || record.phase < 1 || record.phase > 8 ||
|
||
!whole(record.turn) || record.turn < 1 || record.turn > context.turn ||
|
||
!whole(record.sequence) ||
|
||
!text(record.owner) || !text(record.card) || !text(record.entry) ||
|
||
!text(record.token) || !text(record.entrant) ||
|
||
record.entrant === record.owner ||
|
||
!context.players.some(player => player.id === record.entrant) ||
|
||
context.players.find(player => player.id === record.owner)?.faction !== record.faction ||
|
||
record.advanced !== context.advanced ||
|
||
!whole(record.beforeSpice) || record.beforeSpice < record.price ||
|
||
!whole(record.afterSpice) || record.afterSpice !== record.beforeSpice ||
|
||
record.event !== emperorNexusAmbassadorPurchaseEvent(
|
||
record.turn, record.phase, record.owner, record.sequence, record.entry,
|
||
) ||
|
||
record.signature !== signEmperorNexusPurchase(record)
|
||
)
|
||
throw new EmperorNexusPurchaseError(
|
||
'Emperor Nexus Ambassador purchase has lost its original trigger, buyer, price or retained spice.',
|
||
);
|
||
return;
|
||
}
|
||
if (plain(record) && 'version' in record && record.version === 2) {
|
||
if (
|
||
!validContext ||
|
||
Object.keys(record).sort().join(',') !==
|
||
'advanced,afterSpice,beforeSpice,card,event,faction,kind,lot,origin,owner,phase,price,round,seller,sequence,signature,turn,version' ||
|
||
record.kind !== 'purchase' || record.phase !== 3 ||
|
||
!whole(record.turn) || record.turn < 1 || record.turn > context.turn ||
|
||
!whole(record.sequence) || !text(record.owner) || !text(record.card) ||
|
||
!text(record.round) || !text(record.lot) || !text(record.seller) ||
|
||
!['cache', 'blackMarket'].includes(record.origin) ||
|
||
context.players.some(player => player.faction === 'emperor') ||
|
||
context.players.find(player => player.id === record.seller)?.faction !== 'richese' ||
|
||
context.players.find(player => player.id === record.owner)?.faction !== record.faction ||
|
||
(record.origin === 'blackMarket' && (!record.advanced || record.seller === record.owner)) ||
|
||
record.advanced !== context.advanced ||
|
||
!whole(record.price) || record.price < 1 ||
|
||
!whole(record.beforeSpice) || record.beforeSpice < record.price ||
|
||
!whole(record.afterSpice) || record.afterSpice !== record.beforeSpice ||
|
||
record.event !== emperorNexusSellerPurchaseEvent(
|
||
record.turn, record.owner, record.sequence, record.round, record.lot, record.origin, record.price,
|
||
) ||
|
||
record.signature !== signEmperorNexusPurchase(record)
|
||
)
|
||
throw new EmperorNexusPurchaseError(
|
||
'Emperor Nexus seller purchase has lost its original source, buyer, price or retained spice.',
|
||
);
|
||
return;
|
||
}
|
||
if (
|
||
!validContext ||
|
||
!plain(record) ||
|
||
'version' in record ||
|
||
!('auctionIndex' in record) ||
|
||
Object.keys(record).sort().join(',') !==
|
||
'advanced,afterSpice,auctionIndex,beforeSpice,card,event,faction,kind,owner,phase,price,sequence,signature,turn' ||
|
||
record.kind !== 'purchase' ||
|
||
record.phase !== 3 ||
|
||
!whole(record.turn) ||
|
||
record.turn < 1 ||
|
||
record.turn > context.turn ||
|
||
!whole(record.sequence) ||
|
||
!text(record.owner) ||
|
||
!text(record.card) ||
|
||
context.players.find((player) => player.id === record.owner)?.faction !==
|
||
record.faction ||
|
||
record.advanced !== context.advanced ||
|
||
record.event !==
|
||
emperorNexusPurchaseEvent(record.turn, record.owner, record.sequence, record.auctionIndex, record.price) ||
|
||
!whole(record.auctionIndex) ||
|
||
!whole(record.beforeSpice) ||
|
||
!whole(record.afterSpice) ||
|
||
!whole(record.price) ||
|
||
record.price < 1 ||
|
||
record.beforeSpice < record.price ||
|
||
record.afterSpice !== record.beforeSpice ||
|
||
record.signature !== signEmperorNexusPurchase(record)
|
||
)
|
||
throw new EmperorNexusPurchaseError(
|
||
'Emperor Nexus purchase has lost its original bidder, paid price or full reimbursement.',
|
||
);
|
||
}
|
||
|
||
|
||
/** Genuine native Nexus envelopes only; the canonical native profiles own their
|
||
* roster and delegated Discovery/Homeworld/Tech/Stronghold envelopes, and the
|
||
* separately flagged preview aliases never open one. */
|
||
function nativeBankNexusProfile(g: Game): boolean {
|
||
if (g.semutaPreview || g.advancedPreview || g.kullPreview || g.nexusKullPreview ||
|
||
g.guildBetrayalPreview || g.richeseBetrayalPreview ||
|
||
g.nexusIxianReplacementPreview || g.nexusIxianBetrayalPreview ||
|
||
g.nexusHarkonnenBetrayalPreview) return false;
|
||
if (g.leaderSkills)
|
||
return pairedNexusLeaderSkillsProfile(g) || singleE1E2NexusLeaderSkillsProfile(g) ||
|
||
mixedE1E2NexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g) ||
|
||
pairedE3NexusLeaderSkillsProfile(g) || mixedE3NexusLeaderSkillsProfile(g);
|
||
if (pairedNexusModulesProfile(g) || singleE1E2NexusProfile(g) ||
|
||
mixedE1E2NexusProfile(g) || standaloneE3NexusProfile(g) ||
|
||
pairedE3NexusProfile(g) || mixedE3NexusProfile(g)) return true;
|
||
// Bare paired E1/E2 Nexus without Tech or Strongholds.
|
||
if (g.techTokens || g.strongholdCards || g.discoveryEnabled || g.discoveries ||
|
||
g.discoveryStash || g.greatMaker || g.players.length < 2 || g.players.length > 6)
|
||
return false;
|
||
for (let i = 0; i < g.players.length; i++)
|
||
for (let j = 0; j < i; j++)
|
||
if (g.players[j].faction === g.players[i].faction) return false;
|
||
if (g.expansions.length !== 1 || !['ix', 'choam'].includes(g.expansions[0])) return false;
|
||
const expansion = g.expansions[0];
|
||
return g.players.every(p => FACTIONS.some(f => f.id === p.faction &&
|
||
(f.expansion === 'base' || f.expansion === expansion))) &&
|
||
g.players.filter(p => FACTIONS.some(f => f.id === p.faction && f.expansion === expansion)).length === 2;
|
||
}
|
||
|
||
/** Normal bank-auction admission only: a supported original native Nexus roster
|
||
* with the Emperor absent, the concealed face and any buyer wallet or hand left
|
||
* to the caller. */
|
||
export function emperorNexusBankPurchaseModeSupported(g: Game): boolean {
|
||
if (nexusCoreProfile(g))
|
||
return !g.players.some(p => p.faction === 'emperor') && !g.sandtrout;
|
||
if (!g.nexusCards || g.players.some(p => p.faction === 'emperor') ||
|
||
g.ecazTreachery || g.sandtrout) return false;
|
||
return emperorNexusLegacyRevivalModeSupported(g) || nativeBankNexusProfile(g);
|
||
}
|
||
|
||
/** Reuse native Nexus profiles; the existing Richese Betrayal preview composes
|
||
* only on its original bare paired CHOAM/Richese roster, not arbitrary overlays. */
|
||
export function emperorNexusSellerPurchaseModeSupported(g: Game): boolean {
|
||
if (nexusCoreProfile(g))
|
||
return !g.players.some(p => p.faction === 'emperor') && g.players.some(p => p.faction === 'richese');
|
||
if (!g.nexusCards || g.players.some(p => p.faction === 'emperor') ||
|
||
!g.players.some(p => p.faction === 'richese') || g.ecazTreachery || g.sandtrout)
|
||
return false;
|
||
if (pairedNexusModulesProfile(g) || singleE1E2NexusProfile(g) ||
|
||
mixedE1E2NexusProfile(g) || mixedE3NexusProfile(g) ||
|
||
(!!g.leaderSkills && (pairedNexusLeaderSkillsProfile(g) ||
|
||
singleE1E2NexusLeaderSkillsProfile(g) || mixedE1E2NexusLeaderSkillsProfile(g) ||
|
||
mixedE3NexusLeaderSkillsProfile(g)))) return true;
|
||
return g.expansions.length === 1 && g.expansions[0] === 'choam' &&
|
||
g.players.length >= 2 && g.players.length <= 6 &&
|
||
new Set(g.players.map(p => p.faction)).size === g.players.length &&
|
||
g.players.every(p => FACTIONS.some(f => f.id === p.faction &&
|
||
(f.expansion === 'base' || f.expansion === 'choam'))) &&
|
||
(!g.richeseBetrayalPreview || g.players.some(p => p.faction === 'choam')) &&
|
||
!g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && !g.discoveries &&
|
||
!g.discoveryStash && !g.greatMaker && !g.techTokens && !g.strongholdCards &&
|
||
!g.semutaPreview && !g.advancedPreview && !g.kullPreview && !g.nexusKullPreview &&
|
||
!g.guildBetrayalPreview && !g.nexusIxianReplacementPreview &&
|
||
!g.nexusIxianBetrayalPreview && !g.nexusHarkonnenBetrayalPreview;
|
||
}
|
||
|
||
/** Pre-acquisition proof only. The caller owns timing, overlays and mutation;
|
||
* recompute native funding/capacity and the exact original physical source. */
|
||
export function quoteEmperorNexusSellerPurchase(
|
||
g: Game, input: EmperorNexusSellerPurchaseInput, owner: string,
|
||
): { event: string; price: number; seller: string; source: 'cache' | 'blackMarket'; blocked: string | null } {
|
||
try {
|
||
if (!plain(input) || Object.keys(input).sort().join(',') !== 'bank,lot,quote,round' ||
|
||
!text(input.round) || typeof input.bank !== 'boolean' ||
|
||
!plain(input.lot) || !plain(input.quote) ||
|
||
Object.keys(input.lot).sort().join(',') !==
|
||
'acted,active,bid,bidder,cardId,eligible,event,method,order,outcome,owner,passed,sealed,source,tieOrder' ||
|
||
Object.keys(input.quote).sort().join(',') !== 'allyPayment,amount,card,kind,ownPayment,winner' ||
|
||
input.quote.kind !== 'sold' ||
|
||
g.status !== 'playing' || g.phase !== 3 || !whole(g.turn) || g.turn < 1 ||
|
||
(input.lot.source === 'blackMarket' && !g.advanced) ||
|
||
!g.richeseBidding || g.richeseBidding.turn !== g.turn ||
|
||
g.richeseBidding.stage !== 'lot' || g.richeseBidding.event !== input.round ||
|
||
g.richeseBidding.owner !== input.lot.owner ||
|
||
(input.lot.source === 'cache'
|
||
? !['first', 'last'].includes(String(g.richeseBidding.position))
|
||
: g.richeseBidding.position !== null) ||
|
||
!g.richeseAuction || JSON.stringify(input.lot) !== JSON.stringify(g.richeseAuction) ||
|
||
g.auction || g.currentAuctionSale || input.quote.winner !== owner ||
|
||
input.lot.active !== null || input.lot.bidder !== owner ||
|
||
input.lot.bid !== input.quote.amount ||
|
||
(input.bank && (owner === input.lot.owner || input.quote.amount < 1)))
|
||
throw new EmperorNexusPurchaseError('Emperor Nexus purchase needs its unchanged original terminal Richese lot.');
|
||
const buyer = g.players.find(p => p.id === owner),
|
||
seller = g.players.find(p => p.id === input.lot.owner);
|
||
if (!buyer || seller?.faction !== 'richese')
|
||
throw new EmperorNexusPurchaseError('Emperor Nexus purchase lost its original buyer or Richese seller.');
|
||
const funding = g.richeseFunding?.[owner];
|
||
if (!plain(funding) || Object.keys(funding).sort().join(',') !== 'allyPayment,amount,donor' ||
|
||
funding.donor !== (input.quote.allyPayment ? buyer.ally : null))
|
||
throw new EmperorNexusPurchaseError('Emperor Nexus purchase lost its original funding commitment or donor.');
|
||
const sourceCards = input.lot.source === 'cache' ? g.richeseCache ?? [] : seller.hand;
|
||
const physicalCards = [
|
||
...g.players.flatMap(p => p.hand), ...g.deck, ...g.discard,
|
||
...(g.richeseCache ?? []), ...(g.richeseRemoved ?? []),
|
||
...(g.ixSetupCards ?? []), ...(g.ixAuction?.cards ?? []),
|
||
...(g.ornithopter ? [g.ornithopter.card] : []),
|
||
];
|
||
// This shared native source proof checks exact card shape/face, unique
|
||
// custody, cache identity and the Black Market self-purchase boundary.
|
||
quoteRicheseBetrayalInvoice({
|
||
players: g.players, lot: input.lot, quote: input.quote,
|
||
donor: input.quote.allyPayment ? buyer.ally : null,
|
||
originalRecipient: owner === seller.id
|
||
? g.players.find(p => p.faction === 'emperor')?.id ?? null : seller.id,
|
||
sourceCards, physicalCards,
|
||
});
|
||
const native = quoteRicheseSettlement({
|
||
lot: input.lot,
|
||
owner: { id: seller.id, handCount: seller.hand.length, handLimit: handLimit(seller) },
|
||
winner: { id: buyer.id, handCount: buyer.hand.length, handLimit: handLimit(buyer), spice: buyer.spice },
|
||
card: sourceCards.find(c => c.id === input.lot.cardId),
|
||
funding: g.richeseFunding?.[owner],
|
||
allyCredit: buyer.ally && g.aid[buyer.ally]?.recipient === owner ? g.aid[buyer.ally] : null,
|
||
});
|
||
if (native.kind !== 'sold' || native.amount !== input.quote.amount ||
|
||
native.winner !== input.quote.winner || native.ownPayment !== input.quote.ownPayment ||
|
||
native.allyPayment !== input.quote.allyPayment)
|
||
throw new EmperorNexusPurchaseError('Emperor Nexus purchase lost its exact original nominal price or funding split.');
|
||
let blocked: string | null = null;
|
||
if (!emperorNexusSellerPurchaseModeSupported(g))
|
||
blocked = 'Emperor Nexus seller purchase requires a supported original Richese Nexus profile with Emperor absent.';
|
||
else if (buyer.ally) blocked = 'Emperor Nexus Secret Ally requires an unallied buyer.';
|
||
else if (!g.nexusCards?.cards) blocked = 'Hold the real Emperor Nexus card to retain your purchase price.';
|
||
else {
|
||
validateNexusCards(g.nexusCards.cards, g.players);
|
||
if (g.nexusCards.cards.hands[owner] !== 'emperor' ||
|
||
nexusCardMode('emperor', buyer.faction, g.players.map(p => p.faction)) !== 'secretAlly')
|
||
blocked = 'Hold the real Emperor Nexus Secret Ally card to retain your purchase price.';
|
||
else {
|
||
try { quoteEmperorNexusPurchase(native.amount, buyer.spice, native.allyPayment); }
|
||
catch (error) {
|
||
if (!(error instanceof EmperorNexusPurchaseError)) throw error;
|
||
blocked = error.message;
|
||
}
|
||
}
|
||
}
|
||
return {
|
||
event: emperorNexusSellerPurchaseEvent(g.turn, owner, g.nexusEmperorPurchaseHistory?.length ?? 0,
|
||
input.round, input.lot.event, input.lot.source, native.amount),
|
||
price: native.amount, seller: seller.id, source: input.lot.source, blocked,
|
||
};
|
||
} catch (error) {
|
||
if (error instanceof EmperorNexusPurchaseError) throw error;
|
||
throw new EmperorNexusPurchaseError(error instanceof Error ? error.message : 'Malformed original Richese purchase.');
|
||
}
|
||
}
|
||
|
||
/** Prepare consumed-source evidence before payment/card movement. It records
|
||
* retained spice, not an actual paid invoice or a fabricated seller subsidy. */
|
||
export function createEmperorNexusSellerPurchase(
|
||
g: Game, input: EmperorNexusSellerPurchaseInput,
|
||
): EmperorNexusSellerPurchase {
|
||
if (!plain(input) || !plain(input.quote) || !text(input.quote.winner))
|
||
throw new EmperorNexusPurchaseError('Emperor Nexus purchase needs its original sold source and buyer.');
|
||
const quote = quoteEmperorNexusSellerPurchase(g, input, input.quote.winner);
|
||
if (quote.blocked) throw new EmperorNexusPurchaseError(quote.blocked);
|
||
const buyer = g.players.find(p => p.id === input.quote.winner)!;
|
||
const receipt: EmperorNexusSellerPurchase = {
|
||
version: 2, kind: 'purchase', event: quote.event, owner: buyer.id, turn: g.turn,
|
||
phase: 3, sequence: g.nexusEmperorPurchaseHistory?.length ?? 0,
|
||
faction: buyer.faction, advanced: g.advanced, origin: quote.source,
|
||
seller: quote.seller, round: input.round, lot: input.lot.event,
|
||
price: quote.price, card: input.quote.card.id,
|
||
beforeSpice: buyer.spice, afterSpice: buyer.spice, signature: '',
|
||
};
|
||
receipt.signature = signEmperorNexusPurchase(receipt);
|
||
validateEmperorNexusPurchase(g, receipt);
|
||
return receipt;
|
||
}
|
||
|
||
/** Public Emperor-absent Ecaz envelope only, reusing the original E3 Nexus and
|
||
* Skills profiles; it never inspects the concealed Nexus face or a hand. */
|
||
export function emperorNexusAmbassadorPurchaseModeSupported(g: Game): boolean {
|
||
if (!g.nexusCards || g.ecazTreachery || g.sandtrout ||
|
||
g.players.some(p => p.faction === 'emperor') ||
|
||
!g.players.some(p => p.faction === 'ecaz')) return false;
|
||
if (standaloneE3NexusProfile(g) || pairedE3NexusProfile(g) || mixedE3NexusProfile(g))
|
||
return true;
|
||
return !!g.leaderSkills && (standaloneE3NexusLeaderSkillsProfile(g) ||
|
||
pairedE3NexusLeaderSkillsProfile(g) || mixedE3NexusLeaderSkillsProfile(g));
|
||
}
|
||
|
||
type AmbassadorPurchaseSource = {
|
||
buyer: Player | null;
|
||
entry: NonNullable<Game['pendingAmbassador']> | null;
|
||
event: string;
|
||
blocked: string | null;
|
||
};
|
||
|
||
/** Original consumed-token evidence plus the buyer's private eligibility. The
|
||
* physical supply is skipped only for the card an already committed draw bound. */
|
||
function ambassadorPurchaseSource(g: Game, owner: string, supply: boolean): AmbassadorPurchaseSource {
|
||
const entry = g.pendingAmbassador ?? null;
|
||
const buyer = text(owner) ? g.players.find(p => p.id === owner) ?? null : null;
|
||
const token = entry && g.ecazAmbassadors?.tokens.find(t => t.id === entry.token);
|
||
const event = entry && text(entry.event) && text(owner)
|
||
? emperorNexusAmbassadorPurchaseEvent(g.turn, g.phase, owner,
|
||
g.nexusEmperorPurchaseHistory?.length ?? 0, entry.event)
|
||
: '';
|
||
let blocked: string | null = null;
|
||
if (!emperorNexusAmbassadorPurchaseModeSupported(g))
|
||
blocked = 'Emperor Nexus Ambassador purchase requires a supported native Ecaz Nexus profile with Emperor absent.';
|
||
else if (g.status !== 'playing')
|
||
blocked = 'Emperor Nexus Ambassador purchase needs the original game still in progress.';
|
||
else if (!entry || !text(entry.event) || !text(entry.token))
|
||
blocked = 'The original Richese Ambassador trigger is no longer current.';
|
||
else if (!buyer)
|
||
blocked = 'Emperor Nexus Ambassador purchase lost its original purchaser.';
|
||
else if (entry.effect !== 'richese')
|
||
blocked = 'The original Richese Ambassador effect is no longer active.';
|
||
else if (entry.beneficiary !== owner ||
|
||
entry.owner !== g.players.find(p => p.faction === 'ecaz')?.id)
|
||
blocked = 'The original Richese Ambassador purchase belongs to another Ecaz beneficiary.';
|
||
else if (entry.turn !== g.turn || entry.phase !== g.phase || !ambassadorPhaseAllowed(g))
|
||
blocked = 'The original Richese Ambassador trigger belongs to another turn or phase.';
|
||
else if (!validAmbassadorResume(g, entry))
|
||
blocked = 'The original Richese Ambassador trigger lost its recorded entry source.';
|
||
else if (entry.entrant === owner || !g.players.some(p => p.id === entry.entrant))
|
||
blocked = 'The original Richese Ambassador trigger lost its entering player.';
|
||
else if (!token || !((token.effect === 'richese' && token.zone === 'used') ||
|
||
(token.effect === 'beneGesserit' && token.zone === 'removed' &&
|
||
entry.copyChoices.includes('richese'))))
|
||
blocked = 'The original Richese Ambassador token is no longer consumed for this effect.';
|
||
if (blocked) throw new EmperorNexusPurchaseError(blocked);
|
||
if (buyer!.ally)
|
||
blocked = 'Emperor Nexus Secret Ally requires an unallied buyer.';
|
||
else if (!g.nexusCards?.cards)
|
||
blocked = 'Hold the real Emperor Nexus Secret Ally card to retain your purchase price.';
|
||
else {
|
||
try {
|
||
validateNexusCards(g.nexusCards.cards, g.players);
|
||
if (g.nexusCards.cards.hands[owner] !== 'emperor' ||
|
||
nexusCardMode('emperor', buyer!.faction, g.players.map(p => p.faction)) !== 'secretAlly')
|
||
blocked = 'Hold the real Emperor Nexus Secret Ally card to retain your purchase price.';
|
||
else if (buyer!.hand.length >= handLimit(buyer!))
|
||
blocked = 'The Richese Ambassador purchase needs room in the buyer’s hand.';
|
||
else if (!whole(buyer!.spice) || buyer!.spice < 3)
|
||
blocked = 'The Richese Ambassador purchase needs the three-spice price held in the buyer’s own spice.';
|
||
else if (supply && !g.deck.length && !g.discard.length)
|
||
blocked = 'No Treachery Card is available for the Ambassador purchase.';
|
||
} catch (error) {
|
||
blocked = error instanceof Error ? error.message
|
||
: 'Hold the real Emperor Nexus Secret Ally card to retain your purchase price.';
|
||
}
|
||
}
|
||
return { buyer, entry, event, blocked };
|
||
}
|
||
|
||
/** Pre-commit proof only. Private room, spice, supply and Nexus face disable
|
||
* Use without gating Pay; malformed original sources remain hard errors. */
|
||
export function quoteEmperorNexusAmbassadorPurchase(
|
||
g: Game, owner: string,
|
||
): { event: string; price: 3; blocked: string | null } {
|
||
const source = ambassadorPurchaseSource(g, owner, true);
|
||
return { event: source.event, price: 3, blocked: source.blocked };
|
||
}
|
||
|
||
/** Bind the original purchase to the card the engine actually drew before the
|
||
* buyer's hand changes. The draw already removed it from the deck, so absence
|
||
* from the physical stock is valid; a duplicate or an undrawn card is not. */
|
||
export function createEmperorNexusAmbassadorPurchase(
|
||
g: Game, owner: string, card: string,
|
||
): EmperorNexusAmbassadorPurchase {
|
||
if (!text(owner) || !text(card))
|
||
throw new EmperorNexusPurchaseError(
|
||
'Emperor Nexus Ambassador purchase needs its original buyer and drawn Treachery Card.',
|
||
);
|
||
const source = ambassadorPurchaseSource(g, owner, false);
|
||
if (source.blocked) throw new EmperorNexusPurchaseError(source.blocked);
|
||
const buyer = source.buyer!, entry = source.entry!;
|
||
const physical = [
|
||
...g.players.flatMap(p => p.hand), ...g.deck, ...g.discard,
|
||
...(g.richeseCache ?? []), ...(g.richeseRemoved ?? []),
|
||
...(g.ixSetupCards ?? []), ...(g.ixAuction?.cards ?? []),
|
||
...(g.ornithopter ? [g.ornithopter.card] : []),
|
||
].filter(c => c.id === card);
|
||
if (physical.length !== 0)
|
||
throw new EmperorNexusPurchaseError(
|
||
'Emperor Nexus Ambassador purchase lost its single physically drawn Treachery Card.',
|
||
);
|
||
const receipt: EmperorNexusAmbassadorPurchase = {
|
||
version: 3, kind: 'purchase', event: source.event, owner: buyer.id,
|
||
turn: g.turn, phase: g.phase, sequence: g.nexusEmperorPurchaseHistory?.length ?? 0,
|
||
faction: buyer.faction, advanced: g.advanced, origin: 'ambassador', price: 3,
|
||
card, beforeSpice: buyer.spice, afterSpice: buyer.spice, signature: '',
|
||
entry: entry.event, token: entry.token, entrant: entry.entrant,
|
||
};
|
||
receipt.signature = signEmperorNexusPurchase(receipt);
|
||
validateEmperorNexusPurchase(g, receipt);
|
||
return receipt;
|
||
}
|
||
|
||
/** The original special-purchase validator owns timing, uncommitted spice,
|
||
* capacity, once-per-game use and card roles. This is only Nexus entitlement. */
|
||
export function quoteEmperorNexusSpecialPurchase(
|
||
g: Game, owner: string,
|
||
): { event: string; price: 3; blocked: string | null } {
|
||
const buyer = text(owner) ? g.players.find(p => p.id === owner) : undefined;
|
||
let blocked: string | null = null;
|
||
if (!emperorNexusSellerPurchaseModeSupported(g))
|
||
blocked = 'Emperor Nexus special purchase requires a supported native Richese Nexus profile with Emperor absent.';
|
||
else if (g.status !== 'playing' || !whole(g.turn) || g.turn < 1 ||
|
||
!whole(g.phase) || g.phase < 1 || g.phase > 8)
|
||
blocked = 'Emperor Nexus special purchase needs the original game still in progress.';
|
||
else if (!g.advanced || buyer?.faction !== 'richese')
|
||
blocked = 'Only native Advanced Richese can retain its special Karama purchase price.';
|
||
else if (buyer.ally)
|
||
blocked = 'Emperor Nexus Secret Ally requires an unallied buyer.';
|
||
else if (!g.nexusCards?.cards)
|
||
blocked = 'Hold the real Emperor Nexus Secret Ally card to retain your purchase price.';
|
||
else {
|
||
try {
|
||
validateNexusCards(g.nexusCards.cards, g.players);
|
||
if (g.nexusCards.cards.hands[owner] !== 'emperor' ||
|
||
nexusCardMode('emperor', buyer.faction, g.players.map(p => p.faction)) !== 'secretAlly')
|
||
blocked = 'Hold the real Emperor Nexus Secret Ally card to retain your purchase price.';
|
||
else if (!whole(buyer.spice) || buyer.spice < 3)
|
||
blocked = 'The special Karama purchase needs the three-spice price held in Richese’s own spice.';
|
||
} catch (error) {
|
||
blocked = error instanceof Error ? error.message
|
||
: 'Hold the real Emperor Nexus Secret Ally card to retain your purchase price.';
|
||
}
|
||
}
|
||
return {
|
||
event: emperorNexusSpecialPurchaseEvent(
|
||
g.turn, g.phase, owner, g.nexusEmperorPurchaseHistory?.length ?? 0,
|
||
),
|
||
price: 3, blocked,
|
||
};
|
||
}
|
||
|
||
/** Called after original preparation, before either activation is discarded or
|
||
* the cache card moves. Bind both exact physical cards without paying/refunding. */
|
||
export function createEmperorNexusSpecialPurchase(
|
||
g: Game, owner: string, activation: string, acquire: string,
|
||
): EmperorNexusSpecialPurchase {
|
||
const quote = quoteEmperorNexusSpecialPurchase(g, owner);
|
||
if (quote.blocked) throw new EmperorNexusPurchaseError(quote.blocked);
|
||
const buyer = g.players.find(p => p.id === owner)!;
|
||
const chosen = g.richeseCache?.find(card => card.id === acquire);
|
||
const physical = [
|
||
...g.players.flatMap(p => p.hand), ...g.deck, ...g.discard,
|
||
...(g.richeseCache ?? []), ...(g.richeseRemoved ?? []),
|
||
...(g.ixSetupCards ?? []), ...(g.ixAuction?.cards ?? []),
|
||
...(g.ornithopter ? [g.ornithopter.card] : []),
|
||
];
|
||
if (!text(activation) || !text(acquire) || activation === acquire ||
|
||
!chosen || !richeseCardDefinition(chosen) ||
|
||
buyer.hand.filter(card => card.id === activation).length !== 1 ||
|
||
physical.filter(card => card.id === activation).length !== 1 ||
|
||
physical.filter(card => card.id === acquire).length !== 1)
|
||
throw new EmperorNexusPurchaseError(
|
||
'Emperor Nexus special purchase needs one exact native cache card and its single held Karama activation.',
|
||
);
|
||
const receipt: EmperorNexusSpecialPurchase = {
|
||
version: 4, kind: 'purchase', event: quote.event, owner: buyer.id,
|
||
turn: g.turn, phase: g.phase, sequence: g.nexusEmperorPurchaseHistory?.length ?? 0,
|
||
faction: 'richese', advanced: true, origin: 'specialKarama', price: 3,
|
||
card: acquire, activation, beforeSpice: buyer.spice, afterSpice: buyer.spice,
|
||
signature: '',
|
||
};
|
||
receipt.signature = signEmperorNexusPurchase(receipt);
|
||
validateEmperorNexusPurchase(g, receipt);
|
||
return receipt;
|
||
}
|