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

+5 -57
View File
@@ -2,6 +2,7 @@ import type { Card } from './cards';
import type { FactionId } from './catalog';
import type { Game, ResponseWindow } from './engine';
import { requireRicheseDeclarationCache } from './richese-settlement';
import { quoteNormalAuctionRetirement } from './normal-auction-retirement';
export type AuctionContinuationSeat = {
id: string;
@@ -444,61 +445,8 @@ function calculate(
after: round.position === 'first' ? 'normalPool' : 'phase',
});
}
const a = input.auction!,
index = a.index + 1,
able = input.order.filter((id) => {
const p = seated(id)!;
return handCount(p) < p.handLimit;
});
if (index === a.cards.length || !able.length) {
const round = input.richeseBidding,
activeRound = round?.turn === input.turn && round.stage === 'normal';
// User ruling 7 October 2026: the cache lot only runs when physical cache
// cards remain and the compulsory auction was not canceled.
const cacheLast =
!!activeRound &&
round.position === 'last' &&
!round.cacheCanceled &&
(input.richeseCacheCount ?? 0) > 0;
return result({
kind: 'normalEnd',
returned: structuredClone(a.cards.slice(index)),
after: cacheLast ? 'richeseCache' : 'phase',
completeRound: !!activeRound && !cacheLast,
});
}
let opener = a.opener;
do {
opener = (opener + 1) % input.order.length;
} while (!able.includes(input.order[opener]));
const active = input.order[opener],
ix = input.players.find((p) => p.faction === 'ixians'),
atreides = input.players.find((p) => p.faction === 'atreides');
const offer: LotOffer =
input.advanced &&
ix &&
handCount(ix) > 0 &&
input.ixTechnologyTurn !== input.turn
? { kind: 'ixTechnology', player: ix.id }
: atreides
? { kind: 'atreidesAuction', owner: atreides.id, passed: [] }
: null;
const nextAuction = structuredClone(a);
delete nextAuction.saphoOrder;
return result({
kind: 'normalLot',
auction: {
...nextAuction,
index,
bid: 0,
allyPayment: 0,
bidder: null,
passed: [],
opener,
active,
peekKnown: false,
},
active,
offer,
});
return result(quoteNormalAuctionRetirement({ ...input,
players: spending ? input.players.map(player => player.id === spending.player
? { ...player, hand: player.hand.filter((card: { id: string }) => card.id !== spending.card) } : player) : input.players,
}));
}
+2 -1
View File
@@ -8,9 +8,10 @@ export function botNexusRicheseBetrayalActions(game: GameView): Action[] {
!reaction.canPass || reaction.hasPassed) return [];
const own = game.players.find((player) => player.id === game.me);
if (!(own?.bot ?? own?.autopilot)) return [];
const blind = reaction.source === 'normal' || reaction.source === 'karama';
return [{
// Minimal policies keep the card instead of guessing a concealed ordinary lot.
type: reaction.source !== 'normal' && reaction.canUse && !reaction.blocked ? 'richeseBetrayalUse' : 'richeseBetrayalPass',
type: !blind && reaction.canUse && !reaction.blocked ? 'richeseBetrayalUse' : 'richeseBetrayalPass',
event: reaction.event,
}];
}
+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;
}
+54 -10
View File
@@ -9,7 +9,7 @@ export type RicheseBetrayalInvoice = {
kind: 'purchase' | 'sale';
target: string;
buyer: string;
source: 'cache' | 'blackMarket' | 'normal';
source: 'cache' | 'blackMarket' | 'normal' | 'karama' | 'specialKarama';
price: number;
ownPayment: number;
allyPayment: number;
@@ -88,19 +88,24 @@ function validateInvoice(players: readonly NexusPlayer[], invoice: RicheseBetray
const target = players.find(p => p.id === invoice.target);
const buyer = players.find(p => p.id === invoice.buyer);
requireBetrayal(target?.faction === 'richese' && buyer &&
['cache', 'blackMarket', 'normal'].includes(invoice.source) &&
whole(invoice.price) && invoice.price > 0 && whole(invoice.ownPayment) && whole(invoice.allyPayment) &&
['cache', 'blackMarket', 'normal', 'karama', 'specialKarama'].includes(invoice.source) &&
whole(invoice.price) && (invoice.source === 'karama' ? invoice.price === 0 :
invoice.source === 'specialKarama' ? invoice.price === 3 : invoice.price > 0) &&
whole(invoice.ownPayment) && whole(invoice.allyPayment) &&
invoice.allyPayment <= invoice.price && invoice.ownPayment === invoice.price - invoice.allyPayment &&
(invoice.allyPayment === 0 ? invoice.donor === null :
text(invoice.donor) && invoice.donor !== invoice.buyer && players.some(p => p.id === invoice.donor)) &&
validCard(invoice.card), 'Richese Betrayal needs a positive original price, exact funding split and physical card.');
validCard(invoice.card), 'Richese Betrayal needs its original source price, exact funding split and physical card.');
if (invoice.kind === 'purchase') {
requireBetrayal((invoice.source === 'normal' || invoice.source === 'cache' && richeseCardDefinition(invoice.card)) &&
requireBetrayal((['normal', 'karama'].includes(invoice.source) ||
['cache', 'specialKarama'].includes(invoice.source) && richeseCardDefinition(invoice.card)) &&
buyer.id === target.id &&
(invoice.source !== 'karama' && invoice.source !== 'specialKarama' ||
invoice.allyPayment === 0 && invoice.donor === null) &&
invoice.originalRecipient === (players.find(p => p.faction === 'emperor')?.id ?? null),
'Richese self-veto requires an original cache or normal purchase paid to Emperor or bank.');
'Richese self-veto requires its original native purchase and payment recipient.');
} else {
requireBetrayal(invoice.kind === 'sale' && invoice.source !== 'normal' &&
requireBetrayal(invoice.kind === 'sale' && ['cache', 'blackMarket'].includes(invoice.source) &&
buyer.id !== target.id && invoice.originalRecipient === target.id &&
(invoice.source !== 'cache' || richeseCardDefinition(invoice.card)),
'Richese sale diversion requires another buyer and income originally owed to Richese.');
@@ -184,11 +189,50 @@ export function quoteRicheseNormalBetrayalInvoice(input: {
return invoice;
}
/** Evaluate only after the physical Nexus has been committed. A normal-lot
* miss still records a used counter; it grants no earlier face inspection. */
/** Capture a selected, still unplayed Karama purchase. The caller owns native
* role, funding and once-per-game legality; this quote owns distinct physical
* cost/target custody and the original invoice. */
export function quoteRicheseKaramaBetrayalInvoice(input: {
players: readonly NexusPlayer[];
owner: string;
source: 'karama' | 'specialKarama';
card: Card;
costCard: Card;
ownerCards: readonly Card[];
sourceCards: readonly Card[];
physicalCards: readonly Card[];
originalRecipient: string | null;
}): RicheseBetrayalInvoice {
requireBetrayal(plain(input), 'Richese Betrayal needs an original Karama purchase.');
validatePlayers(input.players);
requireBetrayal(text(input.owner) && input.players.some(player => player.id === input.owner && player.faction === 'richese') &&
['karama', 'specialKarama'].includes(input.source) && validCard(input.card) && validCard(input.costCard) &&
input.card.id !== input.costCard.id,
'Richese Betrayal needs its native owner and distinct physical purchase and Karama cost.');
for (const [cards, card] of [
[input.ownerCards, input.costCard], [input.sourceCards, input.card],
[input.physicalCards, input.costCard], [input.physicalCards, input.card],
] as const) {
requireBetrayal(Array.isArray(cards) && cards.every(validCard), 'Richese Betrayal needs physical source custody.');
const matches = cards.filter(candidate => candidate.id === card.id);
requireBetrayal(matches.length === 1 && sameCard(matches[0], card),
'Richese Betrayal has lost or duplicated its exact purchase or Karama cost card.');
}
const price = input.source === 'karama' ? 0 : 3;
const invoice: RicheseBetrayalInvoice = {
kind: 'purchase', target: input.owner, buyer: input.owner, source: input.source,
price, ownPayment: price, allyPayment: 0, donor: null,
originalRecipient: input.originalRecipient, card: { ...input.card },
};
validateInvoice(input.players, invoice);
return invoice;
}
/** Evaluate only after the physical Nexus has been committed. A concealed
* ordinary-lot miss still records a used counter without prior inspection. */
export function richeseBetrayalVetoesPurchase(invoice: RicheseBetrayalInvoice): boolean {
return invoice.kind === 'purchase' &&
(invoice.source !== 'normal' || !!richeseCardDefinition(invoice.card));
(!['normal', 'karama'].includes(invoice.source) || !!richeseCardDefinition(invoice.card));
}
/** Public held-presence only: private Nexus identities never affect timing. */
+103
View File
@@ -0,0 +1,103 @@
import type { AuctionContinuationInput, AuctionContinuationQuote } from './auction-continuation-quote';
export type NormalAuctionRetirementInput = Pick<AuctionContinuationInput,
'turn' | 'advanced' | 'players' | 'order' | 'auction' | 'richeseBidding' |
'richeseCacheCount' | 'ixTechnologyTurn' | 'physicalCards'>;
export type NormalAuctionRetirementQuote = Extract<AuctionContinuationQuote['next'],
{ kind: 'normalLot' | 'normalEnd' }>;
export class NormalAuctionRetirementError extends Error {
constructor(message: string) {
super(message);
this.name = 'NormalAuctionRetirementError';
}
}
function requireRetirement(value: unknown, message: string): asserts value {
if (!value) throw new NormalAuctionRetirementError(message);
}
const text = (value: unknown): value is string => typeof value === 'string' && value.length > 0;
const count = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0;
const ids = (value: unknown): value is string[] =>
Array.isArray(value) && value.every(text) && new Set(value).size === value.length;
const object = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
/** Retire one original ordinary lot, bought or unbought. No winner, sale,
* payment, hand delivery or faction income is manufactured by this quote. */
export function quoteNormalAuctionRetirement(input: NormalAuctionRetirementInput): NormalAuctionRetirementQuote {
try {
return calculate(input);
} catch (error) {
if (error instanceof NormalAuctionRetirementError) throw error;
throw new NormalAuctionRetirementError(error instanceof Error ? error.message : 'Malformed normal auction retirement.');
}
}
function calculate(input: NormalAuctionRetirementInput): NormalAuctionRetirementQuote {
requireRetirement(count(input.turn) && input.turn > 0 && typeof input.advanced === 'boolean',
'Normal auction retirement needs its current turn and rules mode.');
requireRetirement(Array.isArray(input.players) && ids(input.players.map(player => player.id)) &&
input.players.every(player => Array.isArray(player.hand) &&
player.hand.every((card: unknown) => object(card) && text(card.id)) &&
count(player.handLimit) && player.handLimit > 0),
'Auction seats or hand counts are malformed.');
const seated = (id: string) => input.players.find(player => player.id === id);
requireRetirement(ids(input.order) && input.order.length > 0 && input.order.every(id => seated(id)),
'The auction needs a nonempty unique seated order.');
const a = input.auction;
requireRetirement(a && Array.isArray(a.cards) && count(a.index) && a.index < a.cards.length &&
a.cards.every(card => object(card) && text(card.id) && text(card.name) && text(card.kind)),
'The normal auction has an invalid current card or index.');
requireRetirement(count(a.opener) && a.opener < input.order.length && input.order.includes(a.active) &&
ids(a.passed) && a.passed.every(id => input.order.includes(id)) && count(a.bid) &&
(a.allyPayment === undefined || count(a.allyPayment) && a.allyPayment <= a.bid) &&
(a.bidder === null ? a.bid === 0 : input.order.includes(a.bidder)),
'The normal auction has an invalid opener, bidder or bid context.');
// Earlier sold entries are historical aliases. Only the remaining pool must
// be unique, including against the original lot that is being retired.
const unsold = a.cards.slice(a.index + 1).map(card => card.id);
requireRetirement(ids(unsold) && !unsold.includes(a.cards[a.index].id),
'The unsold auction pool duplicates its current physical card.');
requireRetirement(Array.isArray(input.physicalCards) && unsold.every(id =>
input.physicalCards.filter(card => card?.id === id).length === 1),
'The unsold auction pool needs unique physical card custody.');
const round = input.richeseBidding;
if (round) requireRetirement(round.turn === input.turn && round.stage === 'normal' &&
seated(round.owner)?.faction === 'richese' && ['first', 'last', null].includes(round.position) &&
typeof round.cacheCanceled === 'boolean',
'The normal pool has a stale Richese round.');
const index = a.index + 1;
const able = input.order.filter(id => {
const player = seated(id)!;
return player.hand.length < player.handLimit;
});
if (index === a.cards.length || !able.length) {
const activeRound = round?.turn === input.turn && round.stage === 'normal';
// User ruling 7 October 2026: a last cache lot needs physical cache stock
// and an uncanceled compulsory auction.
const cacheLast = !!activeRound && round.position === 'last' && !round.cacheCanceled &&
(input.richeseCacheCount ?? 0) > 0;
return {
kind: 'normalEnd', returned: structuredClone(a.cards.slice(index)),
after: cacheLast ? 'richeseCache' : 'phase', completeRound: !!activeRound && !cacheLast,
};
}
let opener = a.opener;
do {
opener = (opener + 1) % input.order.length;
} while (!able.includes(input.order[opener]));
const active = input.order[opener];
const ix = input.players.find(player => player.faction === 'ixians');
const atreides = input.players.find(player => player.faction === 'atreides');
const offer = input.advanced && ix && ix.hand.length > 0 && input.ixTechnologyTurn !== input.turn
? { kind: 'ixTechnology' as const, player: ix.id }
: atreides ? { kind: 'atreidesAuction' as const, owner: atreides.id, passed: [] } : null;
const nextAuction = structuredClone(a);
delete nextAuction.saphoOrder;
return {
kind: 'normalLot', auction: {
...nextAuction, index, bid: 0, allyPayment: 0, bidder: null,
passed: [], opener, active, peekKnown: false,
}, active, offer,
};
}
+10 -8
View File
@@ -591,9 +591,9 @@ export const RULE_TOPICS: RuleTopic[] = [
steps: [
'In advanced development tables, Richese can use its special Karama once per game. Select an available Karama in your hand and one eligible card from your own cache. The activating Karama is discarded; the chosen card enters your hand secretly.',
'This is a purchase for Richese itself. Pay three of your available spice to Emperor when present, otherwise the bank. The general Emperor purchase-income rule supplies this payment destination. An ally receives no Harkonnen bonus or Ixian replacement from this direct cache acquisition.',
'With Emperor absent, an actual unallied Emperor Nexus holder may use the private joint option after proving three own uncommitted spice. Original Karama and Nexus both spend at purchase commit, one exact cache card enters your hand and native once-use/capacity/reservations remain. No bank money, reimbursement, ally bonus, fake auction or automatic public pause. This purchase composition is visibly provisional; ordinary paid use remains available.',
'With Emperor absent, an actual unallied Emperor Nexus holder may use the private joint option after proving three own uncommitted spice. Original Karama and Nexus both spend at purchase commit, one exact cache card enters your hand and native once-use/capacity/reservations remain. No bank money, reimbursement, ally bonus or fake auction is created. The separate Betrayal preview may pause before those costs. This purchase composition is visibly provisional; ordinary paid use remains available.',
'A Karama cannot cancel the special purchase itself. Emperor income has a separate response: canceling that income does not undo the purchase or restore the spent special power.',
'The separate Richese Nexus Betrayal preview connects ordinary public self-cache auctions, not this private special acquisition. The native Karama cancellation exception above is not authority for deciding whether Nexus Betrayal applies to a special purchase.',
'The separate Richese Nexus Betrayal preview also pauses this intended private acquisition before its costs. A successful veto discards only the selected cache card; Karama, three spice and once-use remain unspent, including an intended joint Emperor Nexus. This order is explicitly provisional composition. Ordinary Karama immunity does not automatically grant Nexus immunity.',
'The private hand panel provides both card selectors and enlarged inspection. It shows the payment recipient and the server’s current availability reason. A reserved auction card cannot be taken from that lot. Spending pledged or already committed funds is not allowed.',
'Full-hand activation now has a visibly provisional exchange policy: spend the held Karama and acquire one cache card, leaving the final hand within its actual limit. This is not a publisher timing clarification or numerical user ruling. Buying the final cached card is legal under the resolved exhausted-cache auction rule; subsequent Bidding restores the ordinary pool. Reserved cards, over-limit hands and pending commitments remain ineligible.',
'All four AI profiles can choose the canonical Richese Karama when the projection allows it and enough spice remains. They leave other cache effects to future implementation and give pending responses, decisions and Truthtrance priority.',
@@ -5016,7 +5016,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'At an eligible original classic/native Nexus normal bank-auction winner payment, prove the positive final bid from your own spice and spend Emperor Nexus to receive the lot without losing spice. Every winner sees the same payment confirmation; the provisional privacy recommendation remains. Canonical native module envelopes preserve original card/capacity, bonus and next-lot inspection; ordinary spice or legal Karama remains available. No bank subsidy or Richese cache/seller route is invented. Strict old physical revival admission is separate and never broadens.',
'Original Richese cache and other-buyer Advanced Black Market offer Pay or eligible Emperor Nexus Use after original Richese Betrayal. The provisional seller-unpaid policy retains your full personally affordable price without bank reimbursement. One original lot, native Harkonnen bonus and source successor continue; legal self-cache is included. Already-held Black Market paid retention supplies no acquisition/free-seller source, so it does not invent this confirmation or spend a Nexus. Original self-purchase forced discard cannot consume this Nexus or acquire a card.',
'A committed Richese Ambassador trigger now offers its original beneficiary Pay or eligible Emperor Nexus Use, with no new decline. Under the visible provisional purchase composition, prove and keep three own spice while drawing one original deck card; no bank payment, reimbursement or fake auction is made. Original private inability, native bonus and entrant movement/worm continuation remain. Allies cannot Use; actual forming-alliance Nexus disposal stays original.',
'Original Advanced Richese special Karama has an eligible private joint option: spend the held Karama plus Emperor Nexus at native purchase commit after proving three own uncommitted spice, keep that spice and acquire one exact unreserved cache card. Native once-use, full-hand/final-cache and enclosing controls remain; no bank money, ally bonus, fake auction or automatic public pause. Ordinary Pay3 remains available. This source composition is visibly provisional, not immunity or broader Kull admission.',
'Original Advanced Richese special Karama has an eligible private joint option: spend the held Karama plus Emperor Nexus at native purchase commit after proving three own uncommitted spice, keep that spice and acquire one exact unreserved cache card. Native once-use, full-hand/final-cache and enclosing controls remain; no bank money, ally bonus or fake auction is created. An explicit Richese Betrayal preview pauses before either cost; veto keeps both cards and once-use, all-pass executes the same joint intent. Ordinary Pay3 remains available. These compositions are visibly provisional, not immunity or broader Kull admission.',
],
related: ['nexus-cards', 'revival'],
checklist: [
@@ -5037,17 +5037,19 @@ export const RULE_TOPICS: RuleTopic[] = [
steps: [
'This is an explicit local Richese Betrayal preview, not a public expansion start. It supports fresh two-to-six-seat Basic or Advanced tables with CHOAM, Richese and otherwise classic factions, the physical CHOAM deck and Nexus alone. Other optional modules, expansion factions, Semuta and Kull previews remain outside this boundary; older games do not silently acquire the effect.',
'To use either alternative, remain unallied and hold the singleton Richese Nexus card while another seat is native Richese. Use commits that physical Nexus card once. It spends no ordinary Treachery card, hand slot, Karama or activation spice. Current own-card promises and committed-resource legality still apply.',
'The opportunity opens after a valid winning quote but before any purchase payment, source removal or hand delivery. You do not choose a different card, buyer, price or recipient: those terms are bound to the original auction.',
'The opportunity opens after a valid winning quote or prepared Karama acquisition, before payment, activating-card spend, source removal or delivery. It binds the original card, buyer, price, funding selection and native continuation.',
'Purchase veto applies to Richese winning its own public cache lot through Once Around or Silent bidding. The exact selected cache card enters ordinary Treachery discard instead of Richese’s hand. No buyer or ally spice is spent; no payment goes to Richese, Emperor or bank, and no purchase bonus is created. The original lot retires once.',
'A positive Richese win in the ordinary auction also opens the neutral response before payment choice, even when the face is concealed. Under the provisional blind-attempt convention, commit your Richese Nexus first. If the reserved card is a Richese-family card, it is discarded without payment, delivery or purchase bonus. Otherwise the Nexus remains spent and the original payment choice continues. There is no pre-spend family check, inspection or refund. The exact discarded face stays private.',
'An immediate ordinary Karama acquisition opens the same blind attempt before its held activation card is played. On a successful veto, that Karama stays held and the unbought lot retires once. On a miss or all-pass, the exact original Karama spends and Richese acquires the lot free once. A competing bid is not charged or awarded.',
'The Advanced private special purchase also pauses before costs, with no selected face or private funding disclosed. Its selected cache card is necessarily Richese-family. A veto discards only that card: Karama, three spice and once-use stay unspent. An intended joint Emperor Nexus stays held too. All-pass executes the original paid or joint intent once, including separate native Emperor income and any suspended parent response. Pre-cost ordering is visibly provisional, not a printed refund ruling.',
'Sale diversion applies when another buyer owes a positive payment to Richese for a cache auction or an Advanced Black Market auction using normal, Once Around or Silent bidding. The same buyer receives the same physical card for the same price and own/authorized-ally split. That original payment goes to the bank, with zero sale income for Richese; it is not an extra charge, refund or second purchase.',
'A diverted sale still earns the original buyer consequences. In particular, an applicable Harkonnen purchase bonus continues once. Losing offers do not spend spice. A vetoed purchase, zero/free cache keep or unsold Black Market retention does not fabricate a bonus or a positive sale.',
'Your voluntary Nexus spend must preserve your own binding shipment promises. If the involuntary purchase veto makes an opponent’s promise impossible, the existing native reconciliation may release that promise; it does not pretend Richese acquired the vetoed card or paid the price.',
'All publicly possible responders receive the same neutral acknowledgement: unallied non-Richese seats with public held-Nexus presence. A seat may pass even if its secret card is irrelevant; only a privately eligible Richese-card holder sees Use. If no seat is publicly possible, settlement is automatic. This privacy-preserving application protocol is not publisher-prescribed timing or a general Nexus/Karama priority ruling.',
'Pass spends nothing and keeps your Nexus card. Your recorded pass cannot be undone within this event. Once every required responder passes, the original purchase resumes its native payment choice and settles once with its original recipient, card delivery and earned consequences. An accepted Use closes the opportunity with its one selected outcome.',
'The panel shows the public Richese target, original buyer, cache/Black Market/ordinary source and winning price. It does not show payer resources, ally balances, rival hands, secret Nexus identities or the saved private receipt. Read your own unavailable reason rather than guessing Use from a held-card face alone.',
'The panel shows the public Richese target, buyer and source. Auctions show their winning price; ordinary Karama shows an intended free acquisition and private special Karama its nominal three-spice price. Private funding stays concealed. No payer resources, ally balances, rival hands, secret Nexus identities or saved receipt are shown.',
'A public cache face remains inspectable through existing entitled Card controls. A concealed Black Market face stays unknown unless you already have a separate inspection entitlement; a seller’s public claim is not its face. Nexus inspection shows printed reference text, not anybody’s hidden custody. This response grants no new card inspection.',
'The normal-lot blind-attempt rule is online composition, not a printed general right to discard a Nexus card at any time. Its normal winning-bid boundary precedes the existing spice/Karama payment choice. Proactive immediate Karama acquisition and the private Advanced special-Karama acquisition retain their separate activating-card and once-use boundaries; this prototype does not certify those producers.',
'The normal-lot blind-attempt rule is online composition, not a printed general right to discard a Nexus card at any time. Its winning-bid boundary precedes the spice/Karama chooser; immediate ordinary and private special acquisitions retain their own exact unplayed activating-card and once-use boundaries.',
'Existing cache-count, ordering and special-acquisition boundaries remain. Already-held Black Market paid retention supplies neither a cache acquisition nor seller income, so no veto/diversion invoice is invented. Unsupported counters or any-time overlays must not enter this saved purchase boundary. Finish a real response before underlying bidding or other actions can change its reserved card or funding.',
'Refreshing resumes the same event, passes and original auction continuation. Saved pending and completed history must retain exact physical source and once-only payment without rerolls, raw-action replay or requiring old cards to remain forever in discard. Stale or foreign event submissions reject rather than selecting another lot.',
'All four AI difficulties use the same private legal Use/public Pass projection and pass blind ordinary attempts while strategic play is deferred. Prior cache/Black Market SQL and phone-browser evidence does not certify the new normal-lot source. Full Richese/Nexus games, wider compositions and deployed acceptance remain open.',
@@ -5055,11 +5057,11 @@ export const RULE_TOPICS: RuleTopic[] = [
example: 'Richese wins a revealed cache lot for 4: Betrayal veto discards that card, charges nobody and delivers nothing. If Harkonnen instead buys it for 4, sale diversion charges the original four spice once, sends it to the bank, delivers the card and preserves the applicable Harkonnen bonus. If everyone passes, the original purchase settles unchanged: Richese’s self-buy pays Emperor/bank; the other buyer’s sale pays Richese.',
related: ['nexus-cards', 'richese-cards', 'richese-acquisition', 'bidding', 'alliance-funding', 'privacy', 'faction-richese', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Public self-cache veto, original ordinary winning-bid blind attempt and other-buyer cache/Black Market recipient override bind actual sources and continuations. Ordinary attempts commit Nexus before family outcome; success skips payment/acquisition and advances once, pass/miss resumes the original chooser. Proactive/special acquisition and wider responses remain unfinished.', evidence: ['game/nexus-richese-betrayal.ts', 'game/engine.ts', 'game/auction-continuation-quote.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'Self-cache, ordinary winning-bid and immediate Karama veto, private special pre-cost veto and other-buyer cache/Black Market recipient override bind actual sources. Ordinary blind misses spend Nexus then resume native purchase. Successful pre-cost veto retains original costs; private joint funding and parent income resume once on pass. Wider profiles/responses remain unfinished.', evidence: ['game/nexus-richese-betrayal.ts', 'game/engine.ts', 'game/normal-auction-retirement.ts', 'game/auction-continuation-quote.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Mounted preview controls separate public neutral pass/wait from private legal Use, retain existing entitled inspectors and suspend the underlying auction panel without revealing the unknown Black Market face.', evidence: ['components/nexus-richese-betrayal.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four minimal legal policies consume canonical own-view eligibility and acknowledge publicly required opportunities; full strategy and calibration remain unfinished.', evidence: ['game/bot-nexus-richese-betrayal.ts', 'game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Prepayment veto, same-invoice diversion, singleton cost, pass behavior, neutral protocol, entitled faces and excluded producer/counter boundaries are explained without full-family or publisher-timing claims.', evidence: ['docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_CARD_RULES.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'New ordinary source has separate invoice, actual auction-continuation and connected controls/minimal-profile cases with a labelled controlled recycled-card fixture. Historical cache/Black Market full-suite, SQL and phone-browser evidence remains dated legacy scope; it does not prove normal-lot recovery or deployed acceptance. Full-family, broader combinations and final assurance remain open.', evidence: ['tests/nexus-richese-betrayal-normal.test.ts', 'tests/richese-normal-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-normal-controls.test.tsx', 'tests/fixture-richese-normal-betrayal.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'docs/NEXUS_RICHESE_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused invoice, unbought retirement, real producer and controls/profile cases cover ordinary winning bids, immediate Karama and private special/joint intent. Controlled ordinary recycling is labelled; private sources use native draws. Historical SQL/phone evidence remains dated legacy scope. Broader combinations, final assurance and deployed acceptance remain open.', evidence: ['tests/nexus-richese-betrayal-karama.test.ts', 'tests/normal-auction-retirement.test.ts', 'tests/richese-karama-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-karama-controls.test.tsx', 'tests/fixture-richese-karama-betrayal.ts', 'tests/richese-normal-betrayal-engine.test.ts', 'docs/NEXUS_RICHESE_RULES.md'] },
],
},
{