170 lines
5.4 KiB
TypeScript
170 lines
5.4 KiB
TypeScript
import type { Card } from './cards';
|
|
import type { RicheseAuction } from './richese-auction';
|
|
import {
|
|
validateRicheseFunding,
|
|
type RicheseFundingRecord,
|
|
} from './richese-funding';
|
|
|
|
export class RicheseSettlementError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'RicheseSettlementError';
|
|
}
|
|
}
|
|
|
|
export type RicheseSettlementSeat = {
|
|
id: string;
|
|
handCount: number;
|
|
/** The engine supplies its current faction-specific hand limit. */
|
|
handLimit: number;
|
|
};
|
|
export type RicheseSettlementInput = {
|
|
lot: Readonly<RicheseAuction>;
|
|
owner: Readonly<RicheseSettlementSeat>;
|
|
winner?: Readonly<RicheseSettlementSeat & { spice: number }> | null;
|
|
/** Resolve from the lot's actual cache/owner-hand source, never its preview. */
|
|
card?: Readonly<Card> | null;
|
|
funding?: Readonly<RicheseFundingRecord> | null;
|
|
/** The engine resolves current aidFor; no unpledged donor funds are counted. */
|
|
allyCredit?: Readonly<{ amount: number }> | null;
|
|
};
|
|
export type RicheseSettlementQuote =
|
|
| { kind: 'open' }
|
|
| { kind: 'retainBlackMarket' }
|
|
| { kind: 'removeCache'; card: Card }
|
|
| { kind: 'chooseUnbidCache' }
|
|
| {
|
|
kind: 'sold';
|
|
card: Card;
|
|
winner: string;
|
|
amount: number;
|
|
ownPayment: number;
|
|
allyPayment: number;
|
|
};
|
|
|
|
/** A Black Market seller already holds the offered card. Provisional paid
|
|
* retention therefore needs no additional hand slot; other wins still do. */
|
|
export function richeseBidFitsHand(
|
|
source: RicheseAuction['source'], owner: string, bidder: string,
|
|
handCount: number, handLimit: number,
|
|
): boolean {
|
|
return handCount < handLimit ||
|
|
source === 'blackMarket' && owner === bidder && handCount === handLimit;
|
|
}
|
|
|
|
function requireSettlement(
|
|
condition: unknown,
|
|
message: string,
|
|
): asserts condition {
|
|
if (!condition) throw new RicheseSettlementError(message);
|
|
}
|
|
function validHand(seat: Readonly<RicheseSettlementSeat>) {
|
|
requireSettlement(
|
|
Number.isSafeInteger(seat.handCount) &&
|
|
seat.handCount >= 0 &&
|
|
Number.isSafeInteger(seat.handLimit) &&
|
|
seat.handLimit > 0,
|
|
'The auction hand size or limit is invalid.',
|
|
);
|
|
}
|
|
function reservedCard(input: RicheseSettlementInput): Card {
|
|
requireSettlement(
|
|
input.card && input.card.id === input.lot.cardId,
|
|
'The reserved auction card is no longer available.',
|
|
);
|
|
return structuredClone(input.card);
|
|
}
|
|
|
|
/**
|
|
* Pure post-bidding branch/price validation, shared by settlement and preflight.
|
|
* Does not reveal, pay, transfer, decide, draw, log, or advance a phase.
|
|
* The caller binds phase/round/event, full physical custody and current ally
|
|
* identity. A returned card is detached evidence, not an additional live card.
|
|
*
|
|
* Preserve unbid asymmetry: Black Market retains without a funding/card lookup;
|
|
* cache space prompts a future choice, while a full owner removes its card.
|
|
*/
|
|
export function quoteRicheseSettlement(
|
|
input: RicheseSettlementInput,
|
|
): RicheseSettlementQuote {
|
|
const { lot, owner } = input;
|
|
if (!lot.outcome) return { kind: 'open' };
|
|
requireSettlement(
|
|
owner && owner.id === lot.owner,
|
|
'The auction owner is no longer available.',
|
|
);
|
|
requireSettlement(
|
|
lot.source === 'cache' || lot.source === 'blackMarket',
|
|
'The auction source is invalid.',
|
|
);
|
|
if (lot.outcome.kind === 'unbid') {
|
|
if (lot.source === 'blackMarket') return { kind: 'retainBlackMarket' };
|
|
validHand(owner);
|
|
return owner.handCount >= owner.handLimit
|
|
? { kind: 'removeCache', card: reservedCard(input) }
|
|
: { kind: 'chooseUnbidCache' };
|
|
}
|
|
requireSettlement(
|
|
lot.outcome.kind === 'sold',
|
|
'The auction outcome is invalid.',
|
|
);
|
|
const winner = input.winner;
|
|
requireSettlement(
|
|
winner && winner.id === lot.outcome.winner,
|
|
'The auction winner is no longer available.',
|
|
);
|
|
const card = reservedCard(input);
|
|
validHand(winner);
|
|
requireSettlement(
|
|
richeseBidFitsHand(lot.source, lot.owner, winner.id, winner.handCount, winner.handLimit),
|
|
'The winning hand has no room for this card.',
|
|
);
|
|
const { funding } = input;
|
|
requireSettlement(
|
|
funding && funding.amount === lot.outcome.amount,
|
|
'The winning bid needs its exact funding commitment.',
|
|
);
|
|
let split: { own: number; ally: number };
|
|
try {
|
|
split = validateRicheseFunding(
|
|
lot.outcome.amount,
|
|
funding.allyPayment,
|
|
winner.spice,
|
|
input.allyCredit?.amount ?? 0,
|
|
);
|
|
} catch (error) {
|
|
if (!(error instanceof RangeError)) throw error;
|
|
throw new RicheseSettlementError(
|
|
error.message === 'The declared payment split is not funded.'
|
|
? 'The payment is no longer funded.'
|
|
: error.message,
|
|
);
|
|
}
|
|
return {
|
|
kind: 'sold',
|
|
card,
|
|
winner: winner.id,
|
|
amount: lot.outcome.amount,
|
|
ownPayment: split.own,
|
|
allyPayment: split.ally,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Use when a canceled/unbid Black Market lot will immediately enter
|
|
* richeseDeclaration; settlement quoting alone deliberately does not execute
|
|
* that continuation. A sold Black Market lot may first pause for
|
|
* replacement/bonus handling.
|
|
*
|
|
* User ruling 7 October 2026: an exhausted cache is legal — the ordinary pool
|
|
* then runs its full declared count — so only a malformed count is rejected.
|
|
*/
|
|
export function requireRicheseDeclarationCache(
|
|
cacheCount: number | undefined,
|
|
): void {
|
|
requireSettlement(
|
|
Number.isSafeInteger(cacheCount) && cacheCount! >= 0,
|
|
'The Richese cache count must be a readable non-negative number.',
|
|
);
|
|
}
|