diff --git a/components/richese-auctions.tsx b/components/richese-auctions.tsx
index 1250517c..289c9537 100644
--- a/components/richese-auctions.tsx
+++ b/components/richese-auctions.tsx
@@ -310,8 +310,6 @@ export function RicheseAuctionLot({ game, act, busy }: Props) {
...part,
});
const ownPayment = amount - allyPayment;
- const sellerBlocked =
- auction.source === 'blackMarket' && auction.owner === me;
const allowedTurn =
!auction.outcome &&
auction.eligible.includes(me) &&
@@ -324,8 +322,7 @@ export function RicheseAuctionLot({ game, act, busy }: Props) {
allyPayment >= 0 &&
allyPayment <= auction.allyAvailable &&
ownPayment >= 0 &&
- ownPayment <= auction.ownAvailable &&
- (!sellerBlocked || amount === 0);
+ ownPayment <= auction.ownAvailable;
const name = (player: string | null) =>
game.players.find((p) => p.id === player)?.name ?? 'Player';
const send = (bid: number | null, ally: number) => {
@@ -367,6 +364,14 @@ export function RicheseAuctionLot({ game, act, busy }: Props) {
{name(auction.owner)} is selling. Karama cannot acquire this card.
+ {auction.source === 'blackMarket' && (
+
+ Provisional paid self-purchase: if the seller wins a positive bid, they
+ retain this same card and pay the Emperor, or the Bank if absent. It
+ counts as one sale and needs no additional hand space. An unbid card
+ stays free and unsold.
+
+ )}
{auction.claim && (
Seller’s public claim: {auction.claim}
)}
@@ -404,59 +409,49 @@ export function RicheseAuctionLot({ game, act, busy }: Props) {
Available funding: {auction.ownAvailable} own spice and{' '}
{auction.allyAvailable} pledged spice.
- {sellerBlocked && (
-
- Positive Black Market bids by the seller are not supported.{' '}
- {silent ? 'Submit zero to continue.' : 'Pass to continue.'}
-
- )}
- {!sellerBlocked && (
+
+ {silent ? 'Sealed bid' : 'Your bid'} (minimum {minimum})
+
+ update({ amount: event.target.value })}
+ />
+ {auction.allyAvailable > 0 && (
<>
-
- {silent ? 'Sealed bid' : 'Your bid'} (minimum {minimum})
-
+ Pay from ally’s pledge
update({ amount: event.target.value })}
+ onChange={(event) => update({ ally: event.target.value })}
/>
- {auction.allyAvailable > 0 && (
- <>
- Pay from ally’s pledge
- update({ ally: event.target.value })}
- />
- >
- )}
-
- {canBid
- ? `Payment if you win: ${ownPayment} own spice + ${allyPayment} ally spice.`
- : 'Choose a valid bid and funding split within the available spice.'}{' '}
- {silent
- ? 'Your submitted amount stays private until all bids are in and cannot be changed.'
- : 'Raise the current bid or pass.'}
-
- send(amount, allyPayment)}
- >
- {silent ? 'Seal bid' : 'Raise bid'}
-
>
)}
+
+ {canBid
+ ? `Payment if you win: ${ownPayment} own spice + ${allyPayment} ally spice.`
+ : 'Choose a valid bid and funding split within the available spice.'}{' '}
+ {silent
+ ? 'Your submitted amount stays private until all bids are in and cannot be changed.'
+ : 'Raise the current bid or pass.'}
+
+ send(amount, allyPayment)}
+ >
+ {silent ? 'Seal bid' : 'Raise bid'}
+
0)),
+ 'Black Market requires Advanced play; paid seller retention needs a positive actual price.',
);
requireAuction(
!s.free || (
diff --git a/game/engine.ts b/game/engine.ts
index 505d1dac..49e68fda 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -440,6 +440,7 @@ import {
} from './richese-funding';
import {
quoteRicheseSettlement,
+ richeseBidFitsHand,
requireRicheseDeclarationCache,
RicheseSettlementError,
type RicheseSettlementQuote,
@@ -12337,6 +12338,8 @@ function richeseBetrayalIntegrity(g: Game): void {
}
}
function beginRicheseBetrayal(g: Game, quote: Extract): boolean {
+ // Already-held retention supplies neither a cache acquisition nor seller income.
+ if (g.richeseAuction?.source === 'blackMarket' && quote.winner === g.richeseAuction.owner) return false;
if (!g.richeseBetrayalPreview) return false;
richeseBetrayalSourcePreflight(g,quote);
const required = richeseBetrayalResponders(g);
@@ -12464,6 +12467,8 @@ function nexusEmperorSellerOffer(g: Game, id: string) {
return { ...quote, blocked, canUse: !blocked, policy: EMPEROR_SELLER_POLICY };
}
function beginNexusEmperorSellerPurchase(g: Game, quote: Extract, bank: boolean): boolean {
+ // The seller/free-acquisition source cannot waive an already-held card's price.
+ if (g.richeseAuction?.source === 'blackMarket' && quote.winner === g.richeseAuction.owner) return false;
if (quote.amount <= 0 || !emperorNexusSellerPurchaseModeSupported(g)) return false;
requireRule(!g.pendingNexusEmperorSellerPurchase && !g.pendingRicheseBetrayal &&
!g.response && !g.decision && !g.truthtrance && !g.phaseOpening &&
@@ -12517,10 +12522,13 @@ function settleRicheseSoldLot(g: Game, quote: Extract c.id !== card.id);
- else owner.hand = owner.hand.filter((c) => c.id !== card.id);
- winner.hand.push(card);
+ const retained = lot.source === 'blackMarket' && winner.id === owner.id;
+ if (!retained) {
+ if (lot.source === 'cache')
+ g.richeseCache = g.richeseCache!.filter((c) => c.id !== card.id);
+ else owner.hand = owner.hand.filter((c) => c.id !== card.id);
+ winner.hand.push(card);
+ }
g.currentAuctionSale = {
...(g.homeworldOccupationPreview ? { occupiedEvent: lot.event,
occupiedContributions: free ? [] : [{ payer: winner.id, amount: quote.ownPayment },
@@ -12538,7 +12546,7 @@ function settleRicheseSoldLot(g: Game, quote: Extract {
+ const bidder = getPlayer(g, id);
+ return richeseBidFitsHand(source, owner.id, id, bidder.hand.length, handLimit(bidder));
+ }),
order,
tieOrder: g.order,
});
@@ -15141,17 +15152,16 @@ function finishAutomaticDecision(g: Game): boolean {
return true;
}
if (g.phase === 3 && !decision && lot && !lot.outcome) {
+ const active = lot.active ? getPlayer(g, lot.active) : null;
const full =
lot.method === 'silent'
- ? lot.eligible.find(
- (id) =>
- !Object.hasOwn(lot.sealed, id) &&
- getPlayer(g, id).hand.length >= handLimit(getPlayer(g, id)),
- )
- : lot.active &&
- getPlayer(g, lot.active).hand.length >=
- handLimit(getPlayer(g, lot.active))
- ? lot.active
+ ? lot.eligible.find(id => {
+ const bidder = getPlayer(g, id);
+ return !Object.hasOwn(lot.sealed, id) &&
+ !richeseBidFitsHand(lot.source, lot.owner, id, bidder.hand.length, handLimit(bidder));
+ })
+ : active && !richeseBidFitsHand(lot.source, lot.owner, active.id, active.hand.length, handLimit(active))
+ ? active.id
: null;
if (full) {
g.richeseAuction = submitRicheseBid(
@@ -29995,7 +30005,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
: lot.bidder === bidder.id;
if (hasWinningCommitment)
requireRule(
- bidder.hand.length < handLimit(bidder),
+ richeseBidFitsHand(lot.source, lot.owner, bidder.id, bidder.hand.length, handLimit(bidder)),
'Leave room for the card in your committed auction.',
);
}
@@ -32833,14 +32843,7 @@ function applyActionInner(
g.phase === 3 && lot && !lot.outcome,
'There is no open Richese auction.',
);
- requireRule(p.hand.length < handLimit(p), 'Your hand is full.');
- requireRule(
- lot.source !== 'blackMarket' ||
- p.id !== lot.owner ||
- action.amount === null ||
- action.amount === 0,
- 'A positive Black Market self-bid is awaiting a ruling on payment and sale counting.',
- );
+ requireRule(richeseBidFitsHand(lot.source, lot.owner, p.id, p.hand.length, handLimit(p)), 'Your hand is full.');
const amount =
action.amount === null
? null
diff --git a/game/reference.ts b/game/reference.ts
index 0b8c780f..f28a5a06 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -416,11 +416,11 @@ export const RULE_TOPICS: RuleTopic[] = [
'The collection belongs to Richese in both Basic and Advanced play. Advanced Black Market and special Karama acquisition add ways to obtain cards; they do not change these ten physical identities.',
'Development tables support the cache auction first or last, with Once Around or Silent bidding. Advanced Black Market offers a hand card using normal, Once Around or Silent bidding; other players may buy it, and an unbid or canceled offer returns to the seller.',
'The controls show the card only to entitled viewers, accept a public Black Market claim, and let bidders choose their own and pledged ally spice. Silent bids remain private until all eligible players submit. A donor cannot reduce pledged funding during unfinished Silent bidding, and a Black Market card reserved for sale cannot be played elsewhere. Atreides inspection, applicable Harkonnen bonus draws and Ixian allied replacement follow the sale’s origin.',
- 'Other buyers pay Richese for a cache or Black Market sale. Richese buying its own cache card pays the Emperor or bank. Only the winner’s committed funding is collected; losing Silent bids do not spend their spice. An unbid cache card may be kept for free if the hand has room, or removed from the game.',
+ 'Other buyers pay Richese for a cache or Black Market sale. Richese buying its own cache card pays the Emperor or Bank. Under a visibly provisional paid-retention policy, a positive Black Market seller win likewise pays Emperor or Bank, retains the same held card without another hand slot, and counts as one sale. No self-refund or duplicate acquisition occurs. Only winning committed funding is collected; losing Silent bids spend nothing. Zero/unbid retention remains free and unsold. An unbid cache card may be kept for free only if the hand has room, or removed from the game.',
'The opt-in Richese Nexus Betrayal auction preview adds a neutral before-payment acknowledgement. A legal rival holder may veto a public self-cache purchase or send another buyer’s sale payment to the bank. See its separate guide and bounded verification evidence; normal hidden self-purchases and special-Karama acquisition are not connected triggers.',
- 'A player whose hand becomes full is automatically passed, or submits zero in an unfinished Silent lot. A full-hand owner’s unbid cache card is removed automatically. If every hand is full, preparing the normal pool does not draw and lose unseen cards.',
+ 'A bidder whose hand becomes full is automatically passed, or submits zero in an unfinished Silent lot, unless it is the Black Market seller retaining its already-held card. The same current hand rule governs eligibility, commitments and settlement. A full-hand owner’s unbid cache card is still removed automatically. If every ordinary hand is full, normal-pool preparation does not draw and lose unseen cards.',
'AI supports cache choices and all three offered bidding methods using its own projected information. It currently skips the optional Black Market prelude. A seller in an already offered Black Market lot passes or submits zero.',
- 'Karama may prevent the declared cache auction before the ordinary pool is drawn. Cancellation leaves the cache intact and restores its deducted ordinary lot; first/last position does not create another cache offer that round. The independent reduction for a completed Black Market sale remains. An exhausted cache similarly causes no cache deduction. Positive Black Market self-bids and advanced Ixian Technology substitution on special lots retain their separate boundaries.',
+ 'Karama may prevent the declared cache auction before the ordinary pool is drawn. Cancellation leaves the cache intact and restores its deducted ordinary lot; first/last position does not create another cache offer that round. The independent reduction for a completed Black Market sale remains, including provisional paid retention. An exhausted cache causes no cache deduction. The supplied Advanced source excludes Richese lots from Ixian Technology.',
'Juice of Sapho now supports bounded first/last Once Around, movement and remaining battle-choice ordering; see its timing guide for current limits. Richese’s Karama uses the existing generic handler, Distrans supports a separate private hand transfer, Nullentropy Box supports a paid private discard search, Ornithopter supports its two movement modes, and Residual Poison supports a random opposing leader death before leader commitment in development fixtures. Portable Snooper supports ordinary and late poison defense. Stone Burner supports a guarded weapon commitment and revealed leader-effect choice. Mirror Weapon copies opposing revealed attacks, with independent copy-first choices for Tooth and Stone in bounded CHOAM/Richese-deck classic-faction battles. A winning physical Mirror may be kept even after copying activated Tooth or Artillery under the user-selected interpretation, not a publisher clarification. Semuta Drug has a bounded clean public ordinary-discard preview; other discard producers and Sapho timing modes remain unfinished. This does not certify every Karama interaction. Richese expansion starts remain disabled. Each component guide identifies its currently available action. Independent continuation and persisted-room checks pass. Desktop checks confirmed all ten enlarged guides, direct topic links, Once Around payment and restored private Silent bidding. Mobile and full Richese-game verification remain unfinished.',
],
related: [
@@ -440,13 +440,13 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
- 'Verified inventory and the first/last cache and advanced Black Market auction pipelines are integrated in development fixtures, including funding, secrecy and sale-origin effects. Card actions and guarded unresolved combinations remain unfinished; full starts stay disabled.',
+ 'Original inventory, first/last cache and Advanced Black Market pipelines retain funding, secrecy and source effects. Provisional paid seller retention now keeps the same card/order, pays native Emperor/Bank income, permits full hands without another slot and counts one sale. One shared current-hand rule governs eligibility, automatic skips, commitments and settlement. Card combinations and complete-mode acceptance remain unfinished.',
},
{
area: 'Player controls',
status: 'Partial',
detail:
- 'Readable inspectors accompany cache/hand offer selection, method and direction, first/last declaration, public claims, sealed bids, funding splits and free-card/removal choices. Desktop catalog inspection, bidding and sealed-bid restoration passed. Browser rechecking confirmed the printed hand category, disabled unsupported action, visible reason and working inspector. Mobile and ordinary card-effect verification remain unfinished.',
+ 'Readable inspectors and original offer/method/direction, declaration, claim, sealed bid, funding and disposition controls remain. A visible provisional paid-retention notice precedes positive seller bidding; an already-held full hand can enter a legal price. Three390px Normal, Once Around and Silent flows exercise real bid/counter/income and next native controls without errors or overflow. Wider card-effect and full-game acceptance remain unfinished.',
},
{
area: 'AI',
@@ -464,8 +464,12 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Verification',
status: 'Partial',
detail:
- 'Focused checks pass for thirteen engine scenarios, eleven independent reviews, seven persisted-room scenarios, eight AI scenarios across four profiles, eighteen auction mechanisms, nine funding cases, six inventory cases, six presentation cases and five roster cases. Bounded desktop inspection, payment and reconnect checks pass. Full Richese games and mobile coverage remain unverified.',
+ 'Earlier inventory, mechanisms, AI, persisted-room and catalog evidence remains dated in the linked contracts. New real producers earn a full hand through cache/ordinary purchases, preserve private Silent offers, distinguish paid versus zero retention, and cover native cancellation, actual Banker legs, bare rules and Nexus source eligibility. Three mobile flows and four legal full-hand seller policies pass. Full modes, comprehensive assurance and deployed acceptance remain open.',
evidence: [
+ 'tests/richese-self-purchase-rounds.test.ts',
+ 'tests/richese-self-purchase-silent.test.ts',
+ 'tests/fixture-richese-self-purchase-full-hand.ts',
+ 'docs/RICHESE_AUCTION_RULES.md',
'tests/richese-cards.test.ts',
'tests/richese-card-presentation.test.ts',
'tests/richese-engine.test.ts',
@@ -4481,7 +4485,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'The Advanced one-per-turn Fedaykin limit still applies across ordinary revivals and this card. The extra total allowance does not permit another elite revival. Fewer than three eligible counters is an unresolved case and remains unavailable.',
'The independent revival grant enters original pending revival. Under the visible provisional policy, native Tleilaxu prevention/income, typed reserves, Tech and eligible Homeworld handling remain; ordinary free-rate blocking does not reprice it. Keeping the card creates no extra public confirmation.',
'At an eligible original classic/native Nexus normal bank-auction winner payment, prove the positive final bid from your own spice and spend Emperor Nexus to receive the lot without losing spice. Every winner sees the same payment confirmation; the provisional privacy recommendation remains. Canonical native module envelopes preserve original card/capacity, bonus and next-lot inspection; ordinary spice or legal Karama remains available. No bank subsidy or Richese cache/seller route is invented. Strict old physical revival admission is separate and never broadens.',
- 'Original Richese cache and Advanced Black Market offer Pay or eligible Emperor Nexus Use after original Richese Betrayal. The provisional seller-unpaid policy retains your full personally affordable price without bank reimbursement. One original lot, native Harkonnen bonus and source successor continue; legal self-cache is included but self-Black remains prohibited. Original self-purchase forced discard cannot consume this Nexus or acquire a card.',
+ 'Original Richese cache and other-buyer Advanced Black Market offer Pay or eligible Emperor Nexus Use after original Richese Betrayal. The provisional seller-unpaid policy retains your full personally affordable price without bank reimbursement. One original lot, native Harkonnen bonus and source successor continue; legal self-cache is included. Already-held Black Market paid retention supplies no acquisition/free-seller source, so it does not invent this confirmation or spend a Nexus. Original self-purchase forced discard cannot consume this Nexus or acquire a card.',
'A committed Richese Ambassador trigger now offers its original beneficiary Pay or eligible Emperor Nexus Use, with no new decline. Under the visible provisional purchase composition, prove and keep three own spice while drawing one original deck card; no bank payment, reimbursement or fake auction is made. Original private inability, native bonus and entrant movement/worm continuation remain. Allies cannot Use; actual forming-alliance Nexus disposal stays original.',
'Original Advanced Richese special Karama has an eligible private joint option: spend the held Karama plus Emperor Nexus at native purchase commit after proving three own uncommitted spice, keep that spice and acquire one exact unreserved cache card. Native once-use, full-hand/final-cache and enclosing controls remain; no bank money, ally bonus, fake auction or automatic public pause. Ordinary Pay3 remains available. This source composition is visibly provisional, not immunity or broader Kull admission.',
],
@@ -4514,7 +4518,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'The panel shows the public Richese target, original buyer, cache/Black Market source and winning price. It does not show payer resources, ally balances, rival hands, secret Nexus identities or the saved private receipt. Read your own unavailable reason rather than guessing Use from a held-card face alone.',
'A public cache face remains inspectable through existing entitled Card controls. A concealed Black Market face stays unknown unless you already have a separate inspection entitlement; a seller’s public claim is not its face. Nexus inspection shows printed reference text, not anybody’s hidden custody. This response grants no new card inspection.',
'Normal-deck Richese self-purchases are outside the connected veto boundary because the Richese-family identity may be hidden. The private advanced special-Karama acquisition has its own activating card and once-use ordering and is also outside this boundary. Native Karama exceptions do not certify Nexus coverage of either producer.',
- 'Existing exhausted-cache, cache-ordering, positive Black Market seller-bid and special-acquisition guards remain. Unsupported counters or any-time overlays must not enter this saved purchase boundary. Finish the response before underlying bidding or other actions can change its reserved card or funding.',
+ 'Existing cache-count, ordering and special-acquisition boundaries remain. Already-held Black Market paid retention supplies neither a cache acquisition nor seller income, so no veto/diversion invoice is invented. Unsupported counters or any-time overlays must not enter this saved purchase boundary. Finish a real response before underlying bidding or other actions can change its reserved card or funding.',
'Refreshing resumes the same event, passes and original auction continuation. Saved pending and completed history must retain exact physical source and once-only payment without rerolls, raw-action replay or requiring old cards to remain forever in discard. Stale or foreign event submissions reject rather than selecting another lot.',
'All four AI difficulties use the same private legal Use/public Pass projection. This is minimal legal participation, not calibrated Richese strategy. Bounded engine, authenticated SQL, real table controls and refreshed phone-browser scenarios are verified locally; full Richese/Nexus games, wider compositions and deployed acceptance remain open.',
],
@@ -4852,7 +4856,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. 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.',
+ '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. Positive Black Market self-purchase is provisionally paid retention of the same card, with full native Emperor/Bank income and no extra hand slot. Supplied Advanced p34 excludes Richese auctions from Ixian Technology; other module and complete-mode boundaries remain explicit.',
],
related: ['leader-skills', 'richese-cards', 'richese-no-field', 'spice-banker-income', 'implementation-checklist'],
checklist: [
diff --git a/game/richese-settlement.ts b/game/richese-settlement.ts
index ba3db3ec..2678b6b4 100644
--- a/game/richese-settlement.ts
+++ b/game/richese-settlement.ts
@@ -42,6 +42,16 @@ export type RicheseSettlementQuote =
allyPayment: number;
};
+/** A Black Market seller already holds the offered card. Provisional paid
+ * retention therefore needs no additional hand slot; other wins still do. */
+export function richeseBidFitsHand(
+ source: RicheseAuction['source'], owner: string, bidder: string,
+ handCount: number, handLimit: number,
+): boolean {
+ return handCount < handLimit ||
+ source === 'blackMarket' && owner === bidder && handCount === handLimit;
+}
+
function requireSettlement(
condition: unknown,
message: string,
@@ -106,13 +116,9 @@ export function quoteRicheseSettlement(
const card = reservedCard(input);
validHand(winner);
requireSettlement(
- winner.handCount < winner.handLimit,
+ richeseBidFitsHand(lot.source, lot.owner, winner.id, winner.handCount, winner.handLimit),
'The winning hand has no room for this card.',
);
- requireSettlement(
- lot.source !== 'blackMarket' || winner.id !== owner.id,
- 'Black Market self-purchase is awaiting a ruling on payment and sale counting.',
- );
const { funding } = input;
requireSettlement(
funding && funding.amount === lot.outcome.amount,
diff --git a/tests/fixture-bureaucrat-richese-funding.ts b/tests/fixture-bureaucrat-richese-funding.ts
index 216f3b69..8423cbd1 100644
--- a/tests/fixture-bureaucrat-richese-funding.ts
+++ b/tests/fixture-bureaucrat-richese-funding.ts
@@ -2,6 +2,7 @@ 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 type { RicheseAuctionMethod } from '../game/richese-auction';
import { createIndependentCardModulesFixture } from './fixture-independent-card-modules';
import { homeworldEcazClean, nextHomeworldEcazStep } from './fixture-homeworld-ecaz-occupy';
@@ -14,6 +15,8 @@ export type RicheseBureaucratOptions = {
owner?: 'beneGesserit' | 'atreides' | 'emperor' | 'richese';
emperor?: boolean;
counter?: boolean;
+ method?: RicheseAuctionMethod;
+ direction?: 'clockwise' | 'counterclockwise';
};
export type RicheseBureaucratStep = { actor: string; action: Action };
export function richeseBureaucratPlayer(game: Game, id: string) {
@@ -89,7 +92,8 @@ export function createRicheseBureaucratFixture(options: RicheseBureaucratOptions
const choose = (values: Record) => 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' });
+ if (source === 'blackMarket') choose({ card: richeseBureaucratPlayer(game, donor).hand[0].id,
+ method: options.method ?? 'silent', direction: options.direction ?? 'clockwise' });
else choose({ decline: true });
}
if (source === 'cache') {
diff --git a/tests/fixture-richese-self-purchase-full-hand.ts b/tests/fixture-richese-self-purchase-full-hand.ts
new file mode 100644
index 00000000..5beaac2a
--- /dev/null
+++ b/tests/fixture-richese-self-purchase-full-hand.ts
@@ -0,0 +1,105 @@
+import assert from 'node:assert/strict';
+import { applyAction, handLimit, 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';
+import {
+ allowRicheseBureaucratResponses, richeseBureaucratCards, richeseBureaucratPlayer,
+} from './fixture-bureaucrat-richese-funding';
+
+/** Original Advanced setup and purchases, not a full-hand state injection.
+ * Only the conserved, unopened turn-two worm is moved to the next Spice reveal. */
+export function createRicheseFullHandSelfPurchaseGame(options: {
+ skills?: boolean;
+ emperor?: boolean;
+ owner?: 'beneGesserit' | 'atreides' | 'emperor' | 'richese';
+} = {}): Game {
+ const emperor = options.emperor !== false, owner = options.owner ?? 'beneGesserit';
+ const roster: FactionId[] = ['richese', 'atreides', 'beneGesserit', emperor ? 'emperor' : 'guild'];
+ assert.ok(roster.includes(owner));
+ const training: Partial> = {
+ richese: 'smuggler', atreides: 'planetologist', beneGesserit: 'warmaster',
+ [emperor ? 'emperor' : 'guild']: 'spice-banker', [owner]: 'bureaucrat',
+ };
+ if (owner === 'emperor') training.beneGesserit = 'spice-banker';
+ let game = createIndependentCardModulesFixture({ advanced: true, factions: roster, expansions: ['choam'],
+ skills: options.skills ?? true, training, independent: false, tech: false }).game;
+ const stock = richeseBureaucratCards(game);
+ assert.equal(stock.length, 45);
+ assert.equal(new Set(stock).size, stock.length);
+ const original = richeseBureaucratPlayer(game, 'richese').hand.map(c => c.id);
+ assert.equal(original.length, 1);
+ assert.equal(richeseBureaucratPlayer(game, 'richese').spice, 5);
+ const act = (actor: string, action: Action) => { game = applyAction(game, actor, action); };
+ const choose = (values: Record) => act('richese',
+ { type: 'decision', event: game.richeseBidding!.event, ...values } as Action);
+ const next = () => {
+ if (game.turn === 1 && !game.response && !game.phaseOpening) {
+ if (game.decision?.kind === 'richeseBlackMarket') { choose({ decline: true }); return; }
+ if (game.decision?.kind === 'richeseDeclaration') { choose({ position: 'first' }); return; }
+ if (game.decision?.kind === 'richeseCache') {
+ choose({ card: game.richeseCache![0].id, method: 'silent' }); return;
+ }
+ if (game.decision?.kind === 'auctionPayment') {
+ act(game.decision.player, { type: 'decision', karama: false }); return;
+ }
+ if (!game.decision && game.richeseAuction && !game.richeseAuction.outcome) {
+ const lot = game.richeseAuction;
+ assert.equal(lot.source, 'cache');
+ const actor = lot.order.find(id => !lot.acted.includes(id));
+ assert.ok(actor);
+ act(actor, { type: 'richeseBid', event: lot.event, amount: actor === 'richese' ? 1 : 0 });
+ return;
+ }
+ if (!game.decision && game.auction) {
+ const actor = game.auction.active;
+ act(actor, actor === 'richese' && richeseBureaucratPlayer(game, actor).hand.length < 4
+ ? { type: 'bid', amount: 1 } : { type: 'passBid' });
+ return;
+ }
+ }
+ if (game.phase === 5 && homeworldEcazClean(game)) act(game.active!, { type: 'endMovement' });
+ else {
+ const step = nextHomeworldEcazStep(game);
+ assert.ok(step, `Missing original full-hand action at ${game.turn}/${game.phase}/${game.decision?.kind}`);
+ act(step.actor, step.action);
+ }
+ };
+ const reach = (until: (state: Game) => boolean) => {
+ for (let n = 0; !until(game) && n < 800; n++) next();
+ assert.ok(until(game), 'Reach the original full-hand purchase boundary');
+ };
+ act('atreides', { type: 'bribe', target: 'richese', amount: 4 });
+ assert.equal(richeseBureaucratPlayer(game, 'richese').spice, 5, 'A promised bribe is not spendable');
+ reach(g => g.phase === 3 && g.richeseBidding?.stage === 'normal' && homeworldEcazClean(g));
+ assert.equal(game.richeseBidding!.position, 'first');
+ assert.equal(richeseBureaucratPlayer(game, 'richese').hand.length, 2);
+ assert.equal(richeseBureaucratPlayer(game, 'richese').spice, 4);
+ reach(g => g.phase === 4 && homeworldEcazClean(g));
+ const full = richeseBureaucratPlayer(game, 'richese');
+ assert.equal(full.hand.length, handLimit(full));
+ assert.equal(full.hand.length, 4);
+ assert.equal(full.hand[0].id, original[0]);
+ assert.equal(full.spice, 2, 'The cache and two ordinary acquisitions each cost one');
+ reach(g => g.phase === 8 && homeworldEcazClean(g));
+ assert.equal(richeseBureaucratPlayer(game, 'richese').spice, 6, 'Only first Mentat collects the four-spice bribe');
+ reach(g => g.turn === 2 && g.phase === 1 && homeworldEcazClean(g));
+ const worm = game.spiceDeck.findIndex(c => 'worm' in c && !c.greatMaker && !c.suppressed);
+ assert.ok(worm >= 0);
+ game.spiceDeck.unshift(game.spiceDeck.splice(worm, 1)[0]);
+ reach(g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && homeworldEcazClean(g));
+ act('richese', { type: 'alliance', target: 'atreides' });
+ act('atreides', { type: 'alliance', target: 'richese' });
+ reach(g => g.phase === 3 && g.decision?.kind === 'richeseBlackMarket');
+ choose({ card: richeseBureaucratPlayer(game, 'richese').hand[0].id, method: 'silent' });
+ game = allowRicheseBureaucratResponses(game);
+ assert.ok(game.richeseAuction && !game.richeseAuction.outcome);
+ assert.equal(game.richeseAuction.acted.length, 0);
+ assert.equal(richeseBureaucratPlayer(game, 'richese').hand.length, 4);
+ assert.equal(richeseBureaucratPlayer(game, 'richese').spice, 6);
+ assert.equal(richeseBureaucratPlayer(game, 'richese').ally, 'atreides');
+ assert.equal(richeseBureaucratPlayer(game, 'atreides').ally, 'richese');
+ assert.deepEqual(richeseBureaucratCards(game), stock);
+ return game;
+}
diff --git a/tests/fixture-richese-self-purchase.ts b/tests/fixture-richese-self-purchase.ts
new file mode 100644
index 00000000..dd307bc2
--- /dev/null
+++ b/tests/fixture-richese-self-purchase.ts
@@ -0,0 +1,98 @@
+import assert from 'node:assert/strict';
+import { applyAction, handLimit, type Action, type Game } from '../game/engine';
+import type { RicheseAuctionMethod } from '../game/richese-auction';
+import {
+ allowRicheseBureaucratResponses, createRicheseBureaucratFixture,
+ richeseBureaucratCards, richeseBureaucratPlayer,
+} from './fixture-bureaucrat-richese-funding';
+
+export type RicheseSelfPurchaseOptions = {
+ method?: RicheseAuctionMethod;
+ direction?: 'clockwise' | 'counterclockwise';
+ price?: number;
+ allyPayment?: number;
+ emperor?: boolean;
+ owner?: 'beneGesserit' | 'atreides' | 'emperor' | 'richese';
+ counter?: boolean;
+ /** An actual original open Black Market lot; never reflag/redeal played state. */
+ initial?: Game;
+};
+export function createRicheseSelfPurchaseFixture(options: RicheseSelfPurchaseOptions = {}) {
+ const method = options.method ?? options.initial?.richeseAuction?.method ?? 'silent';
+ let game = options.initial ? structuredClone(options.initial)
+ : createRicheseBureaucratFixture({ source: 'blackMarket', pledge: 0,
+ method, direction: options.direction, emperor: options.emperor, owner: options.owner }).game;
+ game = allowRicheseBureaucratResponses(game);
+ assert.equal(game.phase, 3);
+ assert.ok(game.richeseAuction && game.richeseAuction.source === 'blackMarket');
+ assert.equal(game.richeseAuction.method, method);
+ assert.equal(game.richeseAuction.outcome, null);
+ assert.equal(game.richeseAuction.acted.length, 0);
+ const seller = game.richeseAuction.owner;
+ const player = richeseBureaucratPlayer(game, seller);
+ assert.equal(player.faction, 'richese');
+ const card = game.richeseAuction.cardId;
+ assert.equal(player.hand.filter(c => c.id === card).length, 1);
+ const trainer = game.leaderSkills?.assignments.find(a => a.skill === 'bureaucrat')?.owner;
+ const banker = game.leaderSkills?.assignments.find(a => a.skill === 'spice-banker')?.owner;
+ const emperor = game.players.find(p => p.faction === 'emperor')?.id;
+ const counterActor = 'beneGesserit';
+ let counterCard: string | undefined;
+ if (options.counter) {
+ const actor = richeseBureaucratPlayer(game, counterActor), 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;
+ }
+ }
+ const price = options.price ?? 5, allyPayment = options.allyPayment ?? 0;
+ const donor = player.ally;
+ const beforePledge = structuredClone(game), actions: { actor: string; action: Action }[] = [];
+ if (allyPayment) {
+ assert.ok(donor, 'Use the original real worm ally, never unpledged funding');
+ const action: Action = { type: 'pledgeAid', amount: allyPayment };
+ actions.push({ actor: donor, action }); game = applyAction(game, donor, action);
+ }
+ const beforeAction = structuredClone(game);
+ const action: Action = { type: 'richeseBid', event: game.richeseAuction!.event, amount: price, allyPayment };
+ return { game, beforePledge, beforeAction, actions, action, seller, payer: seller, donor,
+ method, price, allyPayment, primary: price - allyPayment, card, trainer, banker, emperor,
+ counterActor, counterCard, handLimit: handLimit(player),
+ originalHand: player.hand.map(c => c.id), physical: richeseBureaucratCards(game) };
+}
+export type RicheseSelfPurchaseFixture = ReturnType;
+
+/** Real method opportunities only. Once Around needs a positive other bid to
+ * reach the seller's final raise; all-zero keeps its original unsold shortcut. */
+export function payRicheseSelfPurchase(f: RicheseSelfPurchaseFixture,
+ options: { openingBid?: number } = {}): Game {
+ let game = f.game;
+ const act = (actor: string, action: Action) => {
+ f.actions.push({ actor, action }); game = applyAction(game, actor, action);
+ };
+ if (f.method === 'silent') {
+ const order = game.richeseAuction!.order;
+ act(f.seller, f.action);
+ for (const actor of order) if (actor !== f.seller)
+ act(actor, { type: 'richeseBid', event: f.action.event, amount: 0, allyPayment: 0 });
+ } else {
+ const openingBid = options.openingBid ?? (f.method === 'onceAround' && f.price >= 2 ? 1 : 0);
+ let opened = false;
+ for (let n = 0; game.richeseAuction && !game.richeseAuction.outcome && n < 80; n++) {
+ const actor = game.richeseAuction.active;
+ assert.ok(actor, 'The original ordered auction must retain a bidder');
+ if (actor === f.seller) {
+ act(actor, { ...f.action, amount: f.price > 0 ? f.price : null });
+ } else {
+ const amount = !opened && openingBid > 0 ? openingBid : null;
+ if (amount !== null) opened = true;
+ act(actor, { type: 'richeseBid', event: f.action.event, amount, allyPayment: 0 });
+ }
+ }
+ }
+ assert.ok(!game.richeseAuction || game.richeseAuction.outcome, 'Complete only original auction opportunities');
+ return game;
+}
diff --git a/tests/nexus-emperor-seller-purchase.test.ts b/tests/nexus-emperor-seller-purchase.test.ts
index 1adde862..ac61b58a 100644
--- a/tests/nexus-emperor-seller-purchase.test.ts
+++ b/tests/nexus-emperor-seller-purchase.test.ts
@@ -132,7 +132,6 @@ void test('personal-price, exact source, full hand and physical singleton corrup
const { g, input } = original('blackMarket');
g.advanced = false;
assert.throws(() => createEmperorNexusSellerPurchase(g, input), EmperorNexusPurchaseError);
- assert.throws(() => original('blackMarket', true), /Black Market self-purchase/);
});
void test('private card entitlement and alliances cannot authorize retention, even with enough visible spice', () => {
diff --git a/tests/richese-auction-bots.test.ts b/tests/richese-auction-bots.test.ts
index 809dd428..a11c37d6 100644
--- a/tests/richese-auction-bots.test.ts
+++ b/tests/richese-auction-bots.test.ts
@@ -163,21 +163,6 @@ void test('all profiles skip optional Black Market and legally complete its norm
}
});
-void test('seller policies never emit the unsupported positive Black Market self-bid', () => {
- for (const difficulty of DIFFICULTIES)
- for (const method of ['normal', 'onceAround', 'silent'] as const) {
- const g = offer(method, 'blackMarket', true);
- if (method !== 'silent') g.richeseAuction!.active = 'r';
- const action = botActions(projected(g, 'r', difficulty))[0];
- assert.deepEqual(action, {
- type: 'richeseBid',
- event: g.richeseAuction!.event,
- amount: method === 'silent' ? 0 : null,
- allyPayment: 0,
- });
- assert.doesNotThrow(() => applyAction(g, 'r', action));
- }
-});
void test('unfunded bidders pass, sealed bidders act once, and hidden opponent state cannot affect the bid', () => {
for (const difficulty of DIFFICULTIES) {
diff --git a/tests/richese-engine.test.ts b/tests/richese-engine.test.ts
index 4248f233..a483c73e 100644
--- a/tests/richese-engine.test.ts
+++ b/tests/richese-engine.test.ts
@@ -272,7 +272,7 @@ void test('Atreides inspection grants only its entitled view and survives JSON b
assert.equal(viewGame(g, 'e').richeseAuction!.cardId, null);
assert.equal(g.players[0].hand[0].id, card.id);
});
-void test('connected cache-cancel restores the ordinary pool while seller self-bid rejects atomically', () => {
+void test('connected cache cancellation restores the ordinary pool', () => {
const before = fixture();
const k = before.deck.find((c) => c.effect === 'karama')!;
before.players[2].hand = [k];
@@ -288,13 +288,6 @@ void test('connected cache-cancel restores the ordinary pool while seller self-b
assert.equal(canceled.players[2].hand.some((c) => c.id === k.id), false);
assert.equal(canceled.discard.filter((c) => c.id === k.id).length, 1);
assert.deepEqual(g, snapshot, 'the source declaration is unchanged by the trial cancellation');
- const bm = fixture(true);
- bm.players[0].hand = [bm.deck.shift()!];
- g = begin(bm);
- g = decision(g, { card: g.players[0].hand[0].id, method: 'silent' });
- const exact = structuredClone(g);
- assert.throws(() => bid(g, 'r', 1), /self-bid/);
- assert.deepEqual(g, exact);
});
void test('an outbid ally-funded player whose hand fills is automatically skipped without reserving losing credit', () => {
diff --git a/tests/richese-self-purchase-rounds.test.ts b/tests/richese-self-purchase-rounds.test.ts
new file mode 100644
index 00000000..1a8ebd64
--- /dev/null
+++ b/tests/richese-self-purchase-rounds.test.ts
@@ -0,0 +1,216 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import {
+ allowRicheseBureaucratResponses, chooseRicheseBureaucrat,
+ richeseBureaucratCards, richeseBureaucratPlayer,
+} from './fixture-bureaucrat-richese-funding';
+import {
+ createRicheseSelfPurchaseFixture, payRicheseSelfPurchase,
+ type RicheseSelfPurchaseFixture,
+} from './fixture-richese-self-purchase';
+import { nextHomeworldEcazStep } from './fixture-homeworld-ecaz-occupy';
+
+// Provisional shared Richese payment inheritance, not an official ruling on
+// positive seller participation. The common producer retains the native setup,
+// real Mentat bribe/worm alliance and conserved original counter-card swap.
+function retained(game: Game, f: RicheseSelfPurchaseFixture) {
+ assert.deepEqual(richeseBureaucratPlayer(game, f.seller).hand.map(c => c.id), f.originalHand,
+ 'Paid retention neither removes/reappends the offered card nor makes an acquisition');
+ assert.equal(richeseBureaucratPlayer(game, f.seller).hand.filter(c => c.id === f.card).length, 1);
+ for (const player of game.players)
+ assert.deepEqual(player.hand.map(c => c.id), richeseBureaucratPlayer(f.beforeAction, player.id).hand.map(c => c.id));
+ assert.deepEqual(richeseBureaucratCards(game), f.physical);
+ assert.deepEqual(game.richeseCache, f.beforeAction.richeseCache);
+}
+
+function rejectUnchanged(game: Game, actor: string, action: Action) {
+ const before = structuredClone(game);
+ assert.throws(() => applyAction(game, actor, action));
+ assert.deepEqual(game, before, 'Rejected input preserves custody, wallets, prepaid aid and the original opportunity');
+}
+
+function emperorAllowance(game: Game, f: RicheseSelfPurchaseFixture) {
+ retained(game, f);
+ assert.equal(game.response?.kind, 'emperorIncome');
+ assert.equal(game.response!.owner, f.emperor);
+ assert.ok(viewGame(game, f.counterActor).responseControls?.cancelCards.includes(f.counterCard!));
+ assert.equal(viewGame(game, f.trainer!).bureaucrat.pending, null,
+ 'Native Emperor Karama allowance precedes any optional Bureaucrat diversion');
+ assert.equal(richeseBureaucratPlayer(game, f.emperor!).spice,
+ richeseBureaucratPlayer(f.beforeAction, f.emperor!).spice);
+ assert.equal(richeseBureaucratPlayer(game, f.seller).spice,
+ richeseBureaucratPlayer(f.beforeAction, f.seller).spice - f.primary);
+ assert.equal(game.currentAuctionSale!.winner, f.seller);
+ assert.equal(game.currentAuctionSale!.seller, f.seller);
+ assert.equal(game.currentAuctionSale!.origin, 'blackMarket');
+ assert.equal(game.currentAuctionSale!.amount, f.price);
+ assert.equal(game.currentAuctionSale!.free, false);
+ const invoice = game.currentAuctionSale!.bureaucratPayment!;
+ assert.equal(invoice.payer, f.seller);
+ assert.equal(invoice.payee, f.emperor);
+ assert.equal(invoice.amount, f.price);
+}
+
+function declaration(game: Game, f: RicheseSelfPurchaseFixture, sold: boolean) {
+ retained(game, f);
+ assert.equal(game.phase, 3);
+ assert.equal(game.richeseBidding!.blackMarketSold, sold);
+ assert.equal(game.decision?.kind, 'richeseDeclaration');
+ assert.equal(game.decision!.player, f.seller);
+ assert.equal(game.currentAuctionSale, null);
+ assert.equal(game.richeseAuction, null);
+ assert.equal(game.response, null);
+}
+
+void test('provisional Normal own5 retention pays Emperor once, permits native Karama then Bureaucrat2, and resumes original normal controls', () => {
+ const f = createRicheseSelfPurchaseFixture({ method: 'normal', price: 5, emperor: true, counter: true });
+ let game = f.game;
+ for (let n = 0; game.richeseAuction!.active !== f.seller && n < game.players.length; n++) {
+ const actor = game.richeseAuction!.active!;
+ game = applyAction(game, actor, { type: 'richeseBid', event: f.action.event, amount: null });
+ }
+ assert.equal(game.richeseAuction!.active, f.seller);
+ rejectUnchanged(game, f.seller, { ...f.action, event: game.richeseBidding!.event });
+ rejectUnchanged(game, f.seller, { ...f.action, amount: 6 });
+ game = payRicheseSelfPurchase({ ...f, game });
+ emperorAllowance(game, f);
+ game = allowRicheseBureaucratResponses(game);
+ assert.equal(game.decision?.kind, 'bureaucratPayment');
+ const event = game.bureaucratPaymentEvent!;
+ for (const player of game.players) {
+ const pending = viewGame(game, player.id).bureaucrat.pending!;
+ assert.equal(pending.payee, f.emperor);
+ assert.equal(pending.amount, 5);
+ assert.equal(pending.payer, f.seller);
+ }
+ game = chooseRicheseBureaucrat(game, true);
+ declaration(game, f, true);
+ for (const player of game.players) {
+ const delta = -Number(player.id === f.seller) * 5
+ + Number(player.id === f.emperor) * 3;
+ assert.equal(player.spice, richeseBureaucratPlayer(f.beforeAction, player.id).spice + delta);
+ }
+ assert.equal(viewGame(game, f.trainer!).bureaucrat.usedThisPhase, true);
+ rejectUnchanged(game, f.seller, f.action);
+ rejectUnchanged(game, f.trainer!, { type: 'decision', event, redirect: true });
+
+ const normalCount = game.richeseBidding!.normalCount!;
+ game = applyAction(game, f.seller,
+ { type: 'decision', event: game.richeseBidding!.event, position: 'last' });
+ game = allowRicheseBureaucratResponses(game);
+ assert.equal(game.richeseBidding!.stage, 'normal');
+ assert.equal(game.auction!.cards.length, normalCount);
+ const next = nextHomeworldEcazStep(game);
+ assert.ok(next);
+ assert.equal(next.action.type, 'passBid');
+ assert.equal(next.actor, game.auction!.active, 'Use the original next controller, not a fixed Storm opener');
+ game = applyAction(game, next.actor, next.action);
+ retained(game, f);
+ assert.equal(game.richeseBidding!.blackMarketSold, true);
+ assert.equal(game.richeseBidding!.normalCount, normalCount, 'Continuing the auction does not count retention twice');
+});
+
+void test('provisional Once Around outbids a real1 with R5/ally3, while all-other-pass remains free unsold without an owner opportunity', () => {
+ const f = createRicheseSelfPurchaseFixture({ method: 'onceAround', direction: 'counterclockwise',
+ price: 8, allyPayment: 3, emperor: true, counter: true });
+ const free = payRicheseSelfPurchase({ ...f, actions: [] }, { openingBid: 0 });
+ declaration(free, f, false);
+ for (const player of free.players)
+ assert.equal(player.spice, richeseBureaucratPlayer(f.beforeAction, player.id).spice);
+ assert.equal(free.aid[f.donor!].amount, 3, 'Free unsold retention consumes none of the real prepaid aid');
+ assert.equal(viewGame(free, f.trainer!).bureaucrat.usedThisPhase, false);
+ rejectUnchanged(free, f.seller, f.action);
+
+ let game = f.game;
+ const openingActor = game.richeseAuction!.active!;
+ assert.notEqual(openingActor, f.seller);
+ game = applyAction(game, openingActor, { type: 'richeseBid', event: f.action.event, amount: 1, allyPayment: 0 });
+ assert.equal(game.richeseAuction!.bidder, openingActor);
+ assert.equal(game.richeseAuction!.bid, 1);
+ for (let n = 0; game.richeseAuction!.active !== f.seller && n < game.players.length; n++)
+ game = applyAction(game, game.richeseAuction!.active!, { type: 'richeseBid', event: f.action.event, amount: null });
+ assert.equal(game.richeseAuction!.active, f.seller);
+ rejectUnchanged(game, openingActor, { type: 'richeseBid', event: f.action.event, amount: 2 });
+ rejectUnchanged(game, f.seller, { ...f.action, amount: 9 });
+ rejectUnchanged(game, f.seller, { ...f.action, allyPayment: 4 });
+ game = applyAction(game, f.seller, f.action);
+ emperorAllowance(game, f);
+ assert.equal(game.aid[f.donor!].amount, 0);
+ assert.deepEqual(game.currentAuctionSale!.bureaucratPayment!.contributions,
+ [{ payer: f.seller, amount: 5 }, { payer: f.donor!, amount: 3 }]);
+ game = allowRicheseBureaucratResponses(game);
+ assert.equal(game.decision?.kind, 'bureaucratPayment');
+ const source = game.bureaucratPayments!.pending!.source;
+ assert.equal(source.payee, f.emperor);
+ assert.equal(source.amount, 8);
+ for (const player of game.players) {
+ const pending = viewGame(game, player.id).bureaucrat.pending!;
+ assert.equal(pending.payer, null, 'Actual allied payment shares remain private');
+ assert.equal(pending.payee, f.emperor);
+ assert.equal(pending.amount, 8, 'Public payment is the whole original price');
+ }
+ game = chooseRicheseBureaucrat(game, false);
+ declaration(game, f, true);
+ for (const player of game.players) {
+ const delta = -Number(player.id === f.seller) * 5 - Number(player.id === f.donor) * 3
+ + Number(player.id === f.emperor) * 8;
+ assert.equal(player.spice, richeseBureaucratPlayer(f.beforePledge, player.id).spice + delta,
+ 'Only actual retained funding is paid; the outbid bidder is not charged and Richese is not refunded');
+ }
+ assert.equal(game.aid[f.donor!].amount, 0);
+ assert.equal(viewGame(game, f.trainer!).bureaucrat.usedThisPhase, false);
+ assert.equal(free.richeseBidding!.normalCount, game.richeseBidding!.normalCount! + 1,
+ 'Exactly the positive sale, not free retention, removes one ordinary lot');
+ rejectUnchanged(game, f.seller, f.action);
+
+ game = applyAction(game, f.seller,
+ { type: 'decision', event: game.richeseBidding!.event, position: 'first' });
+ game = allowRicheseBureaucratResponses(game);
+ assert.equal(game.decision?.kind, 'richeseCache');
+ assert.equal(game.decision!.player, f.seller);
+ assert.equal(game.richeseBidding!.blackMarketSold, true);
+ assert.equal(game.auction, null, 'Cache-first continues to the actual cache choice before dealing ordinary cards');
+ retained(game, f);
+});
+
+void test('original no-Emperor Nexus table pays for retained ownership without inventing a free seller acquisition', () => {
+ let game = createGame('RETAINNEXUS', newPlayer('richese', 'Richese', 'richese'), true, ['choam']);
+ joinGame(game, newPlayer('atreides', 'Atreides', 'atreides'));
+ joinGame(game, newPlayer('guild', 'Guild', 'guild'));
+ game = applyAction(game, game.host, { type: 'moduleOption', module: 'nexusCards', enabled: true });
+ for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
+ game = applyAction(game, game.host, { type: 'start', advancedPreview: true });
+ for (let n = 0; game.decision?.kind !== 'richeseBlackMarket' && n < 300; n++) {
+ let step: { actor: string; action: Action } | undefined;
+ if (game.status === 'setup') {
+ for (const player of game.players) {
+ const view = viewGame(game, player.id);
+ view.players.find(p => p.id === player.id)!.bot = 'Easy';
+ const action = botActions(view)[0];
+ if (action) { step = { actor: player.id, action }; break; }
+ }
+ } else step = nextHomeworldEcazStep(game) ?? undefined;
+ assert.ok(step);
+ game = applyAction(game, step.actor, step.action);
+ }
+ assert.equal(game.decision?.kind, 'richeseBlackMarket');
+ const card = richeseBureaucratPlayer(game, 'richese').hand[0].id;
+ game = applyAction(game, 'richese', { type: 'decision', event: game.richeseBidding!.event, card, method: 'silent' });
+ game = allowRicheseBureaucratResponses(game);
+ const before = structuredClone(game), event = game.richeseAuction!.event;
+ game = applyAction(game, 'richese', { type: 'richeseBid', event, amount: 1, allyPayment: 0 });
+ for (const actor of before.richeseAuction!.order) if (actor !== 'richese')
+ game = applyAction(game, actor, { type: 'richeseBid', event, amount: 0 });
+ game = allowRicheseBureaucratResponses(game);
+ assert.equal(game.decision?.kind, 'richeseDeclaration');
+ assert.equal(game.richeseBidding!.blackMarketSold, true);
+ assert.equal(richeseBureaucratPlayer(game, 'richese').spice, richeseBureaucratPlayer(before, 'richese').spice - 1);
+ for (const player of game.players) {
+ assert.deepEqual(player.hand, richeseBureaucratPlayer(before, player.id).hand);
+ if (player.id !== 'richese') assert.equal(player.spice, richeseBureaucratPlayer(before, player.id).spice);
+ }
+ assert.deepEqual(game.nexusCards, before.nexusCards);
+ assert.deepEqual(richeseBureaucratCards(game), richeseBureaucratCards(before));
+});
diff --git a/tests/richese-self-purchase-silent.test.ts b/tests/richese-self-purchase-silent.test.ts
new file mode 100644
index 00000000..6fdeb508
--- /dev/null
+++ b/tests/richese-self-purchase-silent.test.ts
@@ -0,0 +1,165 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, handLimit, viewGame, type Action, type Game } from '../game/engine';
+import {
+ allowRicheseBureaucratResponses, richeseBureaucratCards, richeseBureaucratPlayer,
+} from './fixture-bureaucrat-richese-funding';
+import {
+ createRicheseSelfPurchaseFixture, payRicheseSelfPurchase,
+} from './fixture-richese-self-purchase';
+import { createRicheseFullHandSelfPurchaseGame } from './fixture-richese-self-purchase-full-hand';
+import {
+ advanceSpiceBankerIncomeToMentat, nextSpiceBankerIncomeNativeStep,
+} from './fixture-spice-banker-income';
+
+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);
+}
+
+void test('provisional Silent paid retention admits a full seller, seals its bid, and reduces the real ordinary pool once unlike zero retention', () => {
+ const initial = createRicheseFullHandSelfPurchaseGame();
+ const f = createRicheseSelfPurchaseFixture({ initial, price: 1, counter: true });
+ const seller = richeseBureaucratPlayer(f.beforeAction, f.seller);
+ assert.equal(seller.hand.length, handLimit(seller));
+ assert.ok(f.game.richeseAuction!.eligible.includes(f.seller));
+ assert.equal(f.game.players.filter(p => p.hand.length < handLimit(p)).length, 3);
+ let sealed = applyAction(f.game, f.seller, f.action);
+ assert.deepEqual(richeseBureaucratPlayer(sealed, f.seller).hand.map(c => c.id), f.originalHand);
+ assert.equal(richeseBureaucratPlayer(sealed, f.seller).spice, seller.spice);
+ for (const player of sealed.players) {
+ const auction = viewGame(sealed, player.id).richeseAuction!;
+ assert.equal(auction.ownBid, player.id === f.seller ? 1 : null);
+ assert.equal(auction.revealedBids, null);
+ assert.equal(auction.bid, 0);
+ assert.equal(auction.bidder, null);
+ assert.equal(auction.cardId, player.id === f.seller ? f.card : null);
+ assert.ok(auction.submitted.includes(f.seller));
+ }
+ rejectUnchanged(sealed, f.seller, f.action);
+ for (const actor of sealed.richeseAuction!.order.filter(id => id !== f.seller))
+ sealed = applyAction(sealed, actor, { type: 'richeseBid', event: f.action.event, amount: 0 });
+ assert.equal(sealed.response?.kind, 'emperorIncome');
+ for (const player of sealed.players)
+ assert.equal(viewGame(sealed, player.id).richeseAuction!.revealedBids![f.seller], 1);
+ const paid = allowRicheseBureaucratResponses(sealed);
+ assert.deepEqual(richeseBureaucratPlayer(paid, f.seller).hand.map(c => c.id), f.originalHand);
+ assert.equal(richeseBureaucratPlayer(paid, f.seller).hand.filter(c => c.id === f.card).length, 1);
+ assert.equal(richeseBureaucratPlayer(paid, f.seller).spice, seller.spice - 1);
+ assert.equal(richeseBureaucratPlayer(paid, f.emperor!).spice,
+ richeseBureaucratPlayer(f.beforeAction, f.emperor!).spice + 1);
+ assert.equal(paid.decision?.kind, 'richeseDeclaration');
+ assert.equal(paid.richeseAuction, null);
+ assert.equal(paid.richeseBidding!.blackMarketSold, true);
+ assert.equal(paid.richeseBidding!.normalCount, 1);
+ assert.deepEqual(richeseBureaucratCards(paid), f.physical);
+ rejectUnchanged(paid, f.seller, f.action);
+ const ordinary = allowRicheseBureaucratResponses(applyAction(paid, f.seller,
+ { type: 'decision', event: paid.richeseBidding!.event, position: 'last' }));
+ assert.equal(ordinary.auction!.cards.length, 1);
+ assert.notEqual(ordinary.auction!.active, f.seller);
+ assert.deepEqual(richeseBureaucratCards(ordinary), f.physical);
+
+ const zero = createRicheseSelfPurchaseFixture({ initial, price: 0 });
+ const retained = allowRicheseBureaucratResponses(payRicheseSelfPurchase(zero));
+ assert.equal(retained.richeseBidding!.blackMarketSold, false);
+ assert.equal(retained.richeseBidding!.normalCount, 2);
+ assert.equal(richeseBureaucratPlayer(retained, zero.seller).spice, seller.spice);
+ assert.deepEqual(richeseBureaucratPlayer(retained, zero.seller).hand.map(c => c.id), zero.originalHand);
+ const unsoldOrdinary = allowRicheseBureaucratResponses(applyAction(retained, zero.seller,
+ { type: 'decision', event: retained.richeseBidding!.event, position: 'last' }));
+ assert.equal(unsoldOrdinary.auction!.cards.length, 2);
+ assert.deepEqual(richeseBureaucratCards(unsoldOrdinary), zero.physical);
+});
+
+void test('bare Advanced without Skills earns the same full hand and pays five for original-card retention', () => {
+ const initial = createRicheseFullHandSelfPurchaseGame({ skills: false });
+ const f = createRicheseSelfPurchaseFixture({ initial, price: 5, counter: true });
+ const done = allowRicheseBureaucratResponses(payRicheseSelfPurchase(f));
+ assert.equal(done.phase, 3);
+ assert.equal(done.decision?.kind, 'richeseDeclaration');
+ assert.equal(richeseBureaucratPlayer(done, f.seller).spice,
+ richeseBureaucratPlayer(f.beforeAction, f.seller).spice - 5);
+ assert.equal(richeseBureaucratPlayer(done, f.emperor!).spice,
+ richeseBureaucratPlayer(f.beforeAction, f.emperor!).spice + 5);
+ assert.deepEqual(richeseBureaucratPlayer(done, f.seller).hand.map(c => c.id), f.originalHand);
+ assert.equal(done.richeseBidding!.normalCount, 1);
+ assert.deepEqual(richeseBureaucratCards(done), f.physical);
+});
+
+void test('full-hand allied three plus five: original Emperor Karama banks two payer legs, never offers Bureaucrat, and defers only one Banker spice', () => {
+ const f = createRicheseSelfPurchaseFixture({ initial: createRicheseFullHandSelfPurchaseGame(),
+ price: 8, allyPayment: 5, counter: true });
+ const sourceCount = f.beforeAction.spiceBankerIncome!.sources.length;
+ const bankerBalance = richeseBureaucratPlayer(f.beforeAction, f.banker!).spice;
+ const used = structuredClone(f.beforeAction.bureaucratPayments?.used ?? []);
+ const income = payRicheseSelfPurchase(f);
+ assert.equal(income.response?.kind, 'emperorIncome');
+ assert.deepEqual(richeseBureaucratPlayer(income, f.seller).hand.map(c => c.id), f.originalHand);
+ assert.equal(richeseBureaucratPlayer(income, f.seller).spice,
+ richeseBureaucratPlayer(f.beforePledge, f.seller).spice - 3);
+ assert.equal(richeseBureaucratPlayer(income, f.donor!).spice,
+ richeseBureaucratPlayer(f.beforePledge, f.donor!).spice - 5);
+ assert.equal(viewGame(income, f.trainer!).bureaucrat.pending, null);
+ assert.deepEqual(viewGame(income, f.banker!).spiceBankerIncome!.deferred, []);
+ 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);
+ assert.equal(done.decision?.kind, 'richeseDeclaration');
+ assert.equal(done.richeseBidding!.normalCount, 1);
+ assert.equal(richeseBureaucratPlayer(done, f.emperor!).spice,
+ richeseBureaucratPlayer(f.beforeAction, f.emperor!).spice);
+ assert.equal(richeseBureaucratPlayer(done, f.banker!).spice, bankerBalance);
+ assert.equal(viewGame(done, f.trainer!).bureaucrat.pending, null);
+ assert.deepEqual(done.bureaucratPayments?.used ?? [], used);
+ assert.equal(viewGame(done, f.trainer!).bureaucrat.usedThisPhase, 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.seller, amount: 3 }, { payer: f.donor!, amount: 5 }]);
+ assert.deepEqual(viewGame(done, f.banker!).spiceBankerIncome!.deferred, [{ owner: f.banker, amount: 1 }]);
+ assert.equal(done.aid[f.donor!]?.amount ?? 0, 0);
+ assert.deepEqual(done.richeseFunding, {});
+ assert.deepEqual(richeseBureaucratPlayer(done, f.seller).hand.map(c => c.id), f.originalHand);
+ assert.deepEqual(richeseBureaucratCards(done), f.physical);
+ assert.equal(done.discard.filter(c => c.id === f.counterCard).length, 1);
+ rejectUnchanged(done, f.counterActor, { type: 'card', card: f.counterCard!, mode: 'cancel' });
+
+ let beforeMentat = applyAction(done, f.seller,
+ { type: 'decision', event: done.richeseBidding!.event, position: 'last' });
+ 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(richeseBureaucratPlayer(beforeMentat, f.banker!).spice, bankerBalance);
+ assert.deepEqual(viewGame(beforeMentat, f.banker!).spiceBankerIncome!.deferred, [{ owner: f.banker, amount: 1 }]);
+ rejectUnchanged(beforeMentat, f.banker!, { type: 'bribe', target: f.seller, amount: bankerBalance + 1 });
+ const collected = advanceSpiceBankerIncomeToMentat(beforeMentat);
+ assert.equal(richeseBureaucratPlayer(collected, f.banker!).spice, 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('without Emperor the full seller pays five to the Bank, not itself, and the actual Guild Banker receives one deferred credit', () => {
+ const f = createRicheseSelfPurchaseFixture({ initial: createRicheseFullHandSelfPurchaseGame({ emperor: false }), price: 5 });
+ const sourceCount = f.beforeAction.spiceBankerIncome!.sources.length;
+ const balances = f.beforeAction.players.map(p => [p.id, p.spice] as const);
+ const done = allowRicheseBureaucratResponses(payRicheseSelfPurchase(f));
+ assert.equal(done.decision?.kind, 'richeseDeclaration');
+ assert.deepEqual(done.players.map(p => [p.id, p.spice]),
+ balances.map(([id, amount]) => [id, amount - (id === f.seller ? 5 : 0)]));
+ assert.deepEqual(richeseBureaucratPlayer(done, f.seller).hand.map(c => c.id), f.originalHand);
+ assert.equal(done.richeseBidding!.normalCount, 1);
+ assert.equal(viewGame(done, f.trainer!).bureaucrat.pending, null);
+ assert.equal(viewGame(done, f.trainer!).bureaucrat.usedThisPhase, 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.seller, amount: 5 }]);
+ assert.deepEqual(viewGame(done, f.banker!).spiceBankerIncome!.deferred, [{ owner: f.banker, amount: 1 }]);
+ assert.deepEqual(richeseBureaucratCards(done), f.physical);
+});
diff --git a/tests/richese-settlement-preflight.test.ts b/tests/richese-settlement-preflight.test.ts
index eac5339a..4a24dd36 100644
--- a/tests/richese-settlement-preflight.test.ts
+++ b/tests/richese-settlement-preflight.test.ts
@@ -144,31 +144,6 @@ void test('an open lot performs no settlement or future availability checks', ()
assert.deepEqual(value, before);
});
-void test('sold validation retains reserved-card, hand-capacity, self-purchase and exact-funding precedence', () => {
- const value = input('blackMarket');
- value.winner = { id: 'r', handCount: 4, handLimit: 4, spice: 0 };
- value.lot = {
- ...value.lot,
- outcome: { kind: 'sold', winner: 'r', amount: 6 },
- };
- value.card = null;
- value.funding = null;
- reject(value, /reserved auction card/);
- value.card = richeseCards()[0];
- reject(value, /winning hand has no room/);
- value.winner = { ...value.winner, handCount: 3 };
- reject(value, /Black Market self-purchase/);
- value.lot = { ...value.lot, source: 'cache' };
- reject(value, /exact funding commitment/);
- value.funding = { amount: 6, allyPayment: 2, donor: 'a' };
- reject(value, /no longer funded/);
- value.winner = { ...value.winner, spice: 4 };
- assert.equal(
- quoteRicheseSettlement(value).kind,
- 'sold',
- 'cache owner purchase remains accepted',
- );
-});
void test('server-supplied faction hand limits preserve last-slot purchases and reject full winners', () => {
for (const limit of [4, 5, 8]) {