Connect Richese Nexus to Karama acquisitions

This commit is contained in:
Quintine committed 2026-10-25 23:14:17 +11:00
1 parent cf268243f3
commit 3bf7d26e80
18 files changed
+1513 -110

No files matched your search

+158 -17
View File
@@ -281,6 +281,7 @@ import {
quoteNormalAuctionBid,
quoteNormalAuctionNext,
} from './normal-auction';
import { quoteNormalAuctionRetirement, NormalAuctionRetirementError, type NormalAuctionRetirementQuote } from './normal-auction-retirement';
import { reorderSaphoCyclicAuction, SaphoCyclicAuctionError } from './sapho-cyclic-auction';
import {
quoteNormalAuctionPeek,
@@ -531,7 +532,7 @@ import { STORM_START_SECTOR, PLAYER_CIRCLE_SECTORS } from './player-positions';
import { cashInCards } from './choam-karama';
import { kullBlocksKarama, activeKullRestrictions, validateKullPhaseRestrictions, validateKullAttemptStamp, kullNativeCostCards, kullNexusCostCards, kullCounterCards, type KullPhaseRestriction } from './choam-kull';
import { kullCancellationPurpose, kullSpecialPurpose, kullPurposeLabel, type KullPurpose } from './kull-purpose';
import { quoteRicheseBetrayalInvoice, quoteRicheseNormalBetrayalInvoice, richeseBetrayalVetoesPurchase, requiredRicheseBetrayalResponders, richeseBetrayalEligibility, richeseBetrayalEvent, createRicheseBetrayalReceipt, validateRicheseBetrayalCursor, type RicheseBetrayalCursor, type RicheseBetrayalReceipt } from './nexus-richese-betrayal';
import { quoteRicheseBetrayalInvoice, quoteRicheseNormalBetrayalInvoice, quoteRicheseKaramaBetrayalInvoice, richeseBetrayalVetoesPurchase, requiredRicheseBetrayalResponders, richeseBetrayalEligibility, richeseBetrayalEvent, createRicheseBetrayalReceipt, validateRicheseBetrayalCursor, type RicheseBetrayalCursor, type RicheseBetrayalReceipt } from './nexus-richese-betrayal';
import { saleOptions, quoteSale, type ChoamMarket } from './choam-market';
import { highKaitainDiscardsAvailable, quoteKaitainDiscards, homeworldWorthlessSaleBlock } from './homeworld-card-economy';
import { biddingEndError, biddingEndQuiet, biddingEndPubliclyEmpty, type BiddingEnd } from './bidding-end';
@@ -1199,6 +1200,10 @@ type KaramaUse =
| { kind: 'shipment'; recipient: string; card?: string }
| { kind: 'purchase' }
| { kind: 'auctionPayment' };
type RichesePreCostDeclaration = { round: string; lot: string } & (
| { kind: 'ordinary'; owner: string; card: string; index: number }
| { kind: 'special'; intent: Extract<SpecialKaramaIntent, { kind: 'richese' }> }
);
type PendingHomeworldShipment = HomeworldShipmentIntent & {
guildSecretEvent?: string;
guildNexusEvent?: string;
@@ -1377,6 +1382,8 @@ export type Game = {
} | {
/** The actual unpaid ordinary pool, never a fabricated Richese cache lot. */
normal: { round: string; lot: string; index: number };
} | {
preCost: RichesePreCostDeclaration;
}) | null;
/** Original normal-auction obligation; neutral acknowledgements precede funding. */
nexusEmperorPayment?: { sequence: number; current: string | null; completed: EmperorPaymentReceipt[] };
@@ -12722,6 +12729,16 @@ function richeseBetrayalResponders(g: Game): string[] {
})));
}
function richeseBetrayalSourceSignature(g: Game, frame: NonNullable<Game['pendingRicheseBetrayal']>): string {
if ('preCost' in frame) return JSON.stringify({
event:frame.receipt.event,preCost:frame.preCost,turn:g.turn,phase:g.phase,status:g.status,
round:g.richeseBidding,auction:g.auction,richeseAuction:g.richeseAuction,cache:g.richeseCache,
order:g.order,aid:g.aid,nexus:g.nexusCards,history:g.nexusEmperorPurchaseHistory,
players:g.players.map(p=>({id:p.id,faction:p.faction,ally:p.ally,hand:p.hand,
spice:p.spice,specialKaramaUsed:p.specialKaramaUsed})),
active:g.active,decision:g.decision,response:g.response,phaseOpening:g.phaseOpening,
pendingKarama:g.pendingKarama,truthtrance:g.truthtrance,sale:g.currentAuctionSale,
purchaseIncome:g.pendingRichesePurchaseIncome,
});
if ('normal' in frame) return JSON.stringify({
event:frame.receipt.event,normal:frame.normal,turn:g.turn,phase:g.phase,status:g.status,
round:g.richeseBidding,auction:g.auction,order:g.order,aid:g.aid,
@@ -12778,6 +12795,79 @@ function normalRicheseBetrayalPreflight(g: Game) {
currentAuctionContinuationQuote(g,{kind:'next'});
return invoice;
}
function richesePreCostInvoice(g: Game, declaration: RichesePreCostDeclaration) {
requireRule(richeseBetrayalModeSupported(g) && g.status === 'playing',
'This declared purchase needs the original Richese Nexus profile.');
const owner=declaration.kind === 'ordinary' ? declaration.owner : declaration.intent.owner;
const p=getPlayer(g,owner);
requireRule(p.faction === 'richese', 'Only the original Richese acquisition can be stopped.');
let card:Card, costCard:Card, sourceCards:Card[];
if (declaration.kind === 'ordinary') {
requireRule(!g.pendingKarama && !g.pendingKull && !g.pendingTreacheryDiscard &&
!g.pendingNexusEmperorPayment && !g.pendingNexusEmperorSellerPurchase,
'Finish the already committed auction-card effect before another acquisition.');
normalKaramaAuction(g,p,false);
requireRule(g.auction!.index === declaration.index, 'The declared ordinary lot has changed.');
costCard=p.hand.find(c=>c.id===declaration.card)!;
requireRule(costCard, 'The unplayed activation Karama must remain held.');
const blocked=karamaSpendingBlock(g,p,costCard,'auction-free-purchase');
requireRule(!blocked,blocked ?? 'This Karama is unavailable.');
validateKaramaUse(g,p,{kind:'purchase'},costCard);
card=g.auction!.cards[declaration.index];sourceCards=[card];
} else {
const intent=declaration.intent;
requireRule(intent.turn===g.turn && intent.phase===g.phase,
'The declared special acquisition belongs to another window.');
const prepared=prepareSpecialKaramaIntent(g,owner,specialKaramaAction(intent));
requireRule(prepared.kind==='richese' && JSON.stringify(prepared)===JSON.stringify(intent),
'The original private special-purchase selection or funding has changed.');
costCard=p.hand.find(c=>c.id===intent.card)!;
card=g.richeseCache!.find(c=>c.id===intent.acquire)!;sourceCards=g.richeseCache!;
}
return nexusRule(()=>quoteRicheseKaramaBetrayalInvoice({players:g.players,owner,
source:declaration.kind==='ordinary'?'karama':'specialKarama',card,costCard,
ownerCards:p.hand,sourceCards,physicalCards:physicalTreacheryCards(g),
originalRecipient:byFaction(g,'emperor')?.id ?? null}));
}
/** Costed callbacks remain the original owner's voluntary declaration, even
* when the last neutral acknowledgement is made by another player. */
function reconcileRichesePreCostPromises(g: Game, declaration: RichesePreCostDeclaration) {
const owner=declaration.kind==='ordinary'?declaration.owner:declaration.intent.owner;
const action:Action=declaration.kind==='ordinary'
?{type:'card',mode:'purchase',card:declaration.card}:specialKaramaAction(declaration.intent);
reconcileBattlePromises(g,{actor:owner,action});
reconcileShipmentPromises(g,{actor:owner,action});
reconcileGroundMovementPromises(g,owner,action,g.phase);
}
function beginRichesePreCostBetrayal(g: Game, source:
{kind:'ordinary';owner:string;card:string;index:number} |
{kind:'special';intent:Extract<SpecialKaramaIntent,{kind:'richese'}>}): boolean {
if (!g.richeseBetrayalPreview) return false;
const declaration:RichesePreCostDeclaration={...source,round:crypto.randomUUID(),lot:crypto.randomUUID()};
const invoice=richesePreCostInvoice(g,declaration),required=richeseBetrayalResponders(g);
if (!required.length) return false;
const owner=invoice.buyer;
if (liveShipmentPromises(g.shipmentPromises ?? [],owner,g.turn).length ||
g.battle?.truthPromises?.some(p=>p.player===owner && !p.released) ||
liveMovementPromises(g.movementPromises ?? [],owner,g.turn).some(p=>p.answer)) {
const projected=structuredClone(g);
if (source.kind==='ordinary') {
const p=getPlayer(projected,owner);
spendKarama(projected,p,p.hand.find(c=>c.id===source.card)!,{kind:'purchase'});
} else executeSpecialKaramaIntent(projected,source.intent);
reconcileRichesePreCostPromises(projected,declaration);
}
const cursor=g.richeseBetrayal!,sequence=cursor.sequence+1;
const receipt=nexusRule(()=>createRicheseBetrayalReceipt(g,{
event:richeseBetrayalEvent(g.turn,sequence,declaration.round,declaration.lot),
turn:g.turn,sequence,round:declaration.round,lot:declaration.lot,invoice,required,
passed:[],stage:'pending',actor:null,nexusDiscardIndex:null,
}));
const frame:NonNullable<Game['pendingRicheseBetrayal']>={receipt,preCost:declaration,signature:''};
frame.signature=richeseBetrayalSourceSignature(g,frame);
cursor.sequence=sequence;cursor.current=receipt.event;g.pendingRicheseBetrayal=frame;
return true;
}
function richeseBetrayalSourcePreflight(g: Game, quote: Extract<RicheseSettlementQuote,{kind:'sold'}>): void {
requireRule(richeseBetrayalModeSupported(g) && g.status === 'playing' && g.phase === 3 &&
g.richeseBidding?.stage === 'lot' && g.richeseBidding.turn === g.turn &&
@@ -12837,7 +12927,17 @@ function richeseBetrayalIntegrity(g: Game): void {
nexusCardsIntegrity(g);
nexusRule(() => validateRicheseBetrayalCursor(g,cursor,frame?.receipt ?? null));
if (frame) {
if ('normal' in frame) {
if ('preCost' in frame) {
const declaration=frame.preCost,invoice=richesePreCostInvoice(g,declaration);
const keys=declaration.kind==='ordinary'?'card,index,kind,lot,owner,round':'intent,kind,lot,round';
requireRule(Object.keys(frame).sort().join(',')==='preCost,receipt,signature' &&
Object.keys(declaration).sort().join(',')===keys &&
declaration.round===frame.receipt.round && declaration.lot===frame.receipt.lot &&
frame.receipt.turn===g.turn && JSON.stringify(invoice)===JSON.stringify(frame.receipt.invoice) &&
frame.signature===richeseBetrayalSourceSignature(g,frame) &&
JSON.stringify(frame.receipt.required)===JSON.stringify(richeseBetrayalResponders(g)),
'The intended Karama purchase lost its unplayed costs, selected card or original continuation.');
} else if ('normal' in frame) {
const invoice=normalRicheseBetrayalPreflight(g);
requireRule(Object.keys(frame).sort().join(',') === 'normal,receipt,signature' &&
Object.keys(frame.normal).sort().join(',') === 'index,lot,round' &&
@@ -12864,8 +12964,10 @@ function richeseBetrayalIntegrity(g: Game): void {
}
}
const sale = g.currentAuctionSale;
const completed = cursor.completed.at(-1);
if (completed && completed.invoice.source !== 'normal' && completed.lot === g.richeseAuction?.event) {
const completed = cursor.completed.find(receipt=>
(receipt.invoice.source==='cache' || receipt.invoice.source==='blackMarket') &&
receipt.lot===g.richeseAuction?.event);
if (completed) {
const waiting = g.pendingNexusEmperorSellerPurchase;
requireRule(g.status === 'playing' && g.phase === 3 && completed.turn === g.turn &&
completed.round === g.richeseBidding?.event &&
@@ -12894,7 +12996,7 @@ function richeseBetrayalIntegrity(g: Game): void {
}
}
if (sale?.recipient) {
const receipt = cursor.completed.at(-1);
const receipt = completed;
requireRule(sale.recipient === 'bank' && receipt?.stage === 'used' && receipt.invoice.kind === 'sale' &&
receipt.lot === g.richeseAuction?.event && receipt.round === g.richeseBidding?.event &&
receipt.invoice.buyer === sale.winner && receipt.invoice.target === sale.seller &&
@@ -12966,6 +13068,30 @@ function decideRicheseBetrayal(g: Game, p: Player, action: Action): void {
g.richeseBetrayal!.completed.push(next);
g.richeseBetrayal!.current = null;
g.pendingRicheseBetrayal = null;
if ('preCost' in frame) {
const declaration=frame.preCost;
if (use && richeseBetrayalVetoesPurchase(receipt.invoice)) {
if (declaration.kind==='ordinary') {
const next=normalAuctionRetirementQuote(g);
g.discard.push(g.auction!.cards[g.auction!.index]);
g.response=null;g.decision=null;
commitNormalAuctionRetirement(g,next);
} else {
const index=g.richeseCache!.findIndex(card=>card.id===declaration.intent.acquire);
requireRule(index>=0,'The privately selected card must remain in its original cache.');
g.discard.push(...g.richeseCache!.splice(index,1));
}
log(g,`${p.name} spent Richese Nexus Betrayal and stopped the intended Karama purchase. The selected card was discarded privately; its activation costs remain unspent.`);
} else {
if (use) log(g,`${p.name} spent a committed Richese Nexus attempt. The intended Karama acquisition continues without card inspection.`);
if (declaration.kind==='ordinary') {
const buyer=getPlayer(g,declaration.owner);
spendKarama(g,buyer,buyer.hand.find(card=>card.id===declaration.card)!,{kind:'purchase'});
} else executeSpecialKaramaIntent(g,declaration.intent);
reconcileRichesePreCostPromises(g,declaration);
}
return;
}
if ('normal' in frame) {
// Cost precedes the private family test. A miss cannot be a free oracle.
if (use && richeseBetrayalVetoesPurchase(receipt.invoice)) {
@@ -14199,18 +14325,30 @@ function commitAuctionContinuation(g: Game, quote: AuctionContinuationQuote,bure
offerOccupiedBonus(g, g.response);
}
else if (next.kind === 'richeseEnd') finishRicheseLot(g);
else {
g.currentAuctionSale = null;
if (next.kind === 'normalEnd') {
g.deck = [...next.returned, ...g.deck];
g.auction = null;
finishNormalBidding(g);
} else {
g.auction = next.auction;
g.active = next.active;
if (next.offer?.kind === 'ixTechnology') g.decision = next.offer;
else if (next.offer) offerAuctionPeek(g);
}
else commitNormalAuctionRetirement(g,next);
}
function normalAuctionRetirementQuote(g: Game) {
try {
return quoteNormalAuctionRetirement({turn:g.turn,advanced:g.advanced,
players:g.players.map(p=>({...p,handLimit:handLimit(p)})),order:g.order,auction:g.auction,
richeseBidding:g.richeseBidding,richeseCacheCount:g.richeseCache?.length,
ixTechnologyTurn:g.ixTechnologyTurn,physicalCards:physicalTreacheryCards(g)});
} catch(error) {
if(error instanceof NormalAuctionRetirementError)throw new RuleError(error.message);
throw error;
}
}
function commitNormalAuctionRetirement(g: Game, next: NormalAuctionRetirementQuote) {
g.currentAuctionSale = null;
if (next.kind === 'normalEnd') {
g.deck = [...next.returned, ...g.deck];
g.auction = null;
finishNormalBidding(g);
} else {
g.auction = next.auction;
g.active = next.active;
if (next.offer?.kind === 'ixTechnology') g.decision = next.offer;
else if (next.offer) offerAuctionPeek(g);
}
}
function continueAuctionSale(g: Game, free: boolean) {
@@ -33081,6 +33219,7 @@ function resolveSpecialKaramaEffect(g: Game, p: Player, card: Card, intent: Spec
}
function specialKarama(g: Game, p: Player, action: Action) {
const intent = prepareSpecialKaramaIntent(g, p.id, action);
if (intent.kind==='richese' && beginRichesePreCostBetrayal(g,{kind:'special',intent})) return;
const card = p.hand.find(card => card.id === intent.card)!;
if (!beginKullAttempt(g, p, card, { kind: 'special', use: intent }))
executeSpecialKaramaIntent(g, intent);
@@ -37592,6 +37731,8 @@ function applyActionInner(
g.phase === 3 && g.auction && p.hand.length < handLimit(p),
'You may take the current auction card only when eligible to bid.',
);
if (p.faction==='richese' && beginRichesePreCostBetrayal(g,
{kind:'ordinary',owner:p.id,card:c.id,index:g.auction!.index})) return g;
spendKarama(g, p, c, { kind: 'purchase' });
return g;
}