import type { Game } from './engine'; export type EmperorPaymentSource = { auctionIndex: number; card: string; winner: string; price: number; emperor: string; ally: string; allyPayment: number; aid: { recipient: string | null; amount: number }; emperorSpice: number; }; export type EmperorPaymentQuote = EmperorPaymentSource & { prepaid: number; missing: number; blocked: string | null; }; /** Original obligation and public acknowledgement snapshot, not a new bid or sale. */ export type EmperorPaymentReceipt = EmperorPaymentSource & { event: string; turn: number; phase: 3; order: string[]; holders: string[]; required: string[]; /** Pass answers only; a Use owner is the next member of the original queue. */ passed: string[]; stage: 'pending' | 'passed' | 'used'; owner: string | null; signature: string; }; const object = (value: unknown): value is Record => value !== null && typeof value === 'object' && !Array.isArray(value); const text = (value: unknown): value is string => typeof value === 'string' && value.trim().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 sameIds = (a: readonly string[], b: readonly string[]): boolean => a.length === b.length && a.every((id, index) => id === b[index]); function requirePayment(value: unknown, message: string): asserts value { if (!value) throw new Error(message); } /** A visible conservative full-obligation policy, not a publisher timing ruling. * Prepaid matching Emperor aid is already out of their wallet. Reuse it before * taking only the missing credit from their own spice; never count future income. * The engine alone establishes that ordinary bidding has reached its winner. */ export function emperorPaymentSourceQuote(g: Game): EmperorPaymentQuote | null { const auction = g.auction; if (g.status !== 'playing' || g.phase !== 3 || !count(g.turn) || g.turn === 0 || !auction || g.currentAuctionSale || g.richeseAuction || !Array.isArray(auction.cards) || !count(auction.index) || auction.index >= auction.cards.length || !count(auction.bid) || auction.bid === 0 || !text(auction.cards[auction.index]?.id) || !text(auction.bidder)) return null; const winner = g.players.find(player => player.id === auction.bidder); const emperors = g.players.filter(player => player.faction === 'emperor'); const emperor = emperors[0]; if (!winner || emperors.length !== 1 || !emperor || winner.id === emperor.id || winner.ally !== emperor.id || emperor.ally !== winner.id || !count(emperor.spice)) return null; const allyPayment = auction.allyPayment ?? 0; if (!count(allyPayment) || allyPayment > auction.bid) return null; const credit = g.aid?.[emperor.id]; if (credit && (!object(credit) || !text(credit.recipient) || !count(credit.amount))) return null; const card = auction.cards[auction.index].id; // Sold earlier entries are aliases, not physical custody. A still-unsold // duplicate or a card already acquired by a seat cannot be this original lot. for (let index = auction.index + 1; index < auction.cards.length; index++) if (auction.cards[index]?.id === card) return null; if (g.players.some(player => player.hand?.some(held => held.id === card)) || g.deck?.some(held => held.id === card) || g.discard?.some(held => held.id === card)) return null; const prepaid = credit?.recipient === winner.id ? Math.min(credit.amount, auction.bid) : 0; const missing = auction.bid - prepaid; let blocked: string | null = null; if (credit && credit.amount > 0 && credit.recipient !== winner.id) blocked = 'The Emperor’s prepaid aid belongs to another recipient.'; else if (allyPayment > prepaid) blocked = 'The original allied bid has lost its committed prepaid aid.'; else if (missing === 0) blocked = 'The Emperor has already prepaid the full original price.'; else if (emperor.spice < missing) blocked = 'The Emperor cannot cover the missing full-price funding from their own spice.'; return { auctionIndex: auction.index, card, winner: winner.id, price: auction.bid, emperor: emperor.id, ally: winner.id, allyPayment, aid: { recipient: credit?.recipient ?? null, amount: credit?.amount ?? 0 }, emperorSpice: emperor.spice, prepaid, missing, blocked, }; } /** Public roster only: card identity, bidder hand and native wallet do not * determine who must acknowledge the original obligation. */ export function requiredEmperorPaymentResponders(g: Game): string[] { if (!g.nexusCards?.cards || !g.auction?.bidder || !ids(g.order) || g.order.length !== g.players.length || !ids(g.players.map(player => player.id)) || !g.order.every(id => g.players.some(player => player.id === id))) return []; const emperors = g.players.filter(player => player.faction === 'emperor'); if (emperors.length !== 1) return []; const emperor = emperors[0]; const winner = g.players.find(player => player.id === g.auction!.bidder); if (!winner || winner.id === emperor.id || winner.ally !== emperor.id || emperor.ally !== winner.id) return []; return g.order.filter(id => { const player = g.players.find(seat => seat.id === id)!; return id !== emperor.id && id !== winner.id && !player.ally && !!g.nexusCards!.cards!.hands[id]; }); } export function emperorPaymentReceiptSignature(receipt: EmperorPaymentReceipt): string { return JSON.stringify(['nexusEmperorPaymentReceipt', receipt.event, receipt.turn, receipt.phase, receipt.auctionIndex, receipt.card, receipt.winner, receipt.price, receipt.emperor, receipt.ally, receipt.allyPayment, receipt.aid.recipient, receipt.aid.amount, receipt.emperorSpice, receipt.order, receipt.holders, receipt.required, receipt.passed, receipt.stage, receipt.owner]); } export function createEmperorPaymentReceipt( g: Game, event: string, required: readonly string[], ): EmperorPaymentReceipt { const quote = emperorPaymentSourceQuote(g); requirePayment(quote && text(event) && ids(required) && required.length > 0 && sameIds(required, requiredEmperorPaymentResponders(g)), 'Emperor payment needs the original ordinary winning obligation and public responders.'); const receipt: EmperorPaymentReceipt = { event, turn: g.turn, phase: 3, auctionIndex: quote.auctionIndex, card: quote.card, winner: quote.winner, price: quote.price, emperor: quote.emperor, ally: quote.ally, allyPayment: quote.allyPayment, aid: { ...quote.aid }, emperorSpice: quote.emperorSpice, order: [...g.order], holders: g.order.filter(id => !!g.nexusCards!.cards!.hands[id]), required: [...required], passed: [], stage: 'pending', owner: null, signature: '', }; receipt.signature = emperorPaymentReceiptSignature(receipt); return receipt; } function sameSource(receipt: EmperorPaymentReceipt, quote: EmperorPaymentQuote): boolean { return receipt.auctionIndex === quote.auctionIndex && receipt.card === quote.card && receipt.winner === quote.winner && receipt.price === quote.price && receipt.emperor === quote.emperor && receipt.ally === quote.ally && receipt.allyPayment === quote.allyPayment && receipt.aid.recipient === quote.aid.recipient && receipt.aid.amount === quote.aid.amount && receipt.emperorSpice === quote.emperorSpice; } /** Pending receipts bind the actual original source. Completed history retains * its own original facts, not future wallets, alliances, or physical hand zones. */ export function validateEmperorPaymentReceipt(g: Game, receipt: EmperorPaymentReceipt): void { requirePayment(object(receipt) && Object.keys(receipt).sort().join(',') === 'aid,ally,allyPayment,auctionIndex,card,emperor,emperorSpice,event,holders,order,owner,passed,phase,price,required,signature,stage,turn,winner' && text(receipt.event) && count(receipt.turn) && receipt.turn > 0 && receipt.turn <= g.turn && receipt.phase === 3 && (receipt.turn < g.turn || g.phase >= 3) && count(receipt.auctionIndex) && text(receipt.card) && text(receipt.winner) && count(receipt.price) && receipt.price > 0 && text(receipt.emperor) && receipt.emperor !== receipt.winner && receipt.ally === receipt.winner && count(receipt.allyPayment) && receipt.allyPayment <= receipt.price && object(receipt.aid) && Object.keys(receipt.aid).sort().join(',') === 'amount,recipient' && count(receipt.aid.amount) && (receipt.aid.recipient === null ? receipt.aid.amount === 0 : text(receipt.aid.recipient) && g.players.some(player => player.id === receipt.aid.recipient)) && count(receipt.emperorSpice) && ids(receipt.order) && ids(receipt.holders) && ids(receipt.required) && ids(receipt.passed) && receipt.required.length > 0 && receipt.order.length === g.players.length && receipt.order.every(id => g.players.some(player => player.id === id)) && g.players.some(player => player.id === receipt.emperor && player.faction === 'emperor') && receipt.order.includes(receipt.winner) && sameIds(receipt.holders, receipt.order.filter(id => receipt.holders.includes(id))) && sameIds(receipt.required, receipt.order.filter(id => receipt.required.includes(id))) && receipt.required.every(id => receipt.holders.includes(id) && id !== receipt.emperor && id !== receipt.winner) && receipt.passed.length <= receipt.required.length && receipt.passed.every((id, index) => id === receipt.required[index]) && ((receipt.stage === 'pending' && receipt.owner === null && receipt.passed.length < receipt.required.length) || (receipt.stage === 'passed' && receipt.owner === null && receipt.passed.length === receipt.required.length) || (receipt.stage === 'used' && text(receipt.owner) && receipt.owner === receipt.required[receipt.passed.length])) && text(receipt.signature) && receipt.signature === emperorPaymentReceiptSignature(receipt), 'Emperor payment lost its strict original obligation or ordered acknowledgements.'); if (receipt.stage === 'pending') { const quote = emperorPaymentSourceQuote(g); requirePayment(receipt.turn === g.turn && quote && sameSource(receipt, quote) && sameIds(receipt.order, g.order), 'Emperor payment lost its original current auction, alliance, aid or native spice.'); } if (receipt.stage === 'used') { const prepaid = receipt.aid.recipient === receipt.winner ? Math.min(receipt.aid.amount, receipt.price) : 0; requirePayment((receipt.aid.recipient === null || receipt.aid.recipient === receipt.winner || receipt.aid.amount === 0) && receipt.allyPayment <= prepaid && prepaid < receipt.price && receipt.emperorSpice >= receipt.price - prepaid, 'Emperor payment Use lacks its original full-price funding.'); } } /** Pure entitlement check. The engine spends the one physical card and commits * the additional credit only after this succeeds and its native checks pass. */ export function emperorPaymentUseBlock( g: Game, receipt: EmperorPaymentReceipt, owner: string, ): string | null { try { validateEmperorPaymentReceipt(g, receipt); } catch (error) { return error instanceof Error ? error.message : 'Invalid Emperor payment obligation.'; } const pending = g.pendingNexusEmperorPayment?.receipt; try { if (pending && pending !== receipt) validateEmperorPaymentReceipt(g, pending); } catch (error) { return error instanceof Error ? error.message : 'Invalid current Emperor payment obligation.'; } if (receipt.stage !== 'pending' || g.nexusEmperorPayment?.current !== receipt.event || !pending || pending.event !== receipt.event || pending.signature !== receipt.signature || g.nexusEmperorPayment?.completed.some(completed => completed.event === receipt.event) || receipt.required[receipt.passed.length] !== owner || g.active !== owner || g.decision?.kind !== 'nexusEmperorPayment' || g.decision.player !== owner || g.decision.event !== receipt.event) return 'Only the current original Emperor payment responder may use this obligation.'; if (g.truthtrance || g.nexusTraitorExchanges?.some(exchange => exchange.stage === 'return') || g.nexusCards?.phase?.stage === 'drawing' || g.response || g.pendingKarama) return 'Finish the current question, card return or response first.'; const player = g.players.find(seat => seat.id === owner); if (!player || player.ally || owner === receipt.emperor || owner === receipt.winner || g.nexusCards?.cards?.hands[owner] !== 'emperor') return 'Use requires your actual unallied Emperor Nexus Betrayal card.'; const quote = emperorPaymentSourceQuote(g); if (!quote || !sameSource(receipt, quote)) return 'The original Emperor payment source changed.'; return quote.blocked; }