import type { Card } from './cards'; import type { Game } from './engine'; import { nexusCardMode, validateNexusCards, validateNexusPlayers, type NexusPlayer, type NexusState } from './nexus-cards'; import type { RicheseAuction } from './richese-auction'; import { richeseCardDefinition } from './richese-cards'; import type { RicheseSettlementQuote } from './richese-settlement'; export type RicheseBetrayalInvoice = { kind: 'purchase' | 'sale'; target: string; buyer: string; source: 'cache' | 'blackMarket' | 'normal' | 'karama' | 'specialKarama'; price: number; ownPayment: number; allyPayment: number; donor: string | null; /** The original invoice recipient; null means the Spice Bank. */ originalRecipient: string | null; /** Private physical evidence, never a public reaction field. */ card: Card; }; export type RicheseBetrayalContext = { turn: number; players: readonly NexusPlayer[]; }; export type RicheseBetrayalReceipt = { version: 1; event: string; turn: number; sequence: number; round: string; lot: string; invoice: RicheseBetrayalInvoice; required: string[]; passed: string[]; stage: 'pending' | 'passed' | 'used'; actor: string | null; nexusDiscardIndex: number | null; signature: string; }; export type RicheseBetrayalCursor = { sequence: number; current: string | null; completed: RicheseBetrayalReceipt[]; }; export class RicheseBetrayalError extends Error { constructor(message: string) { super(message); this.name = 'RicheseBetrayalError'; } } function requireBetrayal(value: unknown, message: string): asserts value { if (!value) throw new RicheseBetrayalError(message); } const plain = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value)); const exact = (value: unknown, keys: readonly string[]): boolean => plain(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key)); const text = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0 && !['__proto__', 'constructor', 'prototype'].includes(value); const whole = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; const ids = (value: unknown): value is string[] => Array.isArray(value) && value.every(text) && new Set(value).size === value.length; const cardKinds: Record = { projectile: true, poison: true, lasgun: true, shield: true, snooper: true, poisonBlade: true, shieldSnooper: true, weirdingWay: true, chemistry: true, poisonTooth: true, artillery: true, worthless: true, hero: true, special: true, }; function validCard(card: Card): boolean { return plain(card) && exact(card, card.effect === undefined ? ['id', 'name', 'kind'] : ['id', 'name', 'kind', 'effect']) && text(card.id) && text(card.name) && Object.hasOwn(cardKinds, card.kind) && (card.effect === undefined || text(card.effect)); } function sameCard(a: Card, b: Card): boolean { return a.id === b.id && a.name === b.name && a.kind === b.kind && a.effect === b.effect; } function validatePlayers(players: readonly NexusPlayer[]): void { try { validateNexusPlayers(players); } catch (error) { throw new RicheseBetrayalError(error instanceof Error ? error.message : 'Invalid Richese Betrayal roster.'); } requireBetrayal(players.some(p => p.faction === 'richese'), 'Richese Betrayal requires the native Richese seat.'); } function validateInvoice(players: readonly NexusPlayer[], invoice: RicheseBetrayalInvoice): void { requireBetrayal(exact(invoice, ['kind', 'target', 'buyer', 'source', 'price', 'ownPayment', 'allyPayment', 'donor', 'originalRecipient', 'card']), 'Richese Betrayal has an invalid original invoice.'); const target = players.find(p => p.id === invoice.target); const buyer = players.find(p => p.id === invoice.buyer); requireBetrayal(target?.faction === 'richese' && buyer && ['cache', 'blackMarket', 'normal', 'karama', 'specialKarama'].includes(invoice.source) && whole(invoice.price) && (invoice.source === 'karama' ? invoice.price === 0 : invoice.source === 'specialKarama' ? invoice.price === 3 : invoice.price > 0) && whole(invoice.ownPayment) && whole(invoice.allyPayment) && invoice.allyPayment <= invoice.price && invoice.ownPayment === invoice.price - invoice.allyPayment && (invoice.allyPayment === 0 ? invoice.donor === null : text(invoice.donor) && invoice.donor !== invoice.buyer && players.some(p => p.id === invoice.donor)) && validCard(invoice.card), 'Richese Betrayal needs its original source price, exact funding split and physical card.'); if (invoice.kind === 'purchase') { requireBetrayal((['normal', 'karama'].includes(invoice.source) || ['cache', 'specialKarama'].includes(invoice.source) && richeseCardDefinition(invoice.card)) && buyer.id === target.id && (invoice.source !== 'karama' && invoice.source !== 'specialKarama' || invoice.allyPayment === 0 && invoice.donor === null) && invoice.originalRecipient === (players.find(p => p.faction === 'emperor')?.id ?? null), 'Richese self-veto requires its original native purchase and payment recipient.'); } else { requireBetrayal(invoice.kind === 'sale' && ['cache', 'blackMarket'].includes(invoice.source) && buyer.id !== target.id && invoice.originalRecipient === target.id && (invoice.source !== 'cache' || richeseCardDefinition(invoice.card)), 'Richese sale diversion requires another buyer and income originally owed to Richese.'); } } /** Quote the original invoice only. Veto pays nobody; diversion changes the * recipient to the bank, never the buyer, price, source or contribution split. */ export function quoteRicheseBetrayalInvoice(input: { players: readonly NexusPlayer[]; lot: Readonly; quote: Extract; donor: string | null; originalRecipient: string | null; sourceCards: readonly Card[]; physicalCards: readonly Card[]; }): RicheseBetrayalInvoice { requireBetrayal(plain(input), 'Richese Betrayal needs a source invoice.'); validatePlayers(input.players); const { lot, quote } = input; requireBetrayal(plain(lot) && plain(quote) && quote.kind === 'sold' && text(lot.event) && lot.outcome?.kind === 'sold' && lot.outcome.winner === quote.winner && lot.outcome.amount === quote.amount && lot.cardId === quote.card?.id && ['normal', 'onceAround', 'silent'].includes(lot.method) && !(lot.source === 'cache' && lot.method === 'normal'), 'Richese Betrayal needs the original ordinary sold lot.'); const invoice: RicheseBetrayalInvoice = { kind: quote.winner === lot.owner ? 'purchase' : 'sale', target: lot.owner, buyer: quote.winner, source: lot.source, price: quote.amount, ownPayment: quote.ownPayment, allyPayment: quote.allyPayment, donor: input.donor, originalRecipient: input.originalRecipient, card: { ...quote.card }, }; validateInvoice(input.players, invoice); const buyer = input.players.find(p => p.id === invoice.buyer)!; requireBetrayal(!invoice.donor || (buyer.ally === invoice.donor && input.players.find(p => p.id === invoice.donor)?.ally === buyer.id), 'Richese Betrayal cannot substitute an unallied contributor.'); for (const cards of [input.sourceCards, input.physicalCards]) { requireBetrayal(Array.isArray(cards) && cards.every(validCard), 'Richese Betrayal needs physical source custody.'); const matches = cards.filter(card => card.id === invoice.card.id); requireBetrayal(matches.length === 1 && sameCard(matches[0], invoice.card), 'Richese Betrayal has lost or duplicated its exact source card.'); } return invoice; } /** Bind an ordinary Richese purchase without using the concealed card's family * to decide whether the blind Nexus counter is available. Native settlement * separately proves the payer's actual wallet and pledged ally funding. */ export function quoteRicheseNormalBetrayalInvoice(input: { players: readonly NexusPlayer[]; auction: Pick, 'cards' | 'index' | 'bid' | 'bidder' | 'allyPayment'>; donor: string | null; originalRecipient: string | null; physicalCards: readonly Card[]; }): RicheseBetrayalInvoice { requireBetrayal(plain(input), 'Richese Betrayal needs an original normal auction.'); validatePlayers(input.players); const auction = input.auction; requireBetrayal(plain(auction) && Array.isArray(auction.cards) && auction.cards.every(validCard) && whole(auction.index) && auction.index < auction.cards.length && whole(auction.bid) && auction.bid > 0 && text(auction.bidder) && input.players.some(player => player.id === auction.bidder && player.faction === 'richese'), 'Richese Betrayal needs the native Richese winner and its current positive normal lot.'); const allyPayment = auction.allyPayment === undefined ? 0 : auction.allyPayment; const invoice: RicheseBetrayalInvoice = { kind: 'purchase', target: auction.bidder, buyer: auction.bidder, source: 'normal', price: auction.bid, ownPayment: auction.bid - allyPayment, allyPayment, donor: input.donor, originalRecipient: input.originalRecipient, card: { ...auction.cards[auction.index] }, }; validateInvoice(input.players, invoice); const buyer = input.players.find(player => player.id === invoice.buyer)!; requireBetrayal(!invoice.donor || buyer.ally === invoice.donor && input.players.find(player => player.id === invoice.donor)?.ally === buyer.id, 'Richese Betrayal cannot substitute an unallied contributor.'); for (const cards of [auction.cards, input.physicalCards]) { requireBetrayal(Array.isArray(cards) && cards.every(validCard), 'Richese Betrayal needs physical source custody.'); const matches = cards.filter(card => card.id === invoice.card.id); requireBetrayal(matches.length === 1 && sameCard(matches[0], invoice.card), 'Richese Betrayal has lost or duplicated its exact source card.'); } return invoice; } /** Capture a selected, still unplayed Karama purchase. The caller owns native * role, funding and once-per-game legality; this quote owns distinct physical * cost/target custody and the original invoice. */ export function quoteRicheseKaramaBetrayalInvoice(input: { players: readonly NexusPlayer[]; owner: string; source: 'karama' | 'specialKarama'; card: Card; costCard: Card; ownerCards: readonly Card[]; sourceCards: readonly Card[]; physicalCards: readonly Card[]; originalRecipient: string | null; }): RicheseBetrayalInvoice { requireBetrayal(plain(input), 'Richese Betrayal needs an original Karama purchase.'); validatePlayers(input.players); requireBetrayal(text(input.owner) && input.players.some(player => player.id === input.owner && player.faction === 'richese') && ['karama', 'specialKarama'].includes(input.source) && validCard(input.card) && validCard(input.costCard) && input.card.id !== input.costCard.id, 'Richese Betrayal needs its native owner and distinct physical purchase and Karama cost.'); for (const [cards, card] of [ [input.ownerCards, input.costCard], [input.sourceCards, input.card], [input.physicalCards, input.costCard], [input.physicalCards, input.card], ] as const) { requireBetrayal(Array.isArray(cards) && cards.every(validCard), 'Richese Betrayal needs physical source custody.'); const matches = cards.filter(candidate => candidate.id === card.id); requireBetrayal(matches.length === 1 && sameCard(matches[0], card), 'Richese Betrayal has lost or duplicated its exact purchase or Karama cost card.'); } const price = input.source === 'karama' ? 0 : 3; const invoice: RicheseBetrayalInvoice = { kind: 'purchase', target: input.owner, buyer: input.owner, source: input.source, price, ownPayment: price, allyPayment: 0, donor: null, originalRecipient: input.originalRecipient, card: { ...input.card }, }; validateInvoice(input.players, invoice); return invoice; } /** Evaluate only after the physical Nexus has been committed. A concealed * ordinary-lot miss still records a used counter without prior inspection. */ export function richeseBetrayalVetoesPurchase(invoice: RicheseBetrayalInvoice): boolean { return invoice.kind === 'purchase' && (!['normal', 'karama'].includes(invoice.source) || !!richeseCardDefinition(invoice.card)); } /** Public held-presence only: private Nexus identities never affect timing. */ export function requiredRicheseBetrayalResponders( players: readonly (NexusPlayer & { nexusHeld: boolean })[], ): string[] { validatePlayers(players); requireBetrayal(players.every(p => typeof p.nexusHeld === 'boolean'), 'Richese Betrayal needs public Nexus held-presence.'); return players.filter(p => p.faction !== 'richese' && !p.ally && p.nexusHeld).map(p => p.id); } /** No printed spice fee. The physical singleton Richese Nexus is the cost; * runtime separately preflights the unchanged purchase promises and budget. */ export function richeseBetrayalEligibility( input: { players: readonly NexusPlayer[]; cards: NexusState }, owner: string, ): string | null { validatePlayers(input.players); try { validateNexusCards(input.cards, input.players); } catch (error) { throw new RicheseBetrayalError(error instanceof Error ? error.message : 'Invalid physical Nexus custody.'); } const seat = input.players.find(p => p.id === owner); if (!seat) return 'Choose a seated Richese Nexus holder.'; if (seat.ally) return 'An allied player cannot play a Nexus card.'; if (input.cards.hands[owner] !== 'richese' || nexusCardMode('richese', seat.faction, input.players.map(p => p.faction)) !== 'betrayal') return 'Hold the real Richese Nexus card as another faction to use Betrayal.'; return null; } export function richeseBetrayalEvent(turn: number, sequence: number, round: string, lot: string): string { return JSON.stringify(['nexusRicheseBetrayal', turn, sequence, round, lot]); } function signature(receipt: RicheseBetrayalReceipt): string { const i = receipt.invoice; return JSON.stringify(['richeseBetrayalReceipt', receipt.version, receipt.event, receipt.turn, receipt.sequence, receipt.round, receipt.lot, i.kind, i.target, i.buyer, i.source, i.price, i.ownPayment, i.allyPayment, i.donor, i.originalRecipient, [i.card.id, i.card.name, i.card.kind, i.card.effect ?? null], receipt.required, receipt.passed, receipt.stage, receipt.actor, receipt.nexusDiscardIndex]); } /** Intrinsic historical evidence, independent of current card/discard custody * and later alliances. Signatures detect inconsistency, not arbitrary forgery. */ export function validateRicheseBetrayalReceipt(context: RicheseBetrayalContext, receipt: RicheseBetrayalReceipt): void { requireBetrayal(plain(context) && whole(context.turn) && context.turn > 0, 'Richese Betrayal needs a valid current turn.'); validatePlayers(context.players); requireBetrayal(exact(receipt, ['version', 'event', 'turn', 'sequence', 'round', 'lot', 'invoice', 'required', 'passed', 'stage', 'actor', 'nexusDiscardIndex', 'signature']) && receipt.version === 1 && whole(receipt.turn) && receipt.turn > 0 && receipt.turn <= context.turn && whole(receipt.sequence) && receipt.sequence > 0 && text(receipt.round) && text(receipt.lot) && receipt.event === richeseBetrayalEvent(receipt.turn, receipt.sequence, receipt.round, receipt.lot) && ids(receipt.required) && receipt.required.length > 0 && ids(receipt.passed) && receipt.required.every(id => context.players.some(p => p.id === id && p.faction !== 'richese')) && receipt.passed.every(id => receipt.required.includes(id)), 'Richese Betrayal has lost its original event or acknowledgements.'); validateInvoice(context.players, receipt.invoice); if (receipt.stage === 'used') { requireBetrayal(text(receipt.actor) && receipt.required.includes(receipt.actor) && !receipt.passed.includes(receipt.actor) && whole(receipt.nexusDiscardIndex), 'Richese Betrayal has lost its one physical Nexus spend.'); } else { requireBetrayal((receipt.stage === 'pending' || receipt.stage === 'passed') && receipt.actor === null && receipt.nexusDiscardIndex === null && (receipt.stage === 'passed' ? receipt.passed.length === receipt.required.length : receipt.turn === context.turn && receipt.passed.length < receipt.required.length), 'Richese Betrayal has an invalid pending or all-pass lifecycle.'); } requireBetrayal(receipt.signature === signature(receipt), 'Richese Betrayal receipt has changed its original invoice.'); } export function createRicheseBetrayalReceipt( context: RicheseBetrayalContext, input: Omit, ): RicheseBetrayalReceipt { const receipt: RicheseBetrayalReceipt = { ...input, version: 1, invoice: { ...input.invoice, card: { ...input.invoice.card } }, required: [...input.required], passed: [...input.passed], signature: '', }; receipt.signature = signature(receipt); validateRicheseBetrayalReceipt(context, receipt); return receipt; } /** The independent cursor makes missing/orphaned private frames fail closed. */ export function validateRicheseBetrayalCursor( context: RicheseBetrayalContext, cursor: RicheseBetrayalCursor, pending: RicheseBetrayalReceipt | null, ): void { requireBetrayal(exact(cursor, ['sequence', 'current', 'completed']) && whole(cursor.sequence) && (cursor.current === null || text(cursor.current)) && Array.isArray(cursor.completed), 'Richese Betrayal has an invalid continuation cursor.'); requireBetrayal(cursor.completed.length === cursor.sequence - (pending ? 1 : 0), 'Richese Betrayal has lost a completed or pending continuation.'); for (const [index, receipt] of cursor.completed.entries()) { validateRicheseBetrayalReceipt(context, receipt); requireBetrayal(receipt.stage !== 'pending' && receipt.sequence === index + 1, 'Richese Betrayal history must contain each completed opportunity once.'); } requireBetrayal(new Set(cursor.completed.map(r => r.event)).size === cursor.completed.length, 'Richese Betrayal has duplicate completed events.'); if (pending) { validateRicheseBetrayalReceipt(context, pending); requireBetrayal(pending.stage === 'pending' && pending.sequence === cursor.sequence && cursor.current === pending.event, 'Richese Betrayal has lost ownership of its original pending continuation.'); } else requireBetrayal(cursor.current === null, 'Richese Betrayal has an orphaned continuation cursor.'); }