Files
Dune/game/auction-continuation-quote.ts

453 lines
16 KiB
TypeScript

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;
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<Game['currentAuctionSale']> & { 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<Game['auction']>;
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<string, unknown> =>
!!v && typeof v === 'object' && !Array.isArray(v);
/** The original Emperor/bank-routed invoice, not the buyer-to-Richese leg. */
export function auctionEmperorIncomeAmount(
sale: NonNullable<Game['currentAuctionSale']>,
): number {
if (sale.free || sale.recipient === 'bank') return 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.');
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.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,
}));
}