Route Advanced Emperor allied auction funding
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 02:48:11 +11:00
1 parent 3ba9087405
commit 599af2475d
19 files changed
+883 -35

No files matched your search

+28 -1
View File
@@ -3,6 +3,7 @@ import type { FactionId } from './catalog';
import type { Game, ResponseWindow } from './engine';
import { requireRicheseDeclarationCache } from './richese-settlement';
import { quoteNormalAuctionRetirement } from './normal-auction-retirement';
import { routeAuctionPayment } from './auction-payment-routing';
export type AuctionContinuationSeat = {
id: string;
@@ -97,6 +98,8 @@ export function auctionEmperorIncomeAmount(
sale: NonNullable<Game['currentAuctionSale']>,
): number {
if (sale.free || sale.recipient === 'bank') return 0;
if (sale.paymentRouting) return sale.paymentRouting.filter(leg => leg.recipient === 'emperor')
.reduce((total, leg) => total + leg.amount, 0);
return sale.origin === 'normal' || sale.seller === sale.winner
? sale.amount
: sale.richeseContribution ?? 0;
@@ -322,6 +325,28 @@ function calculate(
);
const winner = seated(sale.winner)!;
requireAuction(winner, 'The auction winner is no longer seated.');
if (sale.paymentRouting !== undefined) {
const emperor = input.players.find(player => player.faction === 'emperor');
requireAuction(input.advanced && !sale.free && Array.isArray(sale.paymentRouting) &&
sale.paymentRouting.length > 0 && sale.paymentRouting.every(leg => seated(leg.payer)) &&
JSON.stringify(sale.paymentRouting) === JSON.stringify(routeAuctionPayment({
advanced: true, free: false, price: sale.amount, buyer: winner.id,
emperor: emperor?.id ?? null, seller: sale.seller, bankOnly: sale.recipient === 'bank',
contributions: sale.paymentRouting.map(({payer, amount}) => ({payer, amount})),
})) && sale.paymentRouting.some(leg => leg.payer === emperor?.id &&
leg.payer !== winner.id && leg.recipient === 'bank'),
'The paid auction lost its original Advanced Emperor bank contribution.');
if (sale.origin === 'normal') {
const a = input.auction!;
const donors = a.coalitionContributions ?? (a.allyPayment ? [{donor:winner.ally, amount:a.allyPayment}] : []);
const expected = donors.map(({donor,amount}) => ({payer:donor, amount}));
const own = sale.amount - expected.reduce((sum, leg) => sum + leg.amount, 0);
if (own > 0) expected.unshift({payer:winner.id, amount:own});
requireAuction(JSON.stringify(sale.paymentRouting.map(({payer,amount}) => ({payer,amount}))) === JSON.stringify(expected),
'The paid auction contributions no longer match the original bid.');
} else requireAuction(sale.paymentRouting.every(leg => leg.payer === winner.id || leg.payer === winner.ally),
'The paid Richese lot lost its original buyer or ally.');
}
let stage = operation.kind;
const steps: AuctionContinuationQuote['steps'] = [];
if (operation.kind === 'cancel') {
@@ -387,7 +412,9 @@ function calculate(
if (!sale.free && sale.origin !== 'normal' && sale.seller !== winner.id) {
if (sale.recipient !== 'bank') {
const seller = seated(sale.seller!)!;
const amount = sale.amount - (sale.richeseContribution ?? 0);
const amount = sale.paymentRouting
? sale.paymentRouting.filter(leg => leg.recipient === 'seller').reduce((sum, leg) => sum + leg.amount, 0)
: sale.amount - (sale.richeseContribution ?? 0);
requireAuction(
count(seller.spice) && count(seller.spice + amount),
'The seller credit would overflow its current balance.',
+83
View File
@@ -0,0 +1,83 @@
/** Actual positive payer legs of a single paid physical Treachery auction.
* The engine supplies already-authorized, funded contributors and owns the
* debit, escrow, income responses and any later diversion or cancellation. */
export type AuctionPaymentInput = Readonly<{
advanced: boolean;
free: boolean;
price: number;
buyer: string;
emperor: string | null;
/** Null is an ordinary auction; a Richese seller may retain its own card. */
seller: string | null;
/** Existing seller-income prevention may route the entire invoice to Bank. */
bankOnly?: boolean;
contributions: readonly Readonly<{ payer: string; amount: number }>[];
}>;
export type AuctionPaymentLeg = {
payer: string;
amount: number;
recipient: 'bank' | 'emperor' | 'seller';
};
export class AuctionPaymentRoutingError extends Error {
constructor(message: string) { super(message); this.name = 'AuctionPaymentRoutingError'; }
}
function check(condition: unknown, message: string): asserts condition {
if (!condition) throw new AuctionPaymentRoutingError(message);
}
const id = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value.trim() === value;
const amount = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0;
/**
* Route the exact already-funded invoice without touching any wallet.
* `seller` identifies Richese, not a generic normal-auction bank seller.
* In Advanced, Emperor's direct contribution to another buyer goes to Bank;
* Richese's contribution to another buyer of its own card goes to Emperor or
* Bank. A direct Emperor gift is part of the buyer's own leg, not aid.
*
* Payer identities/alliances and escrow authority are checked by the caller;
* this helper ensures the invoice has unique, positive, exactly funded legs.
* Free acquisitions carry no paid legs, even when their nominal price is > 0.
*/
export function routeAuctionPayment(input: AuctionPaymentInput): AuctionPaymentLeg[] {
check(!!input && typeof input === 'object' && !Array.isArray(input) &&
typeof input.advanced === 'boolean' && typeof input.free === 'boolean' &&
amount(input.price) && id(input.buyer) &&
(input.emperor === null || id(input.emperor)) &&
(input.seller === null || id(input.seller)) &&
(input.bankOnly === undefined || typeof input.bankOnly === 'boolean') &&
Array.isArray(input.contributions), 'Supply one valid auction invoice.');
check(input.seller === null || input.seller !== input.emperor,
'The Richese seller and Emperor must be distinct factions.');
const seen = new Set<string>();
let total = 0;
for (const leg of input.contributions) {
check(!!leg && typeof leg === 'object' && !Array.isArray(leg) && id(leg.payer) &&
amount(leg.amount) && leg.amount > 0 && !seen.has(leg.payer),
'Every actual payer must have one distinct positive contribution.');
seen.add(leg.payer);
total += leg.amount;
check(Number.isSafeInteger(total), 'The total auction invoice exceeds safe integer bounds.');
}
if (input.free) {
check(input.contributions.length === 0, 'A free acquisition cannot carry paid contributions.');
return [];
}
check(total === input.price, 'Actual auction contributions must exactly fund the price.');
const emperorOrBank = (): AuctionPaymentLeg['recipient'] =>
input.emperor ? 'emperor' : 'bank';
return input.contributions.map(leg => {
let recipient: AuctionPaymentLeg['recipient'];
if (input.bankOnly) recipient = 'bank';
else if (input.advanced && input.emperor && leg.payer === input.emperor && leg.payer !== input.buyer)
recipient = 'bank';
else if (input.seller === null || input.seller === input.buyer)
recipient = input.emperor && input.emperor !== input.buyer ? 'emperor' : 'bank';
else if (input.advanced && leg.payer === input.seller)
recipient = emperorOrBank();
else recipient = 'seller';
return {payer:leg.payer, amount:leg.amount, recipient};
});
}
+79 -18
View File
@@ -1,5 +1,6 @@
import { createTwoPlayerControllers, validateTwoPlayerControllersLobby, validateTwoPlayerControllersComplete,
addTwoPlayerControlledSeat, TwoPlayerControllersError, type TwoPlayerControllers } from './two-player-controllers';
import { routeAuctionPayment, type AuctionPaymentLeg } from './auction-payment-routing';
import { createFutureBattleLeader, bindFutureBattleLeader, settleFutureBattleLeader, releaseFutureBattleLeader,
expireFutureBattleLeader, futureBattleLeaderTiming, futureBattleLeaderWindow, type FutureBattleLeaderReceipt } from './future-battle-leader';
import { createFactionDraft, validateFactionDraft, currentFactionDrafter, chooseFaction,
@@ -2098,6 +2099,8 @@ export type Game = {
recipient?: 'bank';
/** Actual Richese donor share of another buyer's lot; paid to Emperor or bank. */
richeseContribution?: number;
/** New Advanced invoices preserve each actual destination through responses. */
paymentRouting?: AuctionPaymentLeg[];
} | null;
pendingAmbassador?: {
/** Fresh entries retain their actual arrival source; older frames omit it. */
@@ -11366,7 +11369,8 @@ function spiceBankerIncomeIntegrity(g: Game): void {
const payment = carrier?.spiceBankerIncomePayment;
if (!payment) {
requireRule(!carrier || ('winner' in carrier ? carrier.free ||
(carrier.recipient !== 'bank' && auctionEmperorIncomeAmount(carrier) === 0) :
(carrier.recipient !== 'bank' && auctionEmperorIncomeAmount(carrier) === 0 &&
!carrier.paymentRouting?.some(leg => leg.recipient === 'bank')) :
carrier.kind !== 'guildIncome' &&
!(carrier.kind === 'revivalIncome' && carrier.recipient !== carrier.owner && (carrier.amount ?? 0) > 1)),
'The original paid transaction lost its native bank invoice.');
@@ -11379,6 +11383,13 @@ function spiceBankerIncomeIntegrity(g: Game): void {
new Set(payment.legs.map(leg => leg.payer)).size === payment.legs.length,
'The original paid bank transaction changed its event or contribution legs.');
if (carrier && 'winner' in carrier) {
if (carrier.paymentRouting) {
const expected = carrier.paymentRouting.filter(leg => leg.recipient !== 'seller')
.map(leg => ({payer:leg.payer, amount:leg.amount, recipient:leg.recipient === 'emperor' ? 'player' : 'bank'}));
requireRule(payment.kind === 'auction' && !carrier.free && JSON.stringify(payment.legs) === JSON.stringify(expected),
'The deferred bank invoice no longer matches its original auction payer legs.');
continue;
}
const amount = carrier.recipient === 'bank' ? carrier.amount : auctionEmperorIncomeAmount(carrier);
const contributionOnly = carrier.origin !== 'normal' && carrier.seller !== carrier.winner && carrier.recipient !== 'bank';
requireRule(payment.kind === 'auction' && !carrier.free &&
@@ -11590,7 +11601,18 @@ function bureaucratPaymentIntegrity(g: Game) {
requireRule(g.leaderSkills && bureaucratPaymentModeSupported(g) && carrier.bureaucratPaymentEvent === source.event,
'The saved Bureaucrat payment lost its module or independent event.');
validateBureaucratSource(g,source);
if ('winner' in carrier) requireRule(source.kind === 'auction' && !carrier.free &&
if ('winner' in carrier && carrier.paymentRouting) {
const sellerIncome = carrier.origin !== 'normal' && carrier.seller !== carrier.winner;
const shares = carrier.paymentRouting.filter(leg => leg.recipient === (sellerIncome ? 'seller' : 'emperor'))
.map(leg => ({payer:leg.payer,amount:leg.amount}));
requireRule(source.kind === 'auction' && !carrier.free &&
source.payee === (sellerIncome ? carrier.seller : byFaction(g,'emperor')?.id) &&
source.amount === shares.reduce((sum,share) => sum + share.amount,0) &&
shares.some(share => share.payer === source.payer) &&
(!source.contributions || JSON.stringify(source.contributions) === JSON.stringify(shares)) &&
(sellerIncome ? source.richeseSaleAmount === carrier.amount : source.richeseSaleAmount === undefined),
'The Bureaucrat source no longer matches its paid auction sale.');
} else if ('winner' in carrier) requireRule(source.kind === 'auction' && !carrier.free &&
(source.richeseSaleAmount !== undefined
? g.advanced && carrier.origin !== 'normal' && carrier.seller !== carrier.winner &&
(carrier.richeseContribution ?? 0) > 0 && source.richeseSaleAmount === carrier.amount &&
@@ -15414,15 +15436,25 @@ function settleRicheseSoldLot(g: Game, quote: Extract<RicheseSettlementQuote,{ki
if (!confirmed && beginNexusEmperorSellerPurchase(g, quote, bank)) return;
const lot = g.richeseAuction!, owner = getPlayer(g,lot.owner);
const winner = getPlayer(g, quote.winner), card = quote.card;
const emperor = byFaction(g, 'emperor');
const paymentRouting = !free && g.advanced && emperor && winner.ally === emperor.id && quote.allyPayment > 0
? routeAuctionPayment({advanced:true, free:false, price:quote.amount, buyer:winner.id,
emperor:emperor.id, seller:owner.id, bankOnly:bank, contributions:[
...(quote.ownPayment > 0 ? [{payer:winner.id,amount:quote.ownPayment}] : []),
{payer:emperor.id,amount:quote.allyPayment},
]}) : undefined;
const richeseContribution = !free && g.advanced && winner.id !== owner.id && winner.ally === owner.id
? quote.allyPayment : 0;
const bankAmount = free ? 0 : bank || winner.id === owner.id ? quote.amount : richeseContribution;
const emperor = byFaction(g, 'emperor');
const incomePayment = g.spiceBankerIncomePreview && bankAmount > 0 ? bankerIncomePayment(g, 'auction',
const routedBankLegs = paymentRouting?.filter(leg => leg.recipient !== 'seller').map(leg => ({
payer:leg.payer, amount:leg.amount, recipient:leg.recipient === 'emperor' ? 'player' as const : 'bank' as const,
}));
const incomePayment = g.spiceBankerIncomePreview && (routedBankLegs?.length || bankAmount > 0) ? bankerIncomePayment(g, 'auction',
routedBankLegs ?? (
bank || winner.id === owner.id
? [{payer:winner.id,amount:quote.ownPayment,recipient:bank || !emperor ? 'bank' : 'player'},
...(quote.allyPayment ? [{payer:winner.ally!,amount:quote.allyPayment,recipient:bank || !emperor ? 'bank' as const : 'player' as const}] : [])]
: [{payer:owner.id,amount:richeseContribution,recipient:emperor ? 'player' : 'bank'}]) : undefined;
: [{payer:owner.id,amount:richeseContribution,recipient:emperor ? 'player' : 'bank'}])) : undefined;
if (!free) payWithAlly(g, winner, quote.amount, quote.allyPayment);
g.richeseFunding = {};
const retained = lot.source === 'blackMarket' && winner.id === owner.id;
@@ -15434,10 +15466,14 @@ function settleRicheseSoldLot(g: Game, quote: Extract<RicheseSettlementQuote,{ki
}
g.currentAuctionSale = {
...(g.homeworldOccupationPreview ? { occupiedEvent: lot.event,
occupiedContributions: free ? [] : [{ payer: winner.id, amount: quote.ownPayment },
...(quote.allyPayment ? [{ payer: winner.ally!, amount: quote.allyPayment }] : [])] } : {}),
occupiedContributions: free ? [] : paymentRouting
? paymentRouting.filter(leg => leg.recipient === (winner.id === owner.id ? 'emperor' : 'seller'))
.map(({payer,amount}) => ({payer,amount}))
: [{ payer: winner.id, amount: quote.ownPayment },
...(quote.allyPayment ? [{ payer: winner.ally!, amount: quote.allyPayment }] : [])] } : {}),
...(richeseContribution ? {richeseContribution} : {}),
...(incomePayment ? {spiceBankerIncomePayment:incomePayment} : {}),
...(paymentRouting ? {paymentRouting} : {}),
winner: winner.id,
amount: quote.amount,
free,
@@ -15446,7 +15482,8 @@ function settleRicheseSoldLot(g: Game, quote: Extract<RicheseSettlementQuote,{ki
...(bank ? {recipient:'bank' as const} : {}),
...(!free ? stampAuctionBureaucrat(g,winner,quote.amount,quote.allyPayment,owner.id) : {}),
};
if (bank || !emperor) awardBankerIncome(g, incomePayment);
if (bank || !emperor || paymentRouting && auctionEmperorIncomeAmount(g.currentAuctionSale) === 0)
awardBankerIncome(g, incomePayment);
log(
g,
`${winner.name} ${retained ? 'retained their own Black Market card under provisional paid self-purchase' : `bought ${lot.source === 'cache' ? card.name : 'the concealed Black Market card'}`} for ${quote.amount} spice.${free ? ' Emperor Nexus retained the proven price; no seller or bank payment occurred.' : ' The funded bid was paid automatically.'}`,
@@ -16318,9 +16355,16 @@ function coalitionFundingAvailable(g: Game, p: Player, committedBid = false): bo
if (!group || group.members.length < 3) return false;
const groupHasUnsettled = (records: readonly {player: string; fulfilled?: boolean; released?: boolean}[] | undefined) =>
!!records?.some(record => group.members.includes(record.player) && !record.fulfilled && !record.released);
// A current No bribe answer only constrains later ordinary bribes. Moving
// spice into alliance escrow or paying a bid/shipment does not satisfy it.
// Positive and stale answers still need their funding preserved separately.
const groupHasFundedBribe = (records: readonly {player: string; turn: number; answer: boolean;
fulfilled?: boolean; released?: boolean}[] | undefined) => !!records?.some(record =>
group.members.includes(record.player) && !record.fulfilled && !record.released &&
(record.turn !== g.turn || record.answer));
return committedBid || ![g.bidPromises, g.phaseBidPromises, g.turnActionPromises,
g.revivalPromises, g.leaderRevivalPromises, g.revivalExpressionPromises,
g.bribePromises, g.bribeExpressionPromises].some(groupHasUnsettled);
g.revivalPromises, g.leaderRevivalPromises, g.revivalExpressionPromises].some(groupHasUnsettled) &&
!groupHasFundedBribe(g.bribePromises) && !groupHasFundedBribe(g.bribeExpressionPromises);
}
function coalitionEmperorGroup(g: Game, p: Player) {
if (!g.coalitionState || p.faction !== 'emperor' || g.status !== 'playing' ||
@@ -17037,9 +17081,11 @@ function payWithAlly(g: Game, p: Player, cost: number, allyPayment: number) {
if (credit) credit.amount -= allyPayment;
}
function auctionBureaucratParties(g:Game,p:Player,amount:number,allyPayment:number,seller?:string) {
const split = g.advanced && allyPayment > 0 && seller !== undefined && seller !== p.id && p.ally === seller;
const emperorBank = g.advanced && allyPayment > 0 && p.ally === byFaction(g,'emperor')?.id;
const split = g.advanced && allyPayment > 0 && seller !== undefined && seller !== p.id &&
(p.ally === seller || emperorBank);
return {payee:seller && seller !== p.id ? seller : byFaction(g,'emperor')?.id,
shares:(split ? [{payer:p.id,amount:amount-allyPayment}]
shares:(split || emperorBank ? [{payer:p.id,amount:amount-allyPayment}]
: [{payer:p.id,amount:amount-allyPayment},...(allyPayment ? [{payer:p.ally!,amount:allyPayment}] : [])]).filter(share => share.amount > 0),
richeseSaleAmount:split ? amount : undefined};
}
@@ -18085,6 +18131,17 @@ function auctionNext(g: Game) {
function settleAuction(g: Game, free = false, automatic = false, emperorNexus = false) {
const a = g.auction!;
const winner = getPlayer(g, a.bidder!);
const emperor = byFaction(g, 'emperor');
const donors = a.coalitionContributions ?? (a.allyPayment && winner.ally
? [{donor:winner.ally, amount:a.allyPayment}] : []);
const donorTotal = donors.reduce((sum, leg) => sum + leg.amount, 0);
const paymentRouting = !free && g.advanced && emperor &&
donors.some(leg => leg.donor === emperor.id && leg.amount > 0)
? routeAuctionPayment({advanced:true, free:false, price:a.bid, buyer:winner.id,
emperor:emperor.id, seller:null, contributions:[
...(a.bid > donorTotal ? [{payer:winner.id, amount:a.bid-donorTotal}] : []),
...donors.map(leg => ({payer:leg.donor, amount:leg.amount})),
]}) : undefined;
if (free) delete a.coalitionContributions;
else if (a.coalitionContributions !== undefined) {
coalitionBidPaymentQuote(g, winner, a.bid, a.coalitionContributions, true);
@@ -18096,18 +18153,21 @@ function settleAuction(g: Game, free = false, automatic = false, emperorNexus =
g.coalitionEscrow = {...g.coalitionEscrow!, pledges:result.pledges};
} else payWithAlly(g, winner, a.bid, a.allyPayment ?? 0);
winner.hand.push(a.cards[a.index]);
const emperor = byFaction(g, 'emperor');
const paysEmperor = !!emperor && emperor.id !== winner.id;
const incomePayment = !free && g.spiceBankerIncomePreview ? bankerIncomePayment(g, 'auction', [
const incomePayment = !free && g.spiceBankerIncomePreview ? bankerIncomePayment(g, 'auction', paymentRouting
? paymentRouting.map(leg => ({payer:leg.payer,amount:leg.amount,
recipient:leg.recipient === 'emperor' ? 'player' as const : 'bank' as const})) : [
{payer: winner.id, amount: a.bid - (a.allyPayment ?? 0), recipient: paysEmperor ? 'player' : 'bank'},
...(a.allyPayment ? [{payer: winner.ally!, amount: a.allyPayment,
recipient: paysEmperor ? 'player' as const : 'bank' as const}] : []),
]) : undefined;
g.currentAuctionSale = {
...(g.homeworldOccupationPreview ? { occupiedEvent: crypto.randomUUID(),
occupiedContributions: [{ payer: winner.id, amount: free ? 0 : a.bid - (a.allyPayment ?? 0) },
...(!free && a.allyPayment ? [{ payer: winner.ally!, amount: a.allyPayment }] : [])] } : {}),
occupiedContributions: paymentRouting ? paymentRouting.filter(leg => leg.recipient === 'emperor')
.map(({payer,amount}) => ({payer,amount})) : [{ payer: winner.id, amount: free ? 0 : a.bid - (a.allyPayment ?? 0) },
...(!free && a.allyPayment ? [{ payer: winner.ally!, amount: a.allyPayment }] : [])] } : {}),
...(incomePayment ? {spiceBankerIncomePayment: incomePayment} : {}),
...(paymentRouting ? {paymentRouting} : {}),
winner: winner.id,
amount: a.bid,
free,
@@ -18115,7 +18175,7 @@ function settleAuction(g: Game, free = false, automatic = false, emperorNexus =
seller: null,
...(!free ? stampAuctionBureaucrat(g,winner,a.bid,a.allyPayment ?? 0) : {}),
};
if (!paysEmperor) awardBankerIncome(g, incomePayment);
if (!paysEmperor || auctionEmperorIncomeAmount(g.currentAuctionSale) === 0) awardBankerIncome(g, incomePayment);
log(
g,
`${faction(winner.faction).name} won a treachery card for ${emperorNexus ? `the Emperor Nexus card after proving ${a.bid} spice` : free ? 'a Karama' : `${a.bid} spice`}.${automatic ? ' The declared spice payment was the only available payment method.' : ''}`,
@@ -18205,7 +18265,8 @@ function commitAuctionContinuation(g: Game, quote: AuctionContinuationQuote,bure
const sale = g.currentAuctionSale!, received = step.amount - (bureaucratDiversion ?? 0);
const source: OccupiedPercentageSource = { kind: 'richese-income', event: crypto.randomUUID(),
native: seller.id, amount: received, payment: { id: sale.occupiedEvent!,
binding: occupiedProducerProof(g, 'richese-income'), paid: sale.amount,
binding: occupiedProducerProof(g, 'richese-income'),
paid: sale.paymentRouting ? sale.occupiedContributions!.reduce((sum,leg) => sum + leg.amount,0) : sale.amount,
contributions: sale.occupiedContributions!, recipient: seller.id, received } };
if (admitOccupiedPercentage(g, source, received, { kind: 'auction', quote: { ...quote, steps: [] } })) return;
}
+1 -1
View File
@@ -100,7 +100,7 @@ export const HELP = {
'A shield protects against projectiles. A snooper protects against poison. One card cannot occupy both battle slots.',
response:
'The table pauses before this power resolves. Other factions can use a legal Karama cancellation; everyone can allow the power.',
ally: 'Set aside spice for an ally’s bids or shipments. You may reclaim unused funds, but a current winning bid must remain funded.',
ally: 'Set aside spice for an ally’s bids or shipments. You may reclaim unused funds, but a current winning bid must remain funded. In Advanced auctions, the Emperor’s direct contribution to an ally’s purchase goes to the bank; a gift already given to the buyer is the buyer’s own payment.',
prescience:
'Only the requested element becomes fixed. Other plan elements stay private until reveal. Low Salusa prohibits free Sardaukar revival, including Ghola; other legal Ghola choices remain available.',
};