fix: bind Richese Bureaucrat to actual recipient portions
This commit is contained in:
1 parent
06342ad34c
commit
7891517a9b
12 files changed
+711
-31
No files matched your search
@@ -24,17 +24,25 @@ export function BureaucratPayment({
|
||||
return null;
|
||||
const payer = game.players.find((player) => player.id === pending.payer)?.name ?? pending.payer;
|
||||
const payee = game.players.find((player) => player.id === pending.payee)?.name ?? pending.payee;
|
||||
const redirectAmount = pending.amount - pending.redirect;
|
||||
return (
|
||||
<section className="min-w-0 space-y-3" aria-label="Bureaucrat payment">
|
||||
<h3 className="font-serif text-xl">Bureaucrat payment</h3>
|
||||
<p className="text-base leading-7">
|
||||
{payer ? <>The payment from {payer} to {payee}</> : <>The qualifying allied-funded payment to {payee}</>}{' '}
|
||||
is {pending.amount} spice. You may redirect {pending.redirect} spice to the Bank.{' '}
|
||||
{pending.homeworldAllocation
|
||||
? <>{redirectAmount} spice then follows the original Homeworld income allocation, including any occupied share.</>
|
||||
: <>{payee} receives {redirectAmount} spice and the Bank receives {pending.redirect}.</>}{' '}
|
||||
{payer ? `${payer} still pays the full amount` : 'Each contributor still pays their full original share'}.
|
||||
{pending.richeseAllocation ? <>
|
||||
The original Richese sale costs {pending.amount} spice. A qualifying portion goes to {payee}.
|
||||
You may redirect {pending.redirect} spice from that portion to the Bank.{' '}
|
||||
{pending.homeworldAllocation
|
||||
? <>The remaining portion follows the original Homeworld income allocation.</>
|
||||
: <>{payee} receives {pending.redirect} less than their original portion.</>}{' '}
|
||||
Each contributor still pays their full original share.
|
||||
</> : <>
|
||||
{payer ? <>The payment from {payer} to {payee}</> : <>The qualifying allied-funded payment to {payee}</>}{' '}
|
||||
is {pending.amount} spice. You may redirect {pending.redirect} spice to the Bank.{' '}
|
||||
{pending.homeworldAllocation
|
||||
? <>{pending.amount - pending.redirect} spice then follows the original Homeworld income allocation, including any occupied share.</>
|
||||
: <>{payee} receives {pending.amount - pending.redirect} spice and the Bank receives {pending.redirect}.</>}{' '}
|
||||
{payer ? `${payer} still pays the full amount` : 'Each contributor still pays their full original share'}.
|
||||
</>}
|
||||
</p>
|
||||
{pending.kind === 'bribe' && (
|
||||
<p className="text-base leading-7">
|
||||
@@ -68,7 +76,7 @@ export function BureaucratPayment({
|
||||
!busy && act({ type: 'decision', event: pending.event, redirect: false })
|
||||
}
|
||||
>
|
||||
Allow full {pending.amount}-spice payment
|
||||
{pending.richeseAllocation ? 'Allow original portion' : `Allow full ${pending.amount}-spice payment`}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -503,3 +503,61 @@ default Nexus, classic/World/occupied/Betrayal and original phase payment
|
||||
consumers remain in that union. Earlier fixture/helper/profile-only pins are
|
||||
deleted rather than repinned. This is scoped local rule/control proof, not
|
||||
complete Skills, module-combination, save/recovery or deployed acceptance.
|
||||
|
||||
## Advanced Richese portions — 18 October 2026
|
||||
|
||||
The [authorized Advanced contribution rule](RICHESE_AUCTION_RULES.md#authorized-advanced-contribution-recipients--4-october-2026)
|
||||
sends the buyer's own share to Richese and Richese's funded donor share to
|
||||
Emperor or Bank. These are different payer/recipient receipts, not one payment
|
||||
to Richese. Basic retains its publisher seller receipt. Bureaucrat applies the
|
||||
printed third-party/five-spice condition separately to each actual receipt.
|
||||
Neither two sub-five parts nor a Bank-directed part can create a player trigger.
|
||||
The exact supplied-file passage is physical **p36**, printed **p34**, Richese A;
|
||||
the following Black Market clause C supplies its original hidden-hand sale.
|
||||
|
||||
A genuine no-Nexus native all14 table reproduced the missing donor opportunity:
|
||||
Atreides11 pays3, Richese5 pledges5, Richese receives3, and Emperor11 receives5.
|
||||
Living Alia's unused Bureaucrat offered nothing because the original source
|
||||
incorrectly classified the donor share as Richese-to-itself. Original setup,
|
||||
one actual bribe collected at first Mentat, turn-two worm/alliance, cache/card
|
||||
and silent bids remain. No wallet, force, assignment or phase is invented.
|
||||
|
||||
New seller sources contain only the actual buyer share. The original Emperor
|
||||
response stamps a separate donor source/event when that receipt opens. The
|
||||
existing **seller credit → native Emperor Karama → Emperor income** callback
|
||||
order is retained: seller Bureaucrat may precede the later recipient's counter.
|
||||
This is a visibly **provisional sequencing policy**, not a new printed timing
|
||||
ruling. Declining the seller preserves a later eligible donor choice; using
|
||||
either consumes the one physical phase use. A stale seller event cannot answer
|
||||
the donor offer. Cancellation banks only the original donor share, leaves
|
||||
seller credit/card delivery intact and creates no donor Bureaucrat use.
|
||||
|
||||
The internal `richeseSaleAmount` binds the original public price to a signed
|
||||
private one-recipient source. Projection emits that public price, payer-null,
|
||||
actual recipient and `richeseAllocation:true`, never the private portion,
|
||||
contributions, qualifying payer or signature. Controls say the recipient's
|
||||
**portion** is reduced by two, not that the whole price minus two goes there.
|
||||
The redirection log likewise uses the public sale price. Original native
|
||||
income/allocation, Banker donor legs, escrow and card continuation remain.
|
||||
Buyer Bank2 cannot pool with a different donor to manufacture Banker4.
|
||||
|
||||
Actual donor-only3+5 now offers public8/private5 and gives Emperor3, not5:
|
||||
Emperor11→14, Richese3, Atreides8 and trainer9, then an actual ordinary pass
|
||||
continues. Two390px GameTable flows prove a5+5 cache seller decline, native
|
||||
allowance, distinct donor event, Bank2 and next ordinary bid; and a5+3 concealed
|
||||
Black Market seller diversion, retained native Emperor3 income and original
|
||||
cache declaration/selection. No errors or overflow. The temporary memory-only
|
||||
routes are removed and memory released204; no stored room or server restart.
|
||||
|
||||
Four legal policies also settle an actual Black Market donor receipt with the
|
||||
buyer as trainer: own buyer leg is excluded, donor5 remains eligible, original
|
||||
public10 is shown, one use occurs and the next native cache control remains.
|
||||
New cache/market regressions cover native cancellation and deferred original
|
||||
Banker collection, all three trainer roles, Emperor as buyer, Basic and absent
|
||||
Emperor. Frozen18-file **413/413**, focused donor1/1 pass. No complete-mode,
|
||||
new module admission, calibration, deployed or deferred-assurance claim.
|
||||
|
||||
Types and build pass; final lint and focused donor1/1 pass after removing one
|
||||
unnecessary fixture iterator copy. The eighteen-file413/413 consumer union
|
||||
preceded that lint-only change. Existing build warnings remain. No recovery,
|
||||
whole-database, seat-restoration or calibration campaign was added.
|
||||
@@ -29,6 +29,27 @@ optional skill bands and combinations remain open.
|
||||
Rules implementation continues; comprehensive assurance and administration
|
||||
remain deferred until all rules are implemented.
|
||||
|
||||
**Advanced Richese split receipts — 18 October. Prototyped / Partial.**
|
||||
Original buyer3/Richese donor5 wrongly supplied no Bureaucrat opportunity.
|
||||
New signed seller sources use only the buyer share; the original Emperor
|
||||
response stamps a distinct actual donor source/event. Native seller credit
|
||||
first, then Emperor Karama/income remains a visibly provisional sequence.
|
||||
Either use consumes the physical phase opportunity; declining the first
|
||||
preserves the second. Basic, Bank exclusion, full costs/card/escrow stay.
|
||||
|
||||
Public portion guidance shows original price/payer-null, never a private
|
||||
recipient amount or false whole-price-minus-two credit. Actual donor3+5 gives
|
||||
Emperor3 (11→14), Richese3, Atreides8 and trainer9, then a real ordinary pass.
|
||||
Two390px flows exercise dual5+5 seller decline→native allowance→donor diversion
|
||||
and hidden5+3 seller diversion→native Emperor3→original cache selection.
|
||||
Four policies legally settle the donor receipt with buyer as trainer.
|
||||
|
||||
Frozen18-file **413/413**, types/build and final lint/focused1/1 pass. Lint's one
|
||||
unnecessary fixture iterator copy is removed; no production rule changed.
|
||||
Temporary QA routes/memory removed/released204, no errors/overflow, no stored
|
||||
rooms or restart. No new module admission, full-mode/deployed/assurance claim.
|
||||
[Source and provisional sequencing](BUREAUCRAT_PAYMENTS.md#advanced-richese-portions--18-october-2026).
|
||||
|
||||
**Native allied Bureaucrat receipts — 18 October. Prototyped / Partial.**
|
||||
An original no-Nexus Eca/Mori all14 five-own/five-allied shipment rejected
|
||||
unchanged before repair. Single and allied receipts now share the existing
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 18 October 2026 — Actual Advanced Richese Bureaucrat portions
|
||||
|
||||
A real native all14 first turn, deferred bribe1, turn-two worm alliance and
|
||||
silent cache sale reproduced buyer3/Richese donor5 completing without the
|
||||
unused living Bureaucrat's donor opportunity. The source incorrectly treated
|
||||
the donor's actual Emperor payment as seller self-payment.
|
||||
|
||||
Seller sources now contain only buyer-to-Richese money; the original native
|
||||
Emperor response creates a separate donor source/event. Original seller-first,
|
||||
then Emperor Karama/income ordering stays visibly provisional. One physical
|
||||
phase use survives both events; declining seller permits donor use. Native
|
||||
cancellation, full costs/escrow/card, Basic and Bank/owner exclusions remain.
|
||||
Public price/portion controls and redirection logs do not print a private
|
||||
one-recipient amount or claim that the whole price minus two goes to one payee.
|
||||
|
||||
Frozen18-file **413/413**, types/build and final lint/focused1/1 pass. One
|
||||
unnecessary fixture loop copy is removed after lint feedback; the consumer
|
||||
rerun passes. Actual donor3+5 yields Emperor11→14, Richese3/Atreides8/trainer9,
|
||||
one card, then an ordinary pass. Two390px flows prove separate5+5 events and
|
||||
hidden5+3 original cache continuation without errors/overflow. Four policies
|
||||
settle actual donor income with buyer as trainer; eleven new consumer cases
|
||||
retain actual role, native Banker, cancellation and Basic/no-recipient bounds.
|
||||
Temporary QA routes/memory are removed/released204; games/server untouched.
|
||||
Existing build warnings, full-mode/deployed/calibration and assurance gates stay.
|
||||
[Source and current contract](BUREAUCRAT_PAYMENTS.md#advanced-richese-portions--18-october-2026).
|
||||
|
||||
## 18 October 2026 — Registered native allied Bureaucrat receipts
|
||||
|
||||
A genuine Basic no-Nexus Eca/Mori all14 table, real worm alliance and original
|
||||
|
||||
@@ -7,6 +7,23 @@ and fixture opt-ins are removed; existing games are not retrofitted.
|
||||
Earlier dated references to a separate entry describe historical checkpoints.
|
||||
See [current activation and limits](SPICE_BANKER_RUNTIME.md#mandatory-ordinary-skills-default--17-october-2026).
|
||||
|
||||
## Advanced Richese portions — 18 October 2026
|
||||
|
||||
Bureaucrat now evaluates buyer-to-Richese and Richese-donor-to-Emperor receipts
|
||||
separately in original cache/Black Market sales. Real donor5 no longer vanishes
|
||||
as a fictitious self-payment to Richese. Basic keeps its original seller receipt.
|
||||
The inherited seller-first/native-Emperor-counter sequence is visibly
|
||||
provisional; one physical phase use survives both distinct source events.
|
||||
Private exact portions stay private: controls show original public price and
|
||||
recipient portion, not an invented whole-price-minus-two credit.
|
||||
|
||||
Frozen18-file413/413 and focused donor1/1 pass. Two actual390px flows reach the
|
||||
next original ordinary bid or native cache selection, and all four legal
|
||||
policies settle a real donor receipt with the buyer as trainer. Original card,
|
||||
escrow, deferred Banker and native counter callbacks remain. No new module
|
||||
admission, complete-mode, deployed or assurance claim.
|
||||
[Source, precise receipts and limits](BUREAUCRAT_PAYMENTS.md#advanced-richese-portions--18-october-2026).
|
||||
|
||||
## Registered native allied Bureaucrat receipts — 18 October 2026
|
||||
|
||||
Single and actual allied receipts now share the ordinary Skills capability.
|
||||
|
||||
@@ -30,12 +30,15 @@ already-debited donor escrow again. Emperor cancellation sends only its4 to
|
||||
the Bank, not the unrelated buyer-to-Richese share, and does not replay credit
|
||||
or physical card transfer. The existing whole-bank recipient override remains.
|
||||
|
||||
Mandatory normal Banker income in fresh supported ordinary Skills setups
|
||||
counts only actual Bank-routed original payer legs. It never treats allowed Emperor income or ordinary seller
|
||||
credit as a bank payment, aggregates separate payers to four, or spends its
|
||||
new front-shield grant before actual Mentat. Original Bureaucrat split-funding,
|
||||
positive Black Market self-bidding, exhausted-cache counts and special-lot
|
||||
Ixian Technology remain their recorded boundaries.
|
||||
Mandatory normal Banker income in fresh supported ordinary Skills setups counts
|
||||
only actual Bank-routed original payer legs. It never treats allowed Emperor
|
||||
income or ordinary seller credit as Bank payments, pools different payers to
|
||||
four, or spends its new front-shield grant before actual Mentat. Advanced
|
||||
[Bureaucrat portions](BUREAUCRAT_PAYMENTS.md#advanced-richese-portions--18-october-2026)
|
||||
now follow the actual separate seller and donor receipts, retaining Basic and
|
||||
the original native counter/card continuation. Positive Black Market
|
||||
self-bidding, exhausted-cache counts and other special-lot boundaries remain;
|
||||
the supplied source excludes Richese lots from Ixian Technology.
|
||||
|
||||
## Printed rules, by checkpoint
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ export type BureaucratPaymentSource = {
|
||||
/** Exact internal allied-payer receipts; never projected as public shares. */
|
||||
contributions?: readonly { payer: string; amount: number }[];
|
||||
privateFunding?: true;
|
||||
/** Original public price; the qualifying Richese recipient portion stays private. */
|
||||
richeseSaleAmount?: number;
|
||||
signature: string;
|
||||
};
|
||||
/** The one physical skill card's use survives death, redraw and controller changes. */
|
||||
@@ -32,6 +34,7 @@ export type BureaucratPaymentView = {
|
||||
kind: BureaucratPaymentKind;
|
||||
redirect: 2;
|
||||
homeworldAllocation?: true;
|
||||
richeseAllocation?: true;
|
||||
};
|
||||
usedThisPhase: boolean;
|
||||
};
|
||||
|
||||
+47
-15
@@ -9088,16 +9088,17 @@ function checkBureaucratContributors(g: Game,payee:string|undefined,shares:{paye
|
||||
requireRule(bureaucratPaymentModeSupported(g),'Bureaucrat with other optional modules is still being integrated.');
|
||||
}
|
||||
function stampBureaucratPayment(g: Game,kind:BureaucratPaymentSource['kind'],payer:string,payee:string,amount:number,
|
||||
contributions?:BureaucratPaymentSource['contributions']):
|
||||
contributions?:BureaucratPaymentSource['contributions'],richeseSaleAmount?:number):
|
||||
{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,
|
||||
...(contributions ? {contributions,privateFunding:true as const} : {}),signature:''};
|
||||
...(contributions ? {contributions,privateFunding:true as const} : {}),
|
||||
...(richeseSaleAmount !== undefined ? {richeseSaleAmount} : {}),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) {
|
||||
shares:{payer:string;amount:number}[],privateFunding:boolean,richeseSaleAmount?:number) {
|
||||
const paid = shares.filter(share => share.amount > 0);
|
||||
if (!privateFunding && paid.length === 1)
|
||||
return stampBureaucratPayment(g,kind,paid[0].payer,payee,paid[0].amount);
|
||||
@@ -9105,7 +9106,7 @@ function stampBureaucratContributors(g:Game,kind:'auction'|'shipment',payee:stri
|
||||
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) : {};
|
||||
paid.reduce((total,share) => total + share.amount,0),paid,richeseSaleAmount) : {};
|
||||
}
|
||||
function bureaucratPaymentFrame(g: Game): string {
|
||||
return JSON.stringify({turn:g.turn,phase:g.phase,active:g.active,order:g.order,
|
||||
@@ -9131,6 +9132,11 @@ function validateBureaucratSource(g: Game,source:BureaucratPaymentSource) {
|
||||
'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.');
|
||||
if (source.richeseSaleAmount !== undefined)
|
||||
requireRule(source.kind === 'auction' && g.phase === 3 && source.privateFunding === true &&
|
||||
source.contributions?.length === 1 && Number.isSafeInteger(source.richeseSaleAmount) &&
|
||||
source.richeseSaleAmount >= source.amount,
|
||||
'The private Richese portion lost its original public sale price.');
|
||||
}
|
||||
function bureaucratPaymentIntegrity(g: Game) {
|
||||
const state = g.bureaucratPayments,events = g.bureaucratUseEvents ?? [];
|
||||
@@ -9159,13 +9165,25 @@ 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 && source.amount === carrier.amount &&
|
||||
(source.payer === carrier.winner || getPlayer(g,carrier.winner).ally === source.payer) &&
|
||||
source.payee === (carrier.origin !== 'normal' && carrier.seller !== carrier.winner ? carrier.seller : byFaction(g,'emperor')?.id),
|
||||
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 &&
|
||||
source.amount === carrier.amount - carrier.richeseContribution! &&
|
||||
source.payer === carrier.winner && source.payee === carrier.seller
|
||||
: source.amount === carrier.amount &&
|
||||
(source.payer === carrier.winner || getPlayer(g,carrier.winner).ally === source.payer) &&
|
||||
source.payee === (carrier.origin !== 'normal' && carrier.seller !== carrier.winner ? carrier.seller : byFaction(g,'emperor')?.id)),
|
||||
'The Bureaucrat source no longer matches its paid auction sale.');
|
||||
else requireRule(source.payee === carrier.owner &&
|
||||
(carrier.kind === 'guildIncome' && source.kind === 'shipment' && source.amount === carrier.amount ||
|
||||
carrier.kind === 'emperorIncome' && source.kind === 'auction' && g.currentAuctionSale?.bureaucratPaymentEvent === source.event ||
|
||||
carrier.kind === 'emperorIncome' && source.kind === 'auction' &&
|
||||
(source.richeseSaleAmount !== undefined
|
||||
? g.advanced && !!g.currentAuctionSale && !g.currentAuctionSale.free &&
|
||||
g.currentAuctionSale.origin !== 'normal' && g.currentAuctionSale.seller !== g.currentAuctionSale.winner &&
|
||||
source.richeseSaleAmount === g.currentAuctionSale.amount &&
|
||||
source.amount === g.currentAuctionSale.richeseContribution && source.payer === g.currentAuctionSale.seller
|
||||
: g.currentAuctionSale?.bureaucratPaymentEvent === source.event) ||
|
||||
carrier.kind === 'revivalIncome' && source.kind === 'revival' && byFaction(g,'tleilaxu')?.id === source.payee &&
|
||||
source.amount <= (carrier.amount ?? -1) && (carrier.amount ?? 0) <= source.amount + 1 &&
|
||||
source.payer === carrier.recipient),
|
||||
@@ -9220,7 +9238,8 @@ function projectedBureaucrat(g: Game,viewer: string): BureaucratPaymentView {
|
||||
![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.privateFunding ? null : pending.source.payer,payee:pending.source.payee,
|
||||
amount:pending.source.amount,kind:pending.source.kind,redirect:2,
|
||||
amount:pending.source.richeseSaleAmount ?? pending.source.amount,kind:pending.source.kind,redirect:2,
|
||||
...(pending.source.richeseSaleAmount !== undefined ? {richeseAllocation:true as const} : {}),
|
||||
...(g.homeworlds && (pending.source.kind === 'auction' || pending.source.kind === 'shipment')
|
||||
? { homeworldAllocation: true as const } : {})} : null,
|
||||
usedThisPhase:bureaucratUsed(g.bureaucratPayments?.used ?? [],g.turn,g.phase)};
|
||||
@@ -9235,7 +9254,10 @@ function actBureaucratPayment(g: Game,action:Action) {
|
||||
const use:BureaucratPaymentUse = {event:pending.source.event,owner:pending.owner,turn:g.turn,phase:g.phase,signature:''};
|
||||
use.signature = bureaucratPaymentSignature(use);
|
||||
g.bureaucratPayments!.used.push(use);(g.bureaucratUseEvents ??= []).push(use.event);
|
||||
log(g,`${getPlayer(g,pending.owner).name} used Bureaucrat to redirect 2 spice of ${pending.source.kind === 'revival' ? 'a qualifying revival payment' : `the ${pending.source.amount}-spice payment`} to the Bank.`);
|
||||
const payment = pending.source.kind === 'revival' ? 'a qualifying revival payment'
|
||||
: pending.source.richeseSaleAmount !== undefined ? `a qualifying portion of the ${pending.source.richeseSaleAmount}-spice Richese sale`
|
||||
: `the ${pending.source.amount}-spice payment`;
|
||||
log(g,`${getPlayer(g,pending.owner).name} used Bureaucrat to redirect 2 spice of ${payment} to the Bank.`);
|
||||
}
|
||||
delete g.bureaucratPayments!.pending;delete g.bureaucratPaymentEvent;
|
||||
g.response = pending.resume.response;g.decision = pending.resume.decision;
|
||||
@@ -13257,12 +13279,15 @@ 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;
|
||||
return {payee:seller && seller !== p.id ? seller : byFaction(g,'emperor')?.id,
|
||||
shares:[{payer:p.id,amount:amount-allyPayment},...(allyPayment ? [{payer:p.ally!,amount:allyPayment}] : [])].filter(share => share.amount > 0)};
|
||||
shares:(split ? [{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};
|
||||
}
|
||||
function stampAuctionBureaucrat(g:Game,p:Player,amount:number,allyPayment:number,seller?:string) {
|
||||
const {payee,shares} = auctionBureaucratParties(g,p,amount,allyPayment,seller);
|
||||
return payee ? stampBureaucratContributors(g,'auction',payee,shares,allyPayment > 0) : {};
|
||||
const {payee,shares,richeseSaleAmount} = auctionBureaucratParties(g,p,amount,allyPayment,seller);
|
||||
return payee ? stampBureaucratContributors(g,'auction',payee,shares,allyPayment > 0,richeseSaleAmount) : {};
|
||||
}
|
||||
const EMPEROR_PAYMENT_POLICY = 'Provisional full-price funding: Emperor covers this original allied purchase, reusing prepaid aid and adding only the missing spice. No extra gift or bank subsidy is created; original acquisition and native income continue.';
|
||||
function nexusEmperorPaymentIntegrity(g: Game): void {
|
||||
@@ -13522,8 +13547,13 @@ function commitAuctionContinuation(g: Game, quote: AuctionContinuationQuote,bure
|
||||
}
|
||||
const next = quote.next;
|
||||
if (next.kind === 'response') {
|
||||
g.response = {...next.response,...(next.response.kind === 'emperorIncome' && g.currentAuctionSale?.bureaucratPayment ?
|
||||
{bureaucratPayment:g.currentAuctionSale.bureaucratPayment,bureaucratPaymentEvent:g.currentAuctionSale.bureaucratPaymentEvent} : {})};
|
||||
const sale = g.currentAuctionSale;
|
||||
const bureaucrat = next.response.kind !== 'emperorIncome' || !sale ? {}
|
||||
: sale.richeseContribution !== undefined
|
||||
? stampBureaucratContributors(g,'auction',next.response.owner,
|
||||
[{payer:sale.seller!,amount:sale.richeseContribution}],true,sale.amount)
|
||||
: {bureaucratPayment:sale.bureaucratPayment,bureaucratPaymentEvent:sale.bureaucratPaymentEvent};
|
||||
g.response = {...next.response,...bureaucrat};
|
||||
offerOccupiedBonus(g, g.response);
|
||||
}
|
||||
else if (next.kind === 'richeseEnd') finishRicheseLot(g);
|
||||
@@ -32833,6 +32863,8 @@ function applyActionInner(
|
||||
if (amount !== null) {
|
||||
const parties = auctionBureaucratParties(g,p,amount,allyPayment,lot.owner);
|
||||
checkBureaucratContributors(g,parties.payee,parties.shares);
|
||||
if (parties.richeseSaleAmount !== undefined)
|
||||
checkBureaucratContributors(g,byFaction(g,'emperor')?.id,[{payer:lot.owner,amount:allyPayment}]);
|
||||
}
|
||||
g.richeseAuction = submitRicheseBid(
|
||||
lot,
|
||||
|
||||
+3
-2
@@ -4852,7 +4852,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'A selected surviving Planetologist may use a canonical printed green Richese Special or Special - Movement in its weapon slot for two strength. Exact physical identity is required. Stone Burner, Mirror Weapon and Portable Snooper do not qualify. The green card is discarded once without its native effect, fee, search or early leader kill.',
|
||||
'The normal Smuggler own companion is one separate force alongside the marker, not added to its hidden value. Ordinary allied No-Field cancellation and Guild stages retain their exact owner/recipient/token frame; an allied free companion is not granted.',
|
||||
'The authorized Advanced recipient cutover keeps Richese ally contributions distinct: buyer four goes to Richese, funded Richese four goes to Emperor or bank. This also applies when Emperor is the buyer. Cancellation redirects only the original Emperor share, not unrelated buyer credit, with no second donor debit or card transfer. Basic keeps its publisher receipt.',
|
||||
'Separate normal Banker income uses only actual bank-routed payer legs and remains unavailable until actual Mentat. Allowed Emperor income and ordinary seller credit are not bank payments. Supplied Advanced p34 expressly excludes Richese auctions from Ixian Technology, so no per-lot exchange or decline exists; exhausted-cache arithmetic, positive Black Market self-bidding, Bureaucrat split funding and other module boundaries remain explicit.',
|
||||
'Separate normal Banker income uses only actual bank-routed payer legs and remains unavailable until actual Mentat. Allowed Emperor income and ordinary seller credit are not bank payments. Bureaucrat evaluates Advanced buyer-to-Richese and Richese-to-Emperor receipts separately, retaining full original costs and one use per phase; Basic keeps its original seller receipt. Supplied Advanced p34 expressly excludes Richese auctions from Ixian Technology, so no per-lot exchange or decline exists; exhausted-cache arithmetic, positive Black Market self-bidding and other module boundaries remain explicit.',
|
||||
],
|
||||
related: ['leader-skills', 'richese-cards', 'richese-no-field', 'spice-banker-income', 'implementation-checklist'],
|
||||
checklist: [
|
||||
@@ -4957,6 +4957,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'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. Payer costs stay unchanged; declining preserves later use. Bribes qualify at transfer and remain unavailable until Mentat Pause. Supported ordinary/Richese auctions, Guild shipments and paid Tleilaxu revival have connected paths; a separate free-revival award cannot make a smaller price qualify. Allied payer shares never pool to reach five. Trainer or recipient contributions cannot qualify but do not hide an eligible other payer. Public prompts show gross and recipient, not private shares or which payer qualified. Supported classic and native E1/E2/E3 profiles and Homeworld Skills retain original costs and callbacks. Native Guild/Emperor/revival Karama cancellation resolves before Bureaucrat; Richese retains direct seller credit with no invented cancellation window. Basic has no Black Market; free Karama purchases remain normal-only. By user ruling Bureaucrat takes two off gross before occupied Homeworld allocation. Fresh classic occupation plus Skills now connects this: eight becomes two to Bank, three native and three occupied, with full original payer contributions retained. Decline gives four plus four; native income cancellation preserves the separate occupied share and creates no Bureaucrat offer. The low/occupied Guild two-odd contributor rounding boundary is unchanged. By user ruling Guild Nexus Betrayal also carries Bureaucrat on its holder receipt in the bounded classic Nexus plus Skills composition; the holder receives the fee minus two. Emperor gifts remain excluded by user ruling, without threshold disclosure. Other profiles, overlays and full-mode acceptance remain unfinished.',
|
||||
'Registered all14 tables use one ordinary Bureaucrat receipt capability for both single and allied funding. Selected native E1/E2/E3 decks, original Worlds and supported phase modules need no incidental Nexus Cards or exact single-family selection. A native worm alliance is not a Nexus Card. Module and Advanced role admission remain separate; full combined-mode acceptance is unfinished.',
|
||||
'Advanced Richese cache and Black Market purchases route the buyer’s share to Richese and Richese’s own allied contribution to Emperor or Bank. Bureaucrat checks each actual player-directed receipt separately, never pools them, and cannot divert a Bank payment. The inherited provisional sequence offers seller credit first, then the original Emperor Karama window and donor-receipt reaction. Declining the first leaves the second available; using either spends the physical skill’s one phase use. Controls show the original public price and recipient portion, not the private share or a false whole-price-minus-two receipt. Basic, native card custody and escrow costs stay unchanged.',
|
||||
'Smuggler records existing positive battle-territory piles at public reveal. Provisional modifier policy: cap by copied/printed disc plus declared Leader Skill bonuses and Kwisatz2. Pending spice cannot fund plans. A surviving controller collects even when losing or no forces remain, unless an actual zero-strength weapon effect resolves. Artillery Strike supplies that effect; the ordinary projectile Stunner does not. A sole own Traitor bypasses weapons and retains collection. Partial multi-pile collection asks its owner for exact sectors; all/none and single piles stay automatic. Both the modifier and allocation policies are explicit interpretations, not publisher rulings. Collection precedes Sandmaster; a drained pile receives none. Other module guards remain.',
|
||||
'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 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 retain their own limits. Normal Banker income is mandatory in fresh supported ordinary Skills setups, with provisional original-earned-faction custody and no retrofit. Diplomat retreat retains its conservative outcome order. Native Advanced foreign gholas/Face Dancers, skill-first Moritani assassination and ordinary-disc Ecaz coalition skills follow their bounded contracts; shared-Duke assignment, exceptional roles and other combined modules remain unfinished. Normal module starts are labelled previews, not complete-mode acceptance.',
|
||||
@@ -4968,7 +4969,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus surviving-leader battle collection with reveal-time guidance and owned multi-pile allocations. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices, optional worm/physical Ambassador destination checkboxes and direct victory-pile placement when several piles remain. Diplomat offers a named committed Worthless choice or decline after public reveal, and a separate surviving-loser destination/normal/elite retreat or decline. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining combined-module and exceptional-timing controls are incomplete.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/smuggler-battle-collection.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/sandmaster-victory-spice.tsx','components/ambassador-movement.tsx','components/diplomat-defense.tsx','components/diplomat-retreat.tsx','components/game-table.tsx'] },
|
||||
{ area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments and modifier-inclusive collection plans, exact multi-pile Smuggler allocations, Sandmaster route, worm, physical Ambassador destination collection and victory-pile placement, funded Banker commitments and optional Diplomat copying from public revealed defenses or choosing legal losing-battle retreat. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
|
||||
{ area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody. The 13 October 2026 ruling resolves the captured-card replacement question: the original assignment is retained and no replacement is drawn while it exists; other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/BUREAUCRAT_PAYMENTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/SANDMASTER_WORM.md','docs/DIPLOMAT_DEFENSE.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Earlier native/Smuggler/optional-module and recovery evidence remains dated in linked contracts. Used-defense capability3 adds preserved false-vote late copying, native-only Homeworld continuation and independently earned Carthag protection, with true-traitor, own-defense and legacy boundaries. Four390px journeys/25 actions reach original Collection; twelve four-policy programmes/40 actions retain legal copy/decline behavior. Printed-role/context scheduling remains provisional, not complete-mode, full strategy or deployed acceptance.', evidence: ['tests/diplomat-used-defense.test.ts', 'tests/diplomat-late-journey.test.ts', 'tests/fixture-diplomat-late-journey.ts', 'tests/diplomat-context-journey.test.ts', 'tests/fixture-diplomat-context-journey.ts', 'docs/DIPLOMAT_DEFENSE.md', 'tests/smuggler-kwisatz-journey.test.ts', 'tests/fixture-smuggler-kwisatz-journey.ts', 'tests/smuggler-artillery-journey.test.ts', 'tests/fixture-smuggler-artillery-journey.ts', 'docs/SMUGGLER_BATTLE.md', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts','tests/homeworld-native-e1e2-modules-runtime.test.ts','tests/homeworld-native-e3-modules-runtime.test.ts','tests/homeworld-classic-skills-runtime.test.ts','tests/homeworld-skills-discovery-runtime.test.ts','tests/homeworld-optional-modules-runtime.test.ts','tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/captured-skill-revival.test.ts','tests/fixture-captured-skill-revival.ts','tests/captured-skill-special-revival.test.ts','tests/fixture-captured-skill-special-revival.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','tests/classic-skills-stronghold-runtime.test.ts','tests/native-skills-stronghold-runtime.test.ts','tests/ecaz-skills-modules-runtime.test.ts','tests/moritani-skills-modules-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md','docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Earlier native, Smuggler, optional-module, Diplomat and recovery evidence remains dated in linked contracts. Registered native allied receipts share the ordinary capability. Advanced Richese portions add actual separate recipients, stale cross-leg events, native cancellation, original Banker custody and Basic/no-recipient controls. Frozen18-file413/413, two390px native control flows and four legal policies pass. Original timing/context interpretations, full modes, strategy, deferred assurance and deployed acceptance remain unfinished.', evidence: ['tests/bureaucrat-richese-cache.test.ts','tests/bureaucrat-richese-black-market.test.ts','tests/fixture-bureaucrat-richese-funding.ts','docs/BUREAUCRAT_PAYMENTS.md','tests/diplomat-used-defense.test.ts', 'tests/diplomat-late-journey.test.ts', 'tests/fixture-diplomat-late-journey.ts', 'tests/diplomat-context-journey.test.ts', 'tests/fixture-diplomat-context-journey.ts', 'docs/DIPLOMAT_DEFENSE.md', 'tests/smuggler-kwisatz-journey.test.ts', 'tests/fixture-smuggler-kwisatz-journey.ts', 'tests/smuggler-artillery-journey.test.ts', 'tests/fixture-smuggler-artillery-journey.ts', 'docs/SMUGGLER_BATTLE.md', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts','tests/homeworld-native-e1e2-modules-runtime.test.ts','tests/homeworld-native-e3-modules-runtime.test.ts','tests/homeworld-classic-skills-runtime.test.ts','tests/homeworld-skills-discovery-runtime.test.ts','tests/homeworld-optional-modules-runtime.test.ts','tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/captured-skill-revival.test.ts','tests/fixture-captured-skill-revival.ts','tests/captured-skill-special-revival.test.ts','tests/fixture-captured-skill-special-revival.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','tests/classic-skills-stronghold-runtime.test.ts','tests/native-skills-stronghold-runtime.test.ts','tests/ecaz-skills-modules-runtime.test.ts','tests/moritani-skills-modules-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md','docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import {
|
||||
allowRicheseBureaucratResponses,
|
||||
chooseRicheseBureaucrat,
|
||||
createRicheseBureaucratFixture,
|
||||
payRicheseBureaucrat,
|
||||
richeseBureaucratCards,
|
||||
richeseBureaucratPlayer,
|
||||
type RicheseBureaucratFixture,
|
||||
} from './fixture-bureaucrat-richese-funding';
|
||||
|
||||
function allocation(game: Game, f: RicheseBureaucratFixture, payee: string) {
|
||||
assert.equal(game.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(game.decision!.player, f.trainer);
|
||||
const event = game.bureaucratPaymentEvent;
|
||||
assert.ok(event);
|
||||
for (const player of game.players) {
|
||||
const view = viewGame(game, player.id);
|
||||
assert.deepEqual(view.bureaucrat.pending, {
|
||||
event,
|
||||
owner: f.trainer,
|
||||
payer: null,
|
||||
payee,
|
||||
amount: f.price,
|
||||
kind: 'auction',
|
||||
redirect: 2,
|
||||
richeseAllocation: true,
|
||||
}, 'Every seat sees the original public price, never the private recipient leg or qualifying payer');
|
||||
assert.equal('bureaucratPayments' in view, false);
|
||||
assert.equal('bureaucratUseEvents' in view, false);
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
function paidOnce(game: Game, f: RicheseBureaucratFixture, sellerReceipt: number, emperorReceipt: number) {
|
||||
const balances = new Map(f.beforePledge.players.map(player => [player.id, player.spice]));
|
||||
balances.set(f.donor, balances.get(f.donor)! - f.pledge + sellerReceipt);
|
||||
balances.set(f.payer, balances.get(f.payer)! - f.primary);
|
||||
if (f.emperor) balances.set(f.emperor, balances.get(f.emperor)! + emperorReceipt);
|
||||
else assert.equal(emperorReceipt, 0);
|
||||
for (const player of game.players) {
|
||||
assert.equal(player.spice, balances.get(player.id), `${player.id} retains only its actual payment and receipt`);
|
||||
const before = richeseBureaucratPlayer(f.beforeAction, player.id);
|
||||
const delta = Number(player.id === f.payer) - Number(f.source === 'blackMarket' && player.id === f.seller);
|
||||
assert.equal(player.hand.length, before.hand.length + delta);
|
||||
}
|
||||
assert.equal(f.beforeAction.aid[f.donor].amount, f.pledge);
|
||||
assert.equal(game.aid[f.donor].amount, 0, 'Original prepaid donor escrow is consumed once');
|
||||
assert.equal(richeseBureaucratPlayer(game, f.payer).hand.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(richeseBureaucratPlayer(game, f.seller).hand.some(card => card.id === f.card), false);
|
||||
assert.deepEqual(richeseBureaucratCards(game), f.physical);
|
||||
assert.equal(game.currentAuctionSale, null);
|
||||
assert.equal(game.richeseAuction, null);
|
||||
assert.equal(game.response, null);
|
||||
assert.equal(viewGame(game, f.trainer).bureaucrat.pending, null);
|
||||
}
|
||||
|
||||
function blackMarketDeclaration(game: Game, f: RicheseBureaucratFixture) {
|
||||
assert.equal(game.richeseBidding!.blackMarketSold, true);
|
||||
assert.equal(game.decision?.kind, 'richeseDeclaration');
|
||||
assert.equal(game.decision!.player, f.seller);
|
||||
assert.equal(game.active, f.seller);
|
||||
assert.deepEqual(game.richeseCache, f.beforeAction.richeseCache, 'A hidden hand sale does not spend a cache card');
|
||||
}
|
||||
|
||||
void test('Advanced Black Market buyer5/donor3 diverts only seller5, retains native Emperor3 and resumes the cache declaration', () => {
|
||||
const f = createRicheseBureaucratFixture({ source: 'blackMarket', primary: 5, pledge: 3, counter: true });
|
||||
assert.equal(viewGame(f.game, f.trainer).richeseAuction!.card, null);
|
||||
assert.equal(richeseBureaucratPlayer(f.game, f.seller).hand.filter(card => card.id === f.card).length, 1);
|
||||
let game = payRicheseBureaucrat(f);
|
||||
const event = allocation(game, f, f.seller);
|
||||
assert.equal(richeseBureaucratPlayer(game, f.payer).spice, richeseBureaucratPlayer(f.beforeAction, f.payer).spice - 5);
|
||||
assert.equal(richeseBureaucratPlayer(game, f.seller).spice, richeseBureaucratPlayer(f.beforeAction, f.seller).spice);
|
||||
game = chooseRicheseBureaucrat(game, true);
|
||||
assert.equal(richeseBureaucratPlayer(game, f.seller).spice, richeseBureaucratPlayer(f.beforeAction, f.seller).spice + 3);
|
||||
assert.equal(game.response?.kind, 'emperorIncome', 'Using Bureaucrat on seller income does not suppress native Karama');
|
||||
assert.equal(richeseBureaucratPlayer(game, f.emperor!).spice, richeseBureaucratPlayer(f.beforeAction, f.emperor!).spice);
|
||||
game = allowRicheseBureaucratResponses(game);
|
||||
paidOnce(game, f, 3, 3);
|
||||
blackMarketDeclaration(game, f);
|
||||
assert.equal(viewGame(game, f.trainer).bureaucrat.usedThisPhase, true);
|
||||
assert.equal(game.bureaucratPayments!.used.filter(use => use.turn === game.turn && use.phase === game.phase).length, 1);
|
||||
const beforeStale = JSON.stringify(game);
|
||||
assert.throws(() => applyAction(game, f.trainer, { type: 'decision', event, redirect: true }));
|
||||
assert.equal(JSON.stringify(game), beforeStale);
|
||||
|
||||
game = applyAction(game, f.seller, { type: 'decision', event: game.richeseBidding!.event, position: 'first' });
|
||||
assert.equal(game.response?.kind, 'richeseAuction');
|
||||
game = allowRicheseBureaucratResponses(game);
|
||||
assert.equal(game.decision?.kind, 'richeseCache');
|
||||
assert.equal(game.decision!.player, f.seller);
|
||||
assert.equal(game.richeseBidding!.blackMarketSold, true);
|
||||
assert.equal(richeseBureaucratPlayer(game, f.payer).hand.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(richeseBureaucratCards(game), f.physical);
|
||||
});
|
||||
|
||||
void test('an actual Emperor buyer3 still receives Richese donor5, with third-party Bureaucrat diverting only that receipt', () => {
|
||||
const f = createRicheseBureaucratFixture({ source: 'blackMarket', buyer: 'emperor' });
|
||||
let game = allowRicheseBureaucratResponses(payRicheseBureaucrat(f));
|
||||
allocation(game, f, f.emperor!);
|
||||
assert.equal(game.bureaucratPayments!.pending!.resume.response?.kind, 'emperorIncome');
|
||||
assert.equal(richeseBureaucratPlayer(game, f.seller).spice, richeseBureaucratPlayer(f.beforeAction, f.seller).spice + 3);
|
||||
assert.equal(richeseBureaucratPlayer(game, f.payer).spice, richeseBureaucratPlayer(f.beforeAction, f.payer).spice - 3);
|
||||
game = chooseRicheseBureaucrat(game, true);
|
||||
paidOnce(game, f, 3, 3);
|
||||
blackMarketDeclaration(game, f);
|
||||
assert.equal(viewGame(game, f.trainer).bureaucrat.usedThisPhase, true);
|
||||
});
|
||||
|
||||
void test('the actual Atreides Bureaucrat skips its own buyer5 leg but can divert Richese donor5 to Emperor', () => {
|
||||
const f = createRicheseBureaucratFixture({ source: 'blackMarket', owner: 'atreides', primary: 5 });
|
||||
let game = allowRicheseBureaucratResponses(payRicheseBureaucrat(f));
|
||||
allocation(game, f, f.emperor!);
|
||||
assert.equal(richeseBureaucratPlayer(game, f.seller).spice, richeseBureaucratPlayer(f.beforeAction, f.seller).spice + 5);
|
||||
game = chooseRicheseBureaucrat(game, true);
|
||||
paidOnce(game, f, 5, 3);
|
||||
blackMarketDeclaration(game, f);
|
||||
assert.equal(viewGame(game, f.trainer).bureaucrat.usedThisPhase, true);
|
||||
});
|
||||
|
||||
void test('the actual Richese Bureaucrat cannot divert either its seller5 receipt or its own donor5 payment', () => {
|
||||
const f = createRicheseBureaucratFixture({ source: 'blackMarket', owner: 'richese', primary: 5 });
|
||||
const game = allowRicheseBureaucratResponses(payRicheseBureaucrat(f));
|
||||
paidOnce(game, f, 5, 5);
|
||||
blackMarketDeclaration(game, f);
|
||||
assert.equal(viewGame(game, f.trainer).bureaucrat.usedThisPhase, false);
|
||||
});
|
||||
|
||||
void test('the actual Emperor Bureaucrat may decline buyer5 to Richese but cannot divert its own incoming donor5', () => {
|
||||
const f = createRicheseBureaucratFixture({ source: 'blackMarket', owner: 'emperor', primary: 5 });
|
||||
let game = payRicheseBureaucrat(f);
|
||||
allocation(game, f, f.seller);
|
||||
game = chooseRicheseBureaucrat(game, false);
|
||||
game = allowRicheseBureaucratResponses(game);
|
||||
paidOnce(game, f, 5, 5);
|
||||
blackMarketDeclaration(game, f);
|
||||
assert.equal(viewGame(game, f.trainer).bureaucrat.usedThisPhase, false, 'Declining the first offer leaves the skill unused, so the payee exclusion alone suppresses the second');
|
||||
});
|
||||
|
||||
void test('Basic cache control keeps the whole seller8 receipt, reduced to6, without an Advanced donor reroute', () => {
|
||||
const f = createRicheseBureaucratFixture({ advanced: false, primary: 5, pledge: 3 });
|
||||
let game = payRicheseBureaucrat(f);
|
||||
assert.equal(game.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(viewGame(game, f.trainer).bureaucrat.pending!.payee, f.seller);
|
||||
assert.equal(viewGame(game, f.trainer).bureaucrat.pending!.amount, 8);
|
||||
game = chooseRicheseBureaucrat(game, true);
|
||||
game = allowRicheseBureaucratResponses(game);
|
||||
paidOnce(game, f, 6, 0);
|
||||
assert.equal(game.richeseCache!.length, f.beforeAction.richeseCache!.length - 1);
|
||||
assert.equal(game.richeseCache!.some(card => card.id === f.card), false);
|
||||
assert.equal(game.richeseBidding!.stage, 'normal');
|
||||
assert.ok(game.auction, 'The original first-cache continuation prepares normal lots');
|
||||
assert.equal(viewGame(game, f.trainer).bureaucrat.usedThisPhase, true);
|
||||
});
|
||||
|
||||
void test('without Emperor the actual Black Market donor5 goes to Bank, buyer3 stays below Bureaucrat threshold, and Guild Banker defers1', () => {
|
||||
const f = createRicheseBureaucratFixture({ source: 'blackMarket', emperor: false });
|
||||
const bankerBefore = richeseBureaucratPlayer(f.beforeAction, f.banker).spice;
|
||||
const sourcesBefore = f.beforeAction.spiceBankerIncome!.sources.length;
|
||||
const game = allowRicheseBureaucratResponses(payRicheseBureaucrat(f));
|
||||
paidOnce(game, f, 3, 0);
|
||||
blackMarketDeclaration(game, f);
|
||||
assert.equal(viewGame(game, f.trainer).bureaucrat.usedThisPhase, false);
|
||||
assert.equal(richeseBureaucratPlayer(game, f.banker).spice, bankerBefore, 'Earned Banker income is not immediate spendable spice');
|
||||
assert.deepEqual(viewGame(game, f.banker).spiceBankerIncome!.deferred, [{ owner: f.banker, amount: 1 }]);
|
||||
const receipts = game.spiceBankerIncome!.sources.slice(sourcesBefore);
|
||||
assert.equal(receipts.filter(receipt => receipt.grant).length, 1);
|
||||
assert.deepEqual(receipts.flatMap(receipt => receipt.source.bankLegs), [{ payer: f.donor, amount: 5 }], 'Only the actual donor Bank leg earns income; buyer3 is paid to Richese, not pooled into a fictitious Bank or player receipt');
|
||||
assert.equal(receipts.find(receipt => receipt.grant)!.grant!.owner, f.banker);
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
createRicheseBureaucratFixture, payRicheseBureaucrat,
|
||||
allowRicheseBureaucratResponses, chooseRicheseBureaucrat,
|
||||
richeseBureaucratPlayer, richeseBureaucratCards,
|
||||
type RicheseBureaucratFixture,
|
||||
} from './fixture-bureaucrat-richese-funding';
|
||||
import {
|
||||
advanceSpiceBankerIncomeToMentat, nextSpiceBankerIncomeNativeStep,
|
||||
} from './fixture-spice-banker-income';
|
||||
|
||||
const spice = (game: Game, actor: string) => richeseBureaucratPlayer(game, actor).spice;
|
||||
|
||||
function rejectUnchanged(game: Game, actor: string, action: Action): void {
|
||||
const before = JSON.stringify(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.equal(JSON.stringify(game), before);
|
||||
}
|
||||
|
||||
function assertPaidCard(f: RicheseBureaucratFixture, game: Game): void {
|
||||
assert.equal(spice(game, f.payer), spice(f.beforePledge, f.payer) - f.primary);
|
||||
assert.equal(game.aid[f.donor]?.amount ?? 0, 0);
|
||||
assert.deepEqual(game.richeseFunding, {});
|
||||
assert.equal(richeseBureaucratPlayer(game, f.payer).hand.filter(c => c.id === f.card).length, 1);
|
||||
assert.deepEqual(richeseBureaucratPlayer(game, f.payer).hand.map(c => c.id),
|
||||
[...richeseBureaucratPlayer(f.beforeAction, f.payer).hand.map(c => c.id), f.card]);
|
||||
assert.equal(game.richeseCache!.some(c => c.id === f.card), false);
|
||||
assert.deepEqual(richeseBureaucratCards(game), f.physical);
|
||||
}
|
||||
|
||||
function assertNormalContinuation(f: RicheseBureaucratFixture, game: Game): void {
|
||||
assertPaidCard(f, game);
|
||||
assert.equal(game.phase, 3);
|
||||
assert.equal(game.turn, f.beforeAction.turn);
|
||||
assert.equal(game.currentAuctionSale, null);
|
||||
assert.equal(game.richeseAuction, null);
|
||||
assert.equal(game.richeseBidding!.stage, 'normal');
|
||||
assert.ok(game.auction);
|
||||
assert.equal(game.auction.index, 0);
|
||||
assert.equal(game.decision, null);
|
||||
assert.equal(game.response, null);
|
||||
}
|
||||
|
||||
function assertPublicAllocation(f: RicheseBureaucratFixture, game: Game, payee: string): void {
|
||||
const event = game.bureaucratPaymentEvent;
|
||||
assert.ok(event);
|
||||
for (const player of game.players) {
|
||||
const view = viewGame(game, player.id);
|
||||
assert.deepEqual(view.bureaucrat.pending, {
|
||||
event, owner: f.trainer, payer: null, payee, amount: f.price,
|
||||
kind: 'auction', redirect: 2, richeseAllocation: true,
|
||||
});
|
||||
const publicState = view as unknown as Record<string, unknown>;
|
||||
assert.equal('richeseFunding' in publicState, false);
|
||||
assert.equal('bureaucratPayments' in publicState, false);
|
||||
assert.equal('bureaucratPayment' in ((publicState.currentAuctionSale as Record<string, unknown> | undefined) ?? {}), false);
|
||||
assert.equal('bureaucratPayment' in (view.response ?? {}), false);
|
||||
}
|
||||
}
|
||||
|
||||
void test('Advanced native cache 3+5: only Richese-to-Emperor five qualifies, redirects two and resumes ordinary lots once', () => {
|
||||
const f = createRicheseBureaucratFixture({ primary: 3, pledge: 5, counter: true });
|
||||
const paid = payRicheseBureaucrat(f);
|
||||
assertPaidCard(f, paid);
|
||||
assert.equal(paid.response?.kind, 'emperorIncome');
|
||||
assert.equal(viewGame(paid, f.trainer).bureaucrat.pending, null);
|
||||
assert.equal(spice(paid, f.donor), spice(f.beforePledge, f.donor) - 5 + 3);
|
||||
assert.equal(spice(paid, f.emperor!), spice(f.beforePledge, f.emperor!));
|
||||
|
||||
const pending = allowRicheseBureaucratResponses(paid);
|
||||
assert.equal(pending.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(pending.bureaucratPayments!.pending!.source.payer, f.donor);
|
||||
assert.equal(pending.bureaucratPayments!.pending!.source.payee, f.emperor);
|
||||
assert.equal(pending.bureaucratPayments!.pending!.source.amount, 5);
|
||||
assertPublicAllocation(f, pending, f.emperor!);
|
||||
assert.equal(spice(pending, f.emperor!), spice(f.beforePledge, f.emperor!));
|
||||
|
||||
const done = allowRicheseBureaucratResponses(chooseRicheseBureaucrat(pending, true));
|
||||
assertNormalContinuation(f, done);
|
||||
assert.equal(spice(done, f.donor), spice(f.beforePledge, f.donor) - 2);
|
||||
assert.equal(spice(done, f.emperor!), spice(f.beforePledge, f.emperor!) + 3);
|
||||
assert.equal(done.bureaucratPayments!.used.length, 1);
|
||||
assert.equal(viewGame(done, f.trainer).bureaucrat.usedThisPhase, true);
|
||||
assert.equal(viewGame(done, f.trainer).bureaucrat.pending, null);
|
||||
rejectUnchanged(done, f.trainer, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true });
|
||||
|
||||
// Exercise the original ordinary opener, not merely the special-lot callback.
|
||||
const balances = done.players.map(p => [p.id, p.spice]);
|
||||
const hands = done.players.map(p => [p.id, p.hand.map(c => c.id)]);
|
||||
const used = structuredClone(done.bureaucratPayments!.used);
|
||||
const actor = done.auction!.active;
|
||||
const next = allowRicheseBureaucratResponses(applyAction(done, actor, { type: 'passBid' }));
|
||||
assert.equal(next.auction!.index, 0);
|
||||
assert.notEqual(next.auction!.active, actor);
|
||||
assert.deepEqual(next.auction!.cards, done.auction!.cards);
|
||||
assert.deepEqual(next.players.map(p => [p.id, p.spice]), balances);
|
||||
assert.deepEqual(next.players.map(p => [p.id, p.hand.map(c => c.id)]), hands);
|
||||
assert.deepEqual(next.bureaucratPayments!.used, used);
|
||||
assert.deepEqual(richeseBureaucratCards(next), f.physical);
|
||||
});
|
||||
|
||||
void test('Advanced native cache 5+5: decline seller, allow Emperor, reject stale seller event and redirect only donor income', () => {
|
||||
const f = createRicheseBureaucratFixture({ primary: 5, pledge: 5, counter: true });
|
||||
const seller = payRicheseBureaucrat(f);
|
||||
assert.equal(seller.decision?.kind, 'bureaucratPayment');
|
||||
assertPublicAllocation(f, seller, f.seller);
|
||||
assertPaidCard(f, seller);
|
||||
assert.equal(spice(seller, f.seller), spice(f.beforePledge, f.seller) - 5);
|
||||
const sellerEvent = seller.bureaucratPaymentEvent;
|
||||
|
||||
const income = chooseRicheseBureaucrat(seller, false);
|
||||
assert.equal(income.response?.kind, 'emperorIncome');
|
||||
assert.equal(spice(income, f.seller), spice(f.beforePledge, f.seller));
|
||||
assert.equal(income.bureaucratPayments?.used.length ?? 0, 0);
|
||||
const donor = allowRicheseBureaucratResponses(income);
|
||||
assert.equal(donor.decision?.kind, 'bureaucratPayment');
|
||||
assert.notEqual(donor.bureaucratPaymentEvent, sellerEvent);
|
||||
assertPublicAllocation(f, donor, f.emperor!);
|
||||
assert.equal(donor.bureaucratPayments!.pending!.source.amount, 5);
|
||||
rejectUnchanged(donor, f.trainer, { type: 'decision', event: sellerEvent, redirect: true });
|
||||
rejectUnchanged(donor, f.donor, { type: 'decision', event: donor.bureaucratPaymentEvent, redirect: true });
|
||||
|
||||
const done = allowRicheseBureaucratResponses(chooseRicheseBureaucrat(donor, true));
|
||||
assertNormalContinuation(f, done);
|
||||
assert.equal(spice(done, f.seller), spice(f.beforePledge, f.seller));
|
||||
assert.equal(spice(done, f.emperor!), spice(f.beforePledge, f.emperor!) + 3);
|
||||
assert.equal(done.bureaucratPayments!.used.length, 1);
|
||||
assert.equal(done.bureaucratPayments!.used[0].event, donor.bureaucratPaymentEvent);
|
||||
});
|
||||
|
||||
void test('Advanced native cache 5+5: seller diversion does not cancel native Emperor Karama or pool buyer Bank two with donor five', () => {
|
||||
const f = createRicheseBureaucratFixture({ primary: 5, pledge: 5, counter: true });
|
||||
const seller = payRicheseBureaucrat(f);
|
||||
const sourceCount = f.beforeAction.spiceBankerIncome!.sources.length;
|
||||
const bankerBalance = spice(f.beforeAction, f.banker);
|
||||
const income = chooseRicheseBureaucrat(seller, true);
|
||||
assert.equal(income.response?.kind, 'emperorIncome');
|
||||
assert.equal(spice(income, f.seller), spice(f.beforePledge, f.seller) - 2);
|
||||
assert.equal(spice(income, f.emperor!), bankerBalance);
|
||||
assert.equal(viewGame(income, f.trainer).bureaucrat.pending, null);
|
||||
assert.equal(viewGame(income, f.trainer).bureaucrat.usedThisPhase, true);
|
||||
assert.deepEqual(viewGame(income, f.banker).spiceBankerIncome!.deferred, []);
|
||||
assert.equal(income.spiceBankerIncome!.sources.slice(sourceCount).some(row => row.grant), false);
|
||||
assert.ok(viewGame(income, f.counterActor).responseControls!.cancelCards.includes(f.counterCard!));
|
||||
|
||||
const canceled = applyAction(income, f.counterActor, { type: 'card', card: f.counterCard!, mode: 'cancel' });
|
||||
const done = allowRicheseBureaucratResponses(canceled);
|
||||
assertNormalContinuation(f, done);
|
||||
assert.equal(spice(done, f.seller), spice(f.beforePledge, f.seller) - 2);
|
||||
assert.equal(spice(done, f.emperor!), bankerBalance);
|
||||
assert.equal(done.bureaucratPayments!.used.length, 1);
|
||||
assert.equal(done.bureaucratPayments!.used[0].event, seller.bureaucratPaymentEvent);
|
||||
assert.equal(viewGame(done, f.trainer).bureaucrat.pending, null);
|
||||
assert.equal(done.discard.filter(c => c.id === f.counterCard).length, 1);
|
||||
assert.equal(richeseBureaucratPlayer(done, f.counterActor).hand.some(c => c.id === f.counterCard), false);
|
||||
const grants = done.spiceBankerIncome!.sources.slice(sourceCount).filter(row => row.grant);
|
||||
assert.equal(grants.length, 1);
|
||||
assert.deepEqual(grants[0].source.bankLegs, [{ payer: f.donor, amount: 5 }]);
|
||||
assert.deepEqual(viewGame(done, f.banker).spiceBankerIncome!.deferred, [{ owner: f.banker, amount: 1 }]);
|
||||
rejectUnchanged(done, f.counterActor, { type: 'card', card: f.counterCard!, mode: 'cancel' });
|
||||
|
||||
// Native passes and endMovement leave the deferred credit untouched until Mentat.
|
||||
let beforeMentat = done;
|
||||
for (let n = 0; (beforeMentat.phase !== 7 || beforeMentat.phaseOpening || beforeMentat.response || beforeMentat.decision) && n < 400; n++) {
|
||||
const step = nextSpiceBankerIncomeNativeStep(beforeMentat);
|
||||
assert.ok(step);
|
||||
beforeMentat = applyAction(beforeMentat, step.actor, step.action);
|
||||
}
|
||||
assert.equal(beforeMentat.phase, 7);
|
||||
assert.equal(spice(beforeMentat, f.banker), bankerBalance);
|
||||
assert.deepEqual(viewGame(beforeMentat, f.banker).spiceBankerIncome!.deferred, [{ owner: f.banker, amount: 1 }]);
|
||||
rejectUnchanged(beforeMentat, f.banker, { type: 'bribe', target: f.trainer, amount: bankerBalance + 1 });
|
||||
const collected = advanceSpiceBankerIncomeToMentat(beforeMentat);
|
||||
assert.equal(spice(collected, f.banker), bankerBalance + 1);
|
||||
assert.deepEqual(viewGame(collected, f.banker).spiceBankerIncome!.deferred, []);
|
||||
const collection = collected.spiceBankerIncome!.collections.at(-1)!;
|
||||
assert.deepEqual(collection.credits, [{ owner: f.banker, amount: 1 }]);
|
||||
assert.equal(collection.grants.filter(cursor => cursor === grants[0].cursor).length, 1);
|
||||
assert.deepEqual(richeseBureaucratCards(collected), f.physical);
|
||||
});
|
||||
|
||||
void test('Advanced native cache 4+4: separate seller and Emperor receipts never pool into a Bureaucrat payer', () => {
|
||||
const f = createRicheseBureaucratFixture({ primary: 4, pledge: 4, counter: true });
|
||||
const income = payRicheseBureaucrat(f);
|
||||
assert.equal(income.response?.kind, 'emperorIncome');
|
||||
assertPaidCard(f, income);
|
||||
assert.equal(spice(income, f.seller), spice(f.beforePledge, f.seller));
|
||||
assert.equal(viewGame(income, f.trainer).bureaucrat.pending, null);
|
||||
const done = allowRicheseBureaucratResponses(income);
|
||||
assertNormalContinuation(f, done);
|
||||
assert.equal(spice(done, f.seller), spice(f.beforePledge, f.seller));
|
||||
assert.equal(spice(done, f.emperor!), spice(f.beforePledge, f.emperor!) + 4);
|
||||
assert.equal(done.bureaucratPayments?.used.length ?? 0, 0);
|
||||
assert.equal(viewGame(done, f.trainer).bureaucrat.pending, null);
|
||||
assert.equal(viewGame(done, f.trainer).bureaucrat.usedThisPhase, false);
|
||||
assert.deepEqual(viewGame(done, f.banker).spiceBankerIncome!.deferred, []);
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import type { LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { createIndependentCardModulesFixture } from './fixture-independent-card-modules';
|
||||
import { homeworldEcazClean, nextHomeworldEcazStep } from './fixture-homeworld-ecaz-occupy';
|
||||
|
||||
export type RicheseBureaucratOptions = {
|
||||
source?: 'cache' | 'blackMarket';
|
||||
advanced?: boolean;
|
||||
primary?: number;
|
||||
pledge?: number;
|
||||
buyer?: 'atreides' | 'emperor';
|
||||
owner?: 'beneGesserit' | 'atreides' | 'emperor' | 'richese';
|
||||
emperor?: boolean;
|
||||
counter?: boolean;
|
||||
};
|
||||
export type RicheseBureaucratStep = { actor: string; action: Action };
|
||||
export function richeseBureaucratPlayer(game: Game, id: string) {
|
||||
const player = game.players.find(p => p.id === id);
|
||||
assert.ok(player, `Missing original seat ${id}`);
|
||||
return player;
|
||||
}
|
||||
export function richeseBureaucratCards(game: Game): string[] {
|
||||
return [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
||||
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
||||
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? []),
|
||||
].map(c => c.id).sort();
|
||||
}
|
||||
export function allowRicheseBureaucratResponses(state: Game): Game {
|
||||
let game = state;
|
||||
for (let n = 0; game.response && n < 40; n++) {
|
||||
const actor = game.players.find(p => {
|
||||
const controls = viewGame(game, p.id).responseControls;
|
||||
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
|
||||
});
|
||||
assert.ok(actor, 'An original unpassed counter holder must remain');
|
||||
game = applyAction(game, actor.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(game.response, null);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Genuine native all14 setup, ordinary first-turn play, a real one-spice bribe
|
||||
* collected only at Mentat, original turn-two worm alliance and native special
|
||||
* lot. Only unused Spice ordering and a conserved counter-card swap are staged. */
|
||||
export function createRicheseBureaucratFixture(options: RicheseBureaucratOptions = {}) {
|
||||
const advanced = options.advanced ?? true, source = options.source ?? 'cache';
|
||||
assert.ok(advanced || source === 'cache');
|
||||
const payer = options.buyer ?? 'atreides', trainer = options.owner ?? 'beneGesserit';
|
||||
const donor = 'richese', emperor = options.emperor === false ? undefined : 'emperor';
|
||||
const roster: FactionId[] = ['richese', 'atreides', 'beneGesserit', emperor ?? 'guild'];
|
||||
assert.ok(roster.includes(payer) && roster.includes(trainer));
|
||||
const training: Partial<Record<FactionId, LeaderSkillId>> = {
|
||||
richese: 'smuggler', atreides: 'planetologist', beneGesserit: 'warmaster',
|
||||
[emperor ?? 'guild']: 'spice-banker', [trainer]: 'bureaucrat',
|
||||
};
|
||||
if (trainer === 'emperor') training.beneGesserit = 'spice-banker';
|
||||
const setup = createIndependentCardModulesFixture({ advanced, factions: roster, expansions: ['choam'],
|
||||
skills: true, independent: false, tech: false, training });
|
||||
let game = setup.game;
|
||||
const afterSetup = structuredClone(game), actions: RicheseBureaucratStep[] = [];
|
||||
const act = (actor: string, action: Action) => { actions.push({ actor, action }); game = applyAction(game, actor, action); };
|
||||
const next = () => {
|
||||
if (game.phase === 5 && homeworldEcazClean(game)) act(game.active!, { type: 'endMovement' });
|
||||
else {
|
||||
const step = nextHomeworldEcazStep(game);
|
||||
assert.ok(step, `Missing original Richese control ${game.phase}/${game.decision?.kind}`);
|
||||
act(step.actor, step.action);
|
||||
}
|
||||
};
|
||||
const reach = (until: (g: Game) => boolean) => {
|
||||
for (let n = 0; !until(game) && n < 800; n++) next();
|
||||
assert.ok(until(game), 'Reach the original Richese funding boundary');
|
||||
};
|
||||
// The first native cache acquisition costs Richese one. This real deferred
|
||||
// transfer, not a wallet assignment, makes a five-spice later pledge possible.
|
||||
act('atreides', { type: 'bribe', target: donor, amount: 1 });
|
||||
reach(g => g.turn === 2 && g.phase === 1 && homeworldEcazClean(g));
|
||||
const wormAt = game.spiceDeck.findIndex(c => 'worm' in c && !c.greatMaker && !c.suppressed);
|
||||
assert.ok(wormAt >= 0);
|
||||
game.spiceDeck.unshift(game.spiceDeck.splice(wormAt, 1)[0]);
|
||||
reach(g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && homeworldEcazClean(g));
|
||||
const worm = structuredClone(game);
|
||||
act(donor, { type: 'alliance', target: payer });
|
||||
act(payer, { type: 'alliance', target: donor });
|
||||
const alliance = structuredClone(game);
|
||||
reach(g => g.phase === 3 && g.decision?.kind === (advanced ? 'richeseBlackMarket' : 'richeseDeclaration'));
|
||||
const choose = (values: Record<string, unknown>) => act(donor,
|
||||
{ type: 'decision', event: game.richeseBidding!.event, ...values } as Action);
|
||||
if (advanced) {
|
||||
if (source === 'blackMarket') choose({ card: richeseBureaucratPlayer(game, donor).hand[0].id, method: 'silent' });
|
||||
else choose({ decline: true });
|
||||
}
|
||||
if (source === 'cache') {
|
||||
choose({ position: 'first' });
|
||||
game = allowRicheseBureaucratResponses(game);
|
||||
choose({ card: game.richeseCache![0].id, method: 'silent' });
|
||||
}
|
||||
game = allowRicheseBureaucratResponses(game);
|
||||
assert.ok(game.richeseAuction && !game.richeseAuction.outcome);
|
||||
const staging = ['Conserved original unplayed worm moved to the next Spice reveal; actual worm alliance window retained.'];
|
||||
const counterActor = 'beneGesserit';
|
||||
let counterCard: string | undefined;
|
||||
if (options.counter) {
|
||||
const actor = richeseBureaucratPlayer(game, counterActor);
|
||||
const held = actor.hand.find(c => c.effect === 'karama');
|
||||
if (held) counterCard = held.id;
|
||||
else {
|
||||
const at = game.deck.findIndex(c => c.effect === 'karama');
|
||||
assert.ok(at >= 0 && actor.hand.length > 0);
|
||||
const original = actor.hand[0];
|
||||
actor.hand[0] = game.deck[at]; game.deck[at] = original;
|
||||
counterCard = actor.hand[0].id;
|
||||
staging.push(`Swap original unplayed ${counterCard} with held ${original.id}; retain hand size and physical stock.`);
|
||||
}
|
||||
}
|
||||
const primary = options.primary ?? 3, pledge = options.pledge ?? 5, price = primary + pledge;
|
||||
const beforePledge = structuredClone(game), physical = richeseBureaucratCards(game);
|
||||
act(donor, { type: 'pledgeAid', amount: pledge });
|
||||
const beforeAction = structuredClone(game), card = game.richeseAuction!.cardId;
|
||||
const action: Action = { type: 'richeseBid', event: game.richeseAuction!.event, amount: price, allyPayment: pledge };
|
||||
return { game, afterSetup, worm, alliance, beforePledge, beforeAction, actions, staging,
|
||||
source, advanced, payer, donor, seller: donor, trainer, emperor, counterActor, counterCard,
|
||||
banker: game.leaderSkills!.assignments.find(a => a.skill === 'spice-banker')!.owner,
|
||||
primary, pledge, price, card, action, physical };
|
||||
}
|
||||
export type RicheseBureaucratFixture = ReturnType<typeof createRicheseBureaucratFixture>;
|
||||
export function payRicheseBureaucrat(fixture: RicheseBureaucratFixture): Game {
|
||||
let game = applyAction(fixture.game, fixture.payer, fixture.action);
|
||||
for (const actor of game.richeseAuction!.order)
|
||||
if (actor !== fixture.payer) game = applyAction(game, actor,
|
||||
{ type: 'richeseBid', event: fixture.action.event, amount: 0 });
|
||||
return game;
|
||||
}
|
||||
export function chooseRicheseBureaucrat(game: Game, redirect: boolean): Game {
|
||||
assert.equal(game.decision?.kind, 'bureaucratPayment');
|
||||
return applyAction(game, game.decision!.player,
|
||||
{ type: 'decision', event: game.bureaucratPaymentEvent, redirect });
|
||||
}
|
||||
Reference in new issue
Block a user