Files
Dune/game/normal-auction-retirement.ts
Quintine 1eeeb32566
Verify and publish checkpoint container / container (push) Successful in 10m27s
Support staged group bid funding
2026-10-28 23:22:50 +11:00

105 lines
5.7 KiB
TypeScript

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;
delete nextAuction.coalitionContributions;
return {
kind: 'normalLot', auction: {
...nextAuction, index, bid: 0, allyPayment: 0, bidder: null,
passed: [], opener, active, peekKnown: false,
}, active, offer,
};
}