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'; import { routeAuctionPayment } from './auction-payment-routing'; export type AuctionContinuationSeat = { id: string; faction: FactionId; ally?: string | null; spice: number; hand: readonly { id: string }[]; handLimit: number; }; export type AuctionContinuationInput = { status: string; phase: number; turn: number; advanced: boolean; order: readonly string[]; players: readonly AuctionContinuationSeat[]; /** Live ownership zones; sold auction references are not custody. */ physicalCards: readonly { id: string }[]; auction: Game['auction']; sale?: Game['currentAuctionSale']; pendingIxAlly?: Game['pendingIxAlly']; ixTechnologyTurn?: number; richeseAuction?: Game['richeseAuction']; richeseBidding?: Game['richeseBidding']; richeseCacheCount?: number; /** Printed occupied bonus uses actual receiver capacity, not native buyer space. */ occupiedBonusAvailable?: boolean; }; export type AuctionContinuationOperation = | { kind: 'sale'; free: boolean } | { kind: 'bonus' } | { kind: 'next' } | { kind: 'cancel'; response: ResponseWindow }; export type AuctionContinuationSpending = { player: string; card: string }; type Sale = NonNullable & { legacy: boolean }; type Response = { kind: 'emperorIncome' | 'harkonnenBonus'; owner: string; passed: string[]; }; type LotOffer = | { kind: 'ixTechnology'; player: string } | { kind: 'atreidesAuction'; owner: string; passed: string[] } | null; export type AuctionContinuationQuote = { sale: Sale; steps: ( | { kind: 'sellerCredit'; player: string; amount: number; balance: number } | { kind: 'clearIxAlly' } )[]; next: | { kind: 'response'; response: Response } | { kind: 'normalLot'; auction: NonNullable; active: string; offer: LotOffer; } | { kind: 'normalEnd'; returned: Card[]; after: 'richeseCache' | 'phase'; completeRound: boolean; } | { kind: 'richeseEnd'; source: 'cache' | 'blackMarket'; after: 'declaration' | 'normalPool' | 'phase'; blackMarketSold?: true; opener?: number; }; }; export class AuctionContinuationError extends Error { constructor(message: string) { super(message); this.name = 'AuctionContinuationError'; } } function requireAuction(value: unknown, message: string): asserts value { if (!value) throw new AuctionContinuationError(message); } const text = (v: unknown): v is string => typeof v === 'string' && v.length > 0; const count = (v: unknown): v is number => Number.isSafeInteger(v) && (v as number) >= 0; const ids = (v: unknown): v is string[] => Array.isArray(v) && v.every(text) && new Set(v).size === v.length; const object = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v); /** The original Emperor/bank-routed invoice, not the buyer-to-Richese leg. */ export function auctionEmperorIncomeAmount( sale: NonNullable, ): number { if (sale.free || sale.recipient === 'bank') return 0; if (sale.paymentRouting) return sale.paymentRouting.filter(leg => leg.recipient === 'emperor') .reduce((total, leg) => total + leg.amount, 0); return sale.origin === 'normal' || sale.seller === sale.winner ? sale.amount : sale.richeseContribution ?? 0; } /** Deterministic original-sale continuation, including retained-price free lots. * A returned response, Richese transition or normal phase-end is a boundary, * not a simulated future action. * Never publish this internal quote: returned lot cards remain private. */ export function quoteAuctionContinuation( input: AuctionContinuationInput, operation: AuctionContinuationOperation, spending?: AuctionContinuationSpending, ): AuctionContinuationQuote { try { return calculate(input, operation, spending); } catch (error) { if (error instanceof AuctionContinuationError) throw error; throw new AuctionContinuationError( error instanceof Error ? error.message : 'Malformed auction continuation.', ); } } function calculate( input: AuctionContinuationInput, operation: AuctionContinuationOperation, spending?: AuctionContinuationSpending, ): AuctionContinuationQuote { requireAuction( input.status === 'playing' && input.phase === 3 && count(input.turn) && input.turn > 0 && typeof input.advanced === 'boolean', 'Auction continuation needs the current Bidding Phase.', ); requireAuction( Array.isArray(input.players) && ids(input.players.map((p) => p.id)) && input.players.every( (p) => Array.isArray(p.hand) && p.hand.every((c: unknown) => object(c) && text(c.id)) && count(p.handLimit) && p.handLimit > 0, ), 'Auction seats or hand counts are malformed.', ); const seated = (id: string) => input.players.find((p) => p.id === id); requireAuction( ids(input.order) && input.order.length > 0 && input.order.every((id) => seated(id)), 'The auction needs a nonempty unique seated order.', ); const normal = input.sale?.origin === 'normal' || (!input.sale && !input.richeseAuction); let cardId: string; let sale: Sale; if (normal) { const a = input.auction; requireAuction( a && Array.isArray(a.cards) && count(a.index) && a.index < a.cards.length && a.cards.every( (c) => object(c) && text(c.id) && text(c.name) && text(c.kind), ), 'The normal auction has an invalid current card or index.', ); requireAuction( 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 && input.order.includes(a.bidder), 'The normal auction has an invalid opener, bidder or bid context.', ); cardId = a.cards[a.index].id; // Earlier sold entries are historical aliases. They may legitimately match // a later card returned by Ixian Technology; only unsold entries are a pool. const unsold = a.cards.slice(a.index + 1).map((c) => c.id); requireAuction( ids(unsold) && !unsold.includes(cardId), 'The unsold auction pool duplicates its current physical card.', ); requireAuction( Array.isArray(input.physicalCards) && unsold.every( (id) => input.physicalCards.filter((c) => c?.id === id).length === 1, ), 'The unsold auction pool needs unique physical card custody.', ); if (input.sale) { requireAuction( input.sale.origin === 'normal' && input.sale.seller === null && input.sale.winner === a.bidder && input.sale.amount === a.bid && typeof input.sale.free === 'boolean', 'The paid normal sale does not match its bidder and amount.', ); sale = { ...input.sale, legacy: false }; } else sale = { winner: a.bidder, amount: a.bid, free: operation.kind === 'sale' ? operation.free : false, origin: 'normal', seller: null, legacy: true, }; requireAuction( !input.richeseAuction, 'A normal sale cannot also be a live Richese lot.', ); const round = input.richeseBidding; if (round) requireAuction( 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.', ); } else { const s = input.sale, lot = input.richeseAuction, round = input.richeseBidding; requireAuction( s && ['cache', 'blackMarket'].includes(s.origin) && typeof s.free === 'boolean' && count(s.amount) && seated(s.winner) && s.seller && seated(s.seller)?.faction === 'richese', 'The original Richese sale receipt is malformed.', ); requireAuction( lot && !input.auction && text(lot.event) && text(lot.cardId) && lot.source === s.origin && lot.owner === s.seller && lot.outcome?.kind === 'sold' && lot.outcome.winner === s.winner && lot.outcome.amount === s.amount && ids(lot.order) && lot.order.length > 0 && lot.order.every((id) => input.order.includes(id)) && (lot.saphoOriginalOpener === undefined || text(lot.saphoOriginalOpener) && lot.order.includes(lot.saphoOriginalOpener)) && lot.order.includes(s.winner) && ['normal', 'onceAround', 'silent'].includes(lot.method) && !(lot.source === 'cache' && lot.method === 'normal'), 'The paid Richese lot does not match its sold outcome.', ); requireAuction( round && round.turn === input.turn && round.stage === 'lot' && round.owner === lot.owner && text(round.event) && (lot.source === 'blackMarket' ? round.position === null : ['first', 'last'].includes(String(round.position))), 'The paid Richese lot has a stale declaration round.', ); requireAuction( lot.source !== 'blackMarket' || (input.advanced && (s.seller !== s.winner || !s.free && s.amount > 0)), 'Black Market requires Advanced play; paid seller retention needs a positive actual price.', ); requireAuction( !s.free || ( s.amount > 0 && s.richeseContribution === undefined && s.spiceBankerIncomePayment === undefined && s.bureaucratPayment === undefined && s.bureaucratPaymentEvent === undefined && (s.occupiedContributions === undefined || s.occupiedContributions.every(contribution => contribution.amount === 0)) ), 'A free Richese purchase cannot carry an actual payment, donor invoice or generated income.', ); sale = { ...s, legacy: false }; cardId = lot.cardId; } requireAuction(sale.recipient === undefined || (sale.recipient === 'bank' && sale.origin !== 'normal' && sale.seller !== sale.winner && sale.amount > 0), 'A bank recipient override requires a positive Richese sale to another buyer.'); requireAuction( count(sale.amount) && typeof sale.free === 'boolean', 'The paid auction amount or free flag is invalid.', ); requireAuction( sale.richeseContribution === undefined || (input.advanced && sale.origin !== 'normal' && sale.seller !== sale.winner && count(sale.richeseContribution) && sale.richeseContribution > 0 && sale.richeseContribution <= sale.amount), 'A Richese contribution requires a positive bounded Advanced other-buyer sale.', ); if (operation.kind === 'sale') requireAuction( typeof operation.free === 'boolean' && operation.free === sale.free, 'The auction continuation has a changed free-purchase flag.', ); requireAuction( ['sale', 'bonus', 'next', 'cancel'].includes(operation.kind), 'Unknown auction continuation.', ); const winner = seated(sale.winner)!; requireAuction(winner, 'The auction winner is no longer seated.'); if (sale.paymentRouting !== undefined) { const emperor = input.players.find(player => player.faction === 'emperor'); requireAuction(input.advanced && !sale.free && Array.isArray(sale.paymentRouting) && sale.paymentRouting.length > 0 && sale.paymentRouting.every(leg => seated(leg.payer)) && JSON.stringify(sale.paymentRouting) === JSON.stringify(routeAuctionPayment({ advanced: true, free: false, price: sale.amount, buyer: winner.id, emperor: emperor?.id ?? null, seller: sale.seller, bankOnly: sale.recipient === 'bank', contributions: sale.paymentRouting.map(({payer, amount}) => ({payer, amount})), })) && sale.paymentRouting.some(leg => leg.payer === emperor?.id && leg.payer !== winner.id && leg.recipient === 'bank'), 'The paid auction lost its original Advanced Emperor bank contribution.'); if (sale.origin === 'normal') { const a = input.auction!; const donors = a.coalitionContributions ?? (a.allyPayment ? [{donor:winner.ally, amount:a.allyPayment}] : []); const expected = donors.map(({donor,amount}) => ({payer:donor, amount})); const own = sale.amount - expected.reduce((sum, leg) => sum + leg.amount, 0); if (own > 0) expected.unshift({payer:winner.id, amount:own}); requireAuction(JSON.stringify(sale.paymentRouting.map(({payer,amount}) => ({payer,amount}))) === JSON.stringify(expected), 'The paid auction contributions no longer match the original bid.'); } else requireAuction(sale.paymentRouting.every(leg => leg.payer === winner.id || leg.payer === winner.ally), 'The paid Richese lot lost its original buyer or ally.'); } let stage = operation.kind; const steps: AuctionContinuationQuote['steps'] = []; if (operation.kind === 'cancel') { const r = operation.response; requireAuction( r && seated(r.owner) && ids(r.passed) && r.passed.every((id) => seated(id)), 'The canceled auction response has an invalid owner or passes.', ); if (r.kind === 'ixAllyCard') { const pending = input.pendingIxAlly; requireAuction( pending && pending.player === winner.id && r.recipient === winner.id && seated(r.owner)?.faction === 'ixians' && pending.card === cardId && pending.free === sale.free && !sale.legacy && sale.origin !== 'cache', 'The denied Ixian replacement does not match its completed purchase.', ); steps.push({ kind: 'clearIxAlly' }); stage = 'sale'; } else if (r.kind === 'emperorIncome') { requireAuction( seated(r.owner)?.faction === 'emperor' && auctionEmperorIncomeAmount(sale) > 0 && (r.owner !== winner.id || sale.richeseContribution !== undefined), 'The canceled Emperor income is not payable by this sale.', ); stage = 'bonus'; } else if (r.kind === 'harkonnenBonus') { requireAuction( r.owner === winner.id && winner.faction === 'harkonnen', 'The canceled Harkonnen bonus has the wrong winner.', ); stage = 'next'; } else requireAuction( false, 'This response is not a paid-auction continuation.', ); } // The source is checked before projection; successor eligibility is evaluated // only after this one declared cost leaves its actual owner's hand. if (spending) requireAuction( seated(spending.player) && text(spending.card) && seated(spending.player)!.hand.filter((c) => c.id === spending.card) .length === 1, 'The quoted auction cost needs unique custody in its owner hand.', ); const handCount = (p: AuctionContinuationSeat) => p.hand.length - Number(spending?.player === p.id); const result = ( next: AuctionContinuationQuote['next'], ): AuctionContinuationQuote => ({ sale, steps, next }); if (stage === 'sale') { if (!sale.free && sale.origin !== 'normal' && sale.seller !== winner.id) { if (sale.recipient !== 'bank') { const seller = seated(sale.seller!)!; const amount = sale.paymentRouting ? sale.paymentRouting.filter(leg => leg.recipient === 'seller').reduce((sum, leg) => sum + leg.amount, 0) : sale.amount - (sale.richeseContribution ?? 0); requireAuction( count(seller.spice) && count(seller.spice + amount), 'The seller credit would overflow its current balance.', ); steps.push({ kind: 'sellerCredit', player: seller.id, amount, balance: seller.spice + amount, }); } } const emperor = input.players.find((p) => p.faction === 'emperor'); if ( emperor && auctionEmperorIncomeAmount(sale) > 0 && (emperor.id !== winner.id || sale.richeseContribution !== undefined) ) return result({ kind: 'response', response: { kind: 'emperorIncome', owner: emperor.id, passed: [] }, }); stage = 'bonus'; } if ( stage === 'bonus' && winner.faction === 'harkonnen' && (input.occupiedBonusAvailable || handCount(winner) < 8) ) return result({ kind: 'response', response: { kind: 'harkonnenBonus', owner: winner.id, passed: [] }, }); if (sale.origin !== 'normal') { const lot = input.richeseAuction!, round = input.richeseBidding!; if (lot.source === 'blackMarket') { requireRicheseDeclarationCache(input.richeseCacheCount); return result({ kind: 'richeseEnd', source: 'blackMarket', after: 'declaration', blackMarketSold: true, ...(lot.method === 'normal' ? { opener: (input.order.indexOf(lot.saphoOriginalOpener ?? lot.order[0]) + 1) % input.order.length, } : {}), }); } return result({ kind: 'richeseEnd', source: 'cache', after: round.position === 'first' ? 'normalPool' : 'phase', }); } 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, })); }