Files
Dune/game/richese-settlement.ts

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.',
);
}