453 lines
16 KiB
TypeScript
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,
|
|
}));
|
|
}
|