Connect classic allied Bureaucrat payment redirection

This commit is contained in:
Quintine committed 2026-10-13 08:24:56 +11:00
1 parent ec574ab0f5
commit c3d1896673
17 files changed
+1057 -220

No files matched your search

+27 -4
View File
@@ -9,6 +9,9 @@ export type BureaucratPaymentSource = {
payer: string;
payee: string;
amount: number;
/** Exact internal allied-payer receipts; never projected as public shares. */
contributions?: readonly { payer: string; amount: number }[];
privateFunding?: true;
signature: string;
};
/** The one physical skill card's use survives death, redraw and controller changes. */
@@ -23,7 +26,7 @@ export type BureaucratPaymentView = {
pending: null | {
event: string;
owner: string;
payer: string;
payer: string | null;
payee: string;
amount: number;
kind: BureaucratPaymentKind;
@@ -55,6 +58,7 @@ export function quoteBureaucratPayment(input: {
payer: string;
payee: string;
amount: number;
contributions?: readonly { payer: string; amount: number }[];
turn: number;
phase: number;
used: readonly BureaucratPaymentUse[];
@@ -73,11 +77,30 @@ export function quoteBureaucratPayment(input: {
throw new BureaucratPaymentError(
'Bureaucrat needs a valid actual player payment.',
);
let eligible = input.owner !== input.payer && input.payer !== input.payee &&
input.amount >= 5;
if (input.contributions) {
let total = 0;
eligible = false;
for (let index = 0; index < input.contributions.length; index++) {
const contribution = input.contributions[index];
if (!contribution.payer || !Number.isSafeInteger(contribution.amount) ||
contribution.amount <= 0)
throw new BureaucratPaymentError('Bureaucrat needs distinct actual positive payer contributions.');
for (let prior = 0; prior < index; prior++) {
if (input.contributions[prior].payer === contribution.payer)
throw new BureaucratPaymentError('Bureaucrat needs distinct actual positive payer contributions.');
}
total += contribution.amount;
if (contribution.amount >= 5 && contribution.payer !== input.owner &&
contribution.payer !== input.payee) eligible = true;
}
if (!Number.isSafeInteger(total) || total !== input.amount)
throw new BureaucratPaymentError('Bureaucrat contributions must equal the actual recipient payment.');
}
return input.owner &&
input.owner !== input.payer &&
input.owner !== input.payee &&
input.payer !== input.payee &&
input.amount >= 5 &&
eligible &&
!bureaucratUsed(input.used, input.turn, input.phase)
? { redirect: 2, income: input.amount - 2 }
: null;
+32 -7
View File
@@ -8449,7 +8449,7 @@ function nativeBureaucrat(g: Game) {
getPlayer(g,a.owner).leaders.some(l => l.id === a.leader && !l.dead && !l.capturedBy && !l.gholaBy) &&
!g.battle?.leaderSkillHidden?.[a.owner]);
}
function bureaucratPaymentQuote(g: Game,source: Pick<BureaucratPaymentSource,'payer'|'payee'|'amount'>) {
function bureaucratPaymentQuote(g: Game,source: Pick<BureaucratPaymentSource,'payer'|'payee'|'amount'|'contributions'>) {
const owner = nativeBureaucrat(g)?.owner ?? null;
return quoteBureaucratPayment({...source,owner,turn:g.turn,phase:g.phase,used:g.bureaucratPayments?.used ?? []});
}
@@ -8460,15 +8460,29 @@ function checkBureaucratContributors(g: Game,payee:string|undefined,shares:{paye
if (!owner || owner === payee || bureaucratUsed(g.bureaucratPayments?.used ?? [],g.turn,g.phase) ||
!paid.some(share => share.amount >= 5 && share.payer !== owner && share.payer !== payee)) return;
requireRule(bureaucratPaymentModeSupported(g),'Bureaucrat with other optional modules is still being integrated.');
requireRule(paid.length === 1 && !privateFunding,'Bureaucrat on allied funding or multiple payer contributions is awaiting private payment-split integration.');
requireRule(paid.length === 1 && !privateFunding || !g.homeworlds && !g.expansions.length,
'Bureaucrat on allied funding or multiple payer contributions with Homeworlds or expansion factions is awaiting integration.');
}
function stampBureaucratPayment(g: Game,kind:BureaucratPaymentSource['kind'],payer:string,payee:string,amount:number):
function stampBureaucratPayment(g: Game,kind:BureaucratPaymentSource['kind'],payer:string,payee:string,amount:number,
contributions?:BureaucratPaymentSource['contributions']):
{bureaucratPayment?:BureaucratPaymentSource;bureaucratPaymentEvent?:string} {
if (!g.leaderSkills || !bureaucratPaymentModeSupported(g) || amount < 5 || payer === payee) return {};
const source:BureaucratPaymentSource = {event:crypto.randomUUID(),turn:g.turn,phase:g.phase,kind,payer,payee,amount,signature:''};
const source:BureaucratPaymentSource = {event:crypto.randomUUID(),turn:g.turn,phase:g.phase,kind,payer,payee,amount,
...(contributions ? {contributions,privateFunding:true as const} : {}),signature:''};
source.signature = bureaucratPaymentSignature(source);
return {bureaucratPayment:source,bureaucratPaymentEvent:source.event};
}
function stampBureaucratContributors(g:Game,kind:'auction'|'shipment',payee:string,
shares:{payer:string;amount:number}[],privateFunding:boolean) {
const paid = shares.filter(share => share.amount > 0);
if (!privateFunding && paid.length === 1)
return stampBureaucratPayment(g,kind,paid[0].payer,payee,paid[0].amount);
if (g.homeworlds || g.expansions.length) return {};
const owner = nativeBureaucrat(g)?.owner;
const qualifying = paid.find(share => share.amount >= 5 && share.payer !== owner && share.payer !== payee);
return qualifying ? stampBureaucratPayment(g,kind,qualifying.payer,payee,
paid.reduce((total,share) => total + share.amount,0),paid) : {};
}
function bureaucratPaymentFrame(g: Game): string {
return JSON.stringify({turn:g.turn,phase:g.phase,active:g.active,order:g.order,
players:g.players.map(p => ({id:p.id,spice:p.spice,bribes:p.bribes,hand:p.hand,leaders:p.leaders,forces:p.forces,
@@ -8484,6 +8498,15 @@ function validateBureaucratSource(g: Game,source:BureaucratPaymentSource) {
g.players.some(p => p.id === source.payer) && g.players.some(p => p.id === source.payee) && source.payer !== source.payee &&
Number.isSafeInteger(source.amount) && source.amount >= 5 && source.signature === bureaucratPaymentSignature(source),
'The saved Bureaucrat payment changed its actual payer, payee, amount or event.');
if (source.contributions) {
requireRule(!g.homeworlds && !g.expansions.length && source.privateFunding === true &&
(source.kind === 'auction' || source.kind === 'shipment') &&
source.contributions.length >= 1 && source.contributions.length <= 2 &&
source.contributions.every(share => g.players.some(player => player.id === share.payer)) &&
source.contributions.some(share => share.payer === source.payer && share.amount >= 5),
'The saved Bureaucrat payment lost its original allied contributors.');
bureaucratPaymentQuote(g,source);
} else requireRule(!source.privateFunding,'The private Bureaucrat payment lost its actual payer contributions.');
}
function bureaucratPaymentIntegrity(g: Game) {
const state = g.bureaucratPayments,events = g.bureaucratUseEvents ?? [];
@@ -8561,7 +8584,8 @@ function projectedBureaucrat(g: Game,viewer: string): BureaucratPaymentView {
const pending = g.bureaucratPayments?.pending;
const privateRevival = pending?.source.kind === 'revival' &&
![pending.owner,pending.source.payer,pending.source.payee,pending.resume.response?.recipient].includes(viewer);
return {pending:pending && !privateRevival ? {event:pending.source.event,owner:pending.owner,payer:pending.source.payer,payee:pending.source.payee,
return {pending:pending && !privateRevival ? {event:pending.source.event,owner:pending.owner,
payer:pending.source.privateFunding ? null : pending.source.payer,payee:pending.source.payee,
amount:pending.source.amount,kind:pending.source.kind,redirect:2} : null,
usedThisPhase:bureaucratUsed(g.bureaucratPayments?.used ?? [],g.turn,g.phase)};
}
@@ -12246,7 +12270,7 @@ function auctionBureaucratParties(g:Game,p:Player,amount:number,allyPayment:numb
}
function stampAuctionBureaucrat(g:Game,p:Player,amount:number,allyPayment:number,seller?:string) {
const {payee,shares} = auctionBureaucratParties(g,p,amount,allyPayment,seller);
return payee && !allyPayment && shares.length === 1 ? stampBureaucratPayment(g,'auction',shares[0].payer,payee,amount) : {};
return payee ? stampBureaucratContributors(g,'auction',payee,shares,allyPayment > 0) : {};
}
function auctionNext(g: Game) {
const a = g.auction!;
@@ -16578,7 +16602,8 @@ function guildPaymentResponse(g: Game, owner: string, amounts: number[],payer?:s
return { kind: 'guildIncome', owner, amount, passed: [],
...(g.homeworldOccupationPreview ? { occupiedPayment: { event: crypto.randomUUID(),
contributions: named ?? [{ payer: payer!, amount }] } } : {}),
...(contributions.length === 1 && payer ? stampBureaucratPayment(g,'shipment',payer,owner,amount) : {}),
...(named ? stampBureaucratContributors(g,'shipment',owner,named,!payer)
: contributions.length === 1 && payer ? stampBureaucratPayment(g,'shipment',payer,owner,amount) : {}),
...(g.homeworlds?.custody ? { guildContributions: contributions,
guildPaymentProof: guildPaymentSignature(g, owner, amount, contributions) } : {}) };
}
+1 -1
View File
@@ -4802,7 +4802,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Spice Banker can commit one through three own spice with its Battle Plan, separate from force support. Only the surviving selected skilled disc gains that strength. The amount stays private until authorized plan inspection or public reveal. It is paid on win or loss, including leader death, except a sole traitor-call winner spends nothing. Normal deferred income connects only in the separate fresh classic or supported native banker-income profiles, including the bounded Advanced Moritani roster; ordinary/default starts are unchanged.',
'Diplomat may use one committed Worthless as the opponent’s canonical printed defensive role when you played no actual defense: Shield, Snooper, Shield Snooper, defensive Weirding Way or Chemistry, or sealed Portable Snooper. Hybrid protection, Weirding’s non-Shield behavior and Chemistry’s Poison Tooth exception remain distinct. Copying the used role without its activation or own-slot prerequisite is the explicit first-version interpretation. Choose or decline after reveal, before traitors; the Worthless must be discarded even if you win. Already-open base-only battles keep that source window. A native Diplomat can protect its own selected disc; a captive grants no upper band. A surviving trained loser may retreat selected undialed physical counters up to printed/copied disc strength to one empty adjacent non-stronghold before losses. Late/mirror/numeric modifications remain separate.',
'Normal Mentat questions use the user-approved uniform private card-show step before posture and faction powers in supported Leader Skills games. The opponent shows the named held weapon or chooses another held card, even for an exact match or sole eligible card. Weirding Way is a nameable default weapon; Chemistry is not. Cards stay held and need not enter a Battle Plan. New battles activate the question without an extra opt-in; already-opened unversioned battles retain their original flow. Empty hands skip without an answer. Private history records an observation, not a later hand guarantee.',
'Bureaucrat may redirect two spice when one other player pays at least five to a player other than the skill owner, once per phase. The payer still pays in full; the recipient receives two less. Declining keeps the use available for a later payment. Paid bribes qualify when transferred, with their remaining share unavailable until Mentat Pause. Supported auctions, Guild income, Richese sales and paid Tleilaxu revival have connected paths; a separate free-revival award does not make a smaller price qualify. Allied payer shares below five each, such as three plus three, do not qualify merely because their total exceeds five and may proceed through original payment controls. Individually qualifying split funding remains guarded. By user ruling, Emperor gifts are not payments: no Bureaucrat prompt/use or threshold disclosure, and exact gift amounts remain private. Other payment-family/module interactions remain unfinished.',
'Bureaucrat may redirect two spice when one other player pays at least five to a player other than the skill owner, once per phase. The payer still pays in full; the recipient receives two less. Declining keeps the use available for a later payment. Paid bribes qualify when transferred, with their remaining share unavailable until Mentat Pause. Supported auctions, Guild income, Richese sales and paid Tleilaxu revival have connected paths; a separate free-revival award does not make a smaller price qualify. Allied payer shares below five each, such as three plus three, never pool to qualify. Supported classic games without Homeworlds additionally connect qualifying allied-funded auctions and shipments, including fully ally-funded five: one optional two-spice diversion from the gross recipient receipt, unchanged full contributor costs and original Karama income cancellation first. A trainer or payee contribution cannot qualify, but does not hide an eligible other payer. The prompt shows public gross and recipient, not private shares or which payer qualified. Qualifying expansion-faction funding and split-recipient Homeworld income remain guarded. By user ruling, Emperor gifts are not payments: no Bureaucrat prompt/use or threshold disclosure, and exact gift amounts remain private. Other payment-family/module interactions remain unfinished.',
'Smuggler records all existing positive battle-territory piles when plans reveal, capped in total by the selected leader’s unmodified strength. A surviving controller collects even when losing or no forces remain. A partial collection across several piles asks that owner for exact sector amounts; all/none and single-pile collection stay automatic. This allocation policy is provisional. Pending spice cannot fund the plan. Collection precedes Sandmaster addition; a drained pile receives none. Modified strength and other combined-module limits remain guarded. Older saved battles retain their recorded behavior.',
'In classic and supported E1/E2 native Tech composition, actual Smuggler prices and normal Banker qualifying fees remain distinct from phase-end Tech income. Tech piles cannot fund their triggering action; Banker front-shield grants still wait for actual Mentat. Winning physical Suk rescue and Sandmaster board spice precede original played-card cleanup and mandatory winner token reward. One token transfers automatically, several require a selection, and no token skips without an invented decline. A later Face Dancer retains the original winner’s cards, bounty and Tech reward, kills the matching trainer without another bounty and replaces only actual survivors from real sources. Native Tleilaxu-only free revival excludes Axlotl; CHOAM initial income is not Charity. Richese’s free companion does not change its marker price or create the concealed counters early.',
'Physical Fremen Ambassador relocation now offers optional Sandmaster destination collection before arrival reactions, without an invented route or ordinary movement use. Same-territory repositioning earns none; concealed relocation and other special movement still retain their own limits. Normal Banker income has separate fresh classic or supported native source-to-Mentat previews with provisional original-earned-faction custody, not general activation. Diplomat retreat retains its documented conservative outcome order. Native Advanced foreign gholas/Face Dancers, skill-first Moritani assassination and ordinary-disc Ecaz skills follow their bounded contracts; shared-Duke assignment, combined Occupy skills and other combined modules remain unfinished. This prototype does not enable public module starts.',