feat: support provisional paid Black Market retention

This commit is contained in:
Quintine committed 2026-10-18 03:21:44 +11:00
1 parent 7891517a9b
commit e70cd48172
21 files changed
+862 -151

No files matched your search

+44 -49
View File
@@ -310,8 +310,6 @@ export function RicheseAuctionLot({ game, act, busy }: Props) {
...part, ...part,
}); });
const ownPayment = amount - allyPayment; const ownPayment = amount - allyPayment;
const sellerBlocked =
auction.source === 'blackMarket' && auction.owner === me;
const allowedTurn = const allowedTurn =
!auction.outcome && !auction.outcome &&
auction.eligible.includes(me) && auction.eligible.includes(me) &&
@@ -324,8 +322,7 @@ export function RicheseAuctionLot({ game, act, busy }: Props) {
allyPayment >= 0 && allyPayment >= 0 &&
allyPayment <= auction.allyAvailable && allyPayment <= auction.allyAvailable &&
ownPayment >= 0 && ownPayment >= 0 &&
ownPayment <= auction.ownAvailable && ownPayment <= auction.ownAvailable;
(!sellerBlocked || amount === 0);
const name = (player: string | null) => const name = (player: string | null) =>
game.players.find((p) => p.id === player)?.name ?? 'Player'; game.players.find((p) => p.id === player)?.name ?? 'Player';
const send = (bid: number | null, ally: number) => { const send = (bid: number | null, ally: number) => {
@@ -367,6 +364,14 @@ export function RicheseAuctionLot({ game, act, busy }: Props) {
<p className="fine"> <p className="fine">
{name(auction.owner)} is selling. Karama cannot acquire this card. {name(auction.owner)} is selling. Karama cannot acquire this card.
</p> </p>
{auction.source === 'blackMarket' && (
<p className="notice">
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.
</p>
)}
{auction.claim && ( {auction.claim && (
<p className="notice">Seller’s public claim: {auction.claim}</p> <p className="notice">Seller’s public claim: {auction.claim}</p>
)} )}
@@ -404,59 +409,49 @@ export function RicheseAuctionLot({ game, act, busy }: Props) {
Available funding: {auction.ownAvailable} own spice and{' '} Available funding: {auction.ownAvailable} own spice and{' '}
{auction.allyAvailable} pledged spice. {auction.allyAvailable} pledged spice.
</p> </p>
{sellerBlocked && ( <label htmlFor={`${id}-bid`}>
<p className="notice"> {silent ? 'Sealed bid' : 'Your bid'} (minimum {minimum})
Positive Black Market bids by the seller are not supported.{' '} </label>
{silent ? 'Submit zero to continue.' : 'Pass to continue.'} <Input
</p> id={`${id}-bid`}
)} type="number"
{!sellerBlocked && ( min={minimum}
step={1}
max={auction.ownAvailable + auction.allyAvailable}
value={amountText}
disabled={busy}
onChange={(event) => update({ amount: event.target.value })}
/>
{auction.allyAvailable > 0 && (
<> <>
<label htmlFor={`${id}-bid`}> <label htmlFor={`${id}-ally`}>Pay from ally’s pledge</label>
{silent ? 'Sealed bid' : 'Your bid'} (minimum {minimum})
</label>
<Input <Input
id={`${id}-bid`} id={`${id}-ally`}
type="number" type="number"
min={minimum} min={0}
step={1} step={1}
max={auction.ownAvailable + auction.allyAvailable} max={Number.isFinite(amount) ? Math.min(amount, auction.allyAvailable) : auction.allyAvailable}
value={amountText} value={allyText}
disabled={busy} disabled={busy}
onChange={(event) => update({ amount: event.target.value })} onChange={(event) => update({ ally: event.target.value })}
/> />
{auction.allyAvailable > 0 && (
<>
<label htmlFor={`${id}-ally`}>Pay from ally’s pledge</label>
<Input
id={`${id}-ally`}
type="number"
min={0}
step={1}
max={Number.isFinite(amount) ? Math.min(amount, auction.allyAvailable) : auction.allyAvailable}
value={allyText}
disabled={busy}
onChange={(event) => update({ ally: event.target.value })}
/>
</>
)}
<p className="fine">
{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.'}
</p>
<Button
className={buttonClass}
disabled={busy || !canBid}
onClick={() => send(amount, allyPayment)}
>
{silent ? 'Seal bid' : 'Raise bid'}
</Button>
</> </>
)} )}
<p className="fine">
{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.'}
</p>
<Button
className={buttonClass}
disabled={busy || !canBid}
onClick={() => send(amount, allyPayment)}
>
{silent ? 'Seal bid' : 'Raise bid'}
</Button>
<Button <Button
className={buttonClass} className={buttonClass}
variant="outline" variant="outline"
+13
View File
@@ -561,3 +561,16 @@ Types and build pass; final lint and focused donor1/1 pass after removing one
unnecessary fixture iterator copy. The eighteen-file413/413 consumer union unnecessary fixture iterator copy. The eighteen-file413/413 consumer union
preceded that lint-only change. Existing build warnings remain. No recovery, preceded that lint-only change. Existing build warnings remain. No recovery,
whole-database, seat-restoration or calibration campaign was added. whole-database, seat-restoration or calibration campaign was added.
## Original paid Black Market retention — 18 October 2026
[Provisional paid seller retention](RICHESE_AUCTION_RULES.md#provisional-paid-black-market-retention--18-october-2026)
keeps the already-held card and pays Emperor/Bank. This is one whole native
invoice, not the other-buyer Richese portion described above: own5/ally3 pays
Emperor8; allowed Bureaucrat sends Bank2 and Emperor6 without trainer income
or a self-refund. Native Karama still precedes that reaction and retains the
full original payer legs for Banker. Already-held full hands need no extra
slot and no new-acquisition/free-seller source is fabricated.
Native controls exercise own5 full-hand diversion, funded8 cancellation and
funded8 full income. The related frozen17-file151/151, types/lint/build pass;
complete modes, original source ambiguity and deferred assurance remain open.
+21
View File
@@ -29,6 +29,27 @@ optional skill bands and combinations remain open.
Rules implementation continues; comprehensive assurance and administration Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented. remain deferred until all rules are implemented.
**Paid Black Market retention — 18 October. Prototyped / Partial.**
Under the recorded choose-recommended policy, a positive seller win retains the
same card/order, pays native Emperor/Bank income and counts one sale. Zero/unbid
retention stays free/unsold. The supplied physical36/printed34 source ambiguity
remains explicit; this is not a publisher ruling or full-mode gate opening.
One shared current-hand rule now covers original eligibility, automatic
zero/pass, bid/commitment and settlement. Real earned4/4 hands need no extra
slot. Native Nexus retention supplies no new-acquisition/free-seller invoice;
the original paid path continues without spending a card or inventing a gain.
Actual bare/full/all14/Nexus programmes preserve card IDs/order and native fees.
Three390px flows exercise Silent full5→Bank2/one normal lot, Normal5+3 with
native cancellation and Once Around real1→final8/full income. No errors or
overflow. All four legal full-hand seller policies may retain free/unsold
through zero; strategy remains deferred. Frozen17-file **151/151**, focused1/1
and repaired old-exclusion1/1, types/lint/build pass. Initial native full-hand
auto-zero/slot failures are fixed through the shared rule; old pins are deleted.
Temporary routes/memory removed/released204, no rooms/reset/restart/deployment.
[Source, policy and evidence](RICHESE_AUCTION_RULES.md#provisional-paid-black-market-retention--18-october-2026).
**Advanced Richese split receipts — 18 October. Prototyped / Partial.** **Advanced Richese split receipts — 18 October. Prototyped / Partial.**
Original buyer3/Richese donor5 wrongly supplied no Bureaucrat opportunity. Original buyer3/Richese donor5 wrongly supplied no Bureaucrat opportunity.
New signed seller sources use only the buyer share; the original Emperor New signed seller sources use only the buyer share; the original Emperor
+28
View File
@@ -1,5 +1,33 @@
# Dune implementation status # Dune implementation status
## 18 October 2026 — Provisional paid Black Market retention
Original funded seller bid1 rejected unchanged. Under standing choose-recommended
authority, explicitly adopt paid retention of the same owned card: full original
price goes to Emperor/Bank, positive paid retention counts one sale, zero/unbid
keeps its unsold/free result. Supplied physical36/printed34 A/C does not explicitly
settle this self-purchase, so the rule and UI remain visibly provisional.
Shared current-hand legality covers eligibility, automatic skips, actor bid,
reserved commitments and terminal quote. A real full4/4 producer exposed old
auto-zero and extra-slot assumptions; both are repaired, not bypassed with a
hand edit. Cache1 + ordinary1 + ordinary1, then real deferred bribe4, earns that
hand/spice6. Retention neither removes/reappends the card nor creates acquisition.
An original Nexus table exposed an acquisition-only invoice call: already-held
retention now supplies no false veto/free-seller source and pays its native fee.
Frozen17-file **151/151**, focused full-hand1/1, repaired old-exclusion1/1,
types/lint/build and actual Normal/full/bare/Nexus programmes pass. One stale
Nexus test asserted the former native exclusion; it is deleted, not re-pinned.
Obsolete seller strategy, error precedence and fixture-copy pins are also removed.
Three390px controls cover full Silent5 with Bank2/one normal lot, Normal5+3
native cancellation, and Once Around real1→final8/full Emperor income. Same
owned cards, original armies/escrow and stock45 remain; no errors/overflow.
Four policies legally choose zero for free unsold full-hand retention.
Temporary routes/memory removed/released204; games/server untouched. Existing
build warnings and complete-mode/deployed/calibration/assurance gates remain.
[Canonical current source/policy](RICHESE_AUCTION_RULES.md#provisional-paid-black-market-retention--18-october-2026).
## 18 October 2026 — Actual Advanced Richese Bureaucrat portions ## 18 October 2026 — Actual Advanced Richese Bureaucrat portions
A real native all14 first turn, deferred bribe1, turn-two worm alliance and A real native all14 first turn, deferred bribe1, turn-two worm alliance and
+15
View File
@@ -7,6 +7,21 @@ and fixture opt-ins are removed; existing games are not retrofitted.
Earlier dated references to a separate entry describe historical checkpoints. 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). See [current activation and limits](SPICE_BANKER_RUNTIME.md#mandatory-ordinary-skills-default--17-october-2026).
## Provisional paid Richese retention — 18 October 2026
Positive Black Market seller wins now retain the same held card/order and pay
the original Emperor/Bank invoice. This visibly provisional policy is not a
retrieved ruling; zero/unbid stays free and unsold. All14 is not required:
real bare full-hand and original Nexus starts also complete native paid retention.
Owned full hands need no new slot. Bureaucrat sees one whole eligible Emperor
payment, not a private Richese portion; cancellation/Banker keep original legs.
Frozen17-file151/151, types/lint/build, three actual390px methods and four legal
full-hand seller policies pass. Native original cards, deferred bribe/acquisitions
and physical counter sources are retained. No new acquisition/free-seller frame,
spurious Nexus spend, full-mode or assurance claim.
[Source, exact receipts and scope](RICHESE_AUCTION_RULES.md#provisional-paid-black-market-retention--18-october-2026).
## Advanced Richese portions — 18 October 2026 ## Advanced Richese portions — 18 October 2026
Bureaucrat now evaluates buyer-to-Richese and Richese-donor-to-Emperor receipts Bureaucrat now evaluates buyer-to-Richese and Richese-donor-to-Emperor receipts
+6 -5
View File
@@ -109,13 +109,14 @@ the existing public asset root; this is not complete artwork acceptance.
### Recommended Richese seller-unpaid purchase — 14 October 2026 ### Recommended Richese seller-unpaid purchase — 14 October 2026
Original positive Richese cache and Advanced-only Black Market sales now offer Original positive Richese cache acquisitions and other-buyer Advanced Black
every winning buyer the same Pay confirmation, independently of hidden Nexus Market sales offer each winning buyer the same Pay confirmation, independently of hidden Nexus
face. Only an actual unallied Emperor-card holder, with native Emperor absent face. Only an actual unallied Emperor-card holder, with native Emperor absent
and the full price personally affordable, can retain that price. Basic cache and the full price personally affordable, can retain that price. Basic cache
and legally offered self-cache are included; original Black Market self-purchase and legally offered self-cache are included. Already-held Black Market paid
remains prohibited. Allied funding and ordinary bank-purchase admission are retention supplies no acquisition/free-seller source, so its native price is
unchanged. paid without inventing this confirmation or spending a Nexus. Allied funding
and ordinary bank-purchase admission remain unchanged.
**Provisional recommendation: the seller is unpaid.** The printed card tells **Provisional recommendation: the seller is unpaid.** The printed card tells
the buyer to keep spice and prints no bank reimbursement. Do not invent seller the buyer to keep spice and prints no bank reimbursement. Do not invent seller
+67 -3
View File
@@ -10,7 +10,61 @@ Audit: 2026-09-06. This document covers the complete core bidding pipeline: the
- **I20**: [GF9 Ixian & Tleilaxu rulebook](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf), pp.8–9,12: pool preparation, Technology, allied replacement, cancellation. Local `ix-official-mirror.txt` supplies the full paragraphs; publisher-indexed text corroborates the relevant cancellation table. - **I20**: [GF9 Ixian & Tleilaxu rulebook](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf), pp.8–9,12: pool preparation, Technology, allied replacement, cancellation. Local `ix-official-mirror.txt` supplies the full paragraphs; publisher-indexed text corroborates the relevant cancellation table.
- **F20**: [GF9 November 2020 FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf), pp.3,6–7: once-per-game special powers and ordinary acquisition/alliance clarifications. It predates Richese and supplies no Richese-specific exception. - **F20**: [GF9 November 2020 FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf), pp.3,6–7: once-per-game special powers and ordinary acquisition/alliance clarifications. It predates Richese and supplies no Richese-specific exception.
Targeted searches did not yield a newer applicable publisher/designer ruling resolving the edge cases below. This is not a claim that none exists. Tournament rewrites and community compilations are not authority for these cases. Search-result dates do not date a printing. The September publisher/designer searches did not resolve every edge case below. The user's 1 October amendment now authorizes the supplied revision, including its identified unofficial rulings; see the [source amendment](RULE_DECISIONS.md#authorized-source-amendment--1-october-2026). Its own remaining ambiguity is distinguished from the explicit provisional policies below. Other community/retailer interpretations are not automatically promoted to authority.
## Provisional paid Black Market retention — 18 October 2026
The authorized supplied revision's physical **p36**, printed **p34**, Richese
A/C, gives the shared Emperor/Bank payment rule and Black Market auction
methods, but no explicit answer to positive seller participation, self-payment
or sale counting. The older official-only uncertainty below remains a source
uncertainty. Under the user's 14 October choose-recommended direction, the
prototype now explicitly adopts **paid retention**, not a publisher ruling:
- A positive seller win retains the same already-held card in place. Pay the
actual original price to Emperor, or Bank if absent, including only original
authorized ally escrow. No self-refund, duplicate card or new acquisition.
- Count that completed positive payment as one Black Market sale. Zero/unbid
retention is still free and unsold. Once Around keeps its original
all-other-pass shortcut rather than inventing another seller opportunity.
- An already-held full hand needs no extra slot. Shared current-hand legality
governs initial eligibility, automatic zero/pass, positive bid, committed
reservation and terminal quote. Other full bidders and cache acquisition
retain their existing limits.
- Reuse native Emperor Karama, eligible Bureaucrat, actual Banker legs and
original declaration/pool continuation. Retention supplies neither a cache
acquisition nor seller-income invoice to the separate Richese veto/Emperor
free-seller sources. Existing Nexus cards are not spent on a fabricated gain.
Before repair, an original silent seller bid1 with five real spice rejected
unchanged. A genuinely earned full hand then exposed two additional generic
assumptions: automatic zero submission and a reserved extra-slot invariant.
Both now use the same rule, not special-cased saved data. A real original
no-Emperor Nexus table also exposed an acquisition-only source call; it now
pays1 to Bank, retains its original card/Nexus state and resumes declaration.
The full-hand producer buys an original cache card1 and two ordinary cards1
each during turn1; a real bribe4 waits for Mentat, leaving Richese6 and four
held cards. Actual all14 paid5 retention leaves one spice and the identical
four IDs/order; bare no-Skills paid1 leaves five. All four legal seller policies
may still submit zero, leaving free unsold retention and no price/count change.
Three actual390px controls exercise: full Silent paid5 with native allowance
and Bank2 (Emperor13→16, same4/4 hand, one normal lot); Normal5+3 after a real
competing1, original income cancellation (Emperor stays11, both costs/escrow
paid once, same two cards, no Bureaucrat use); and Once Around final8 after
another player's1, full original income (Emperor11→19, same two cards, no use).
The notice is visibly provisional before positive bidding. Card stock45,
native starting armies, private sealed projection and original continuation
remain. No browser errors/overflow. Temporary routes/memory are removed and
released204, with no stored-room mutation or server restart.
Meaningful native regressions live in `tests/richese-self-purchase-rounds.test.ts`
and `tests/richese-self-purchase-silent.test.ts`, with original producers in
`tests/fixture-richese-self-purchase.ts` and
`tests/fixture-richese-self-purchase-full-hand.ts`. Old exclusion, error-wording,
strategy-choice and fixture-copy pins are removed, not re-pinned. This remains
local prototype evidence, not full Richese/mode, deployed or final assurance.
## Authorized Advanced contribution recipients — 4 October 2026 ## Authorized Advanced contribution recipients — 4 October 2026
@@ -71,8 +125,8 @@ These questions cannot be silently represented as settled FAQ compliance:
1. **Canceled compulsory lot and an exhausted cache — resolved 7 October 2026 by user ruling; connected 8 October.** When no cache lot can run, the ordinary pool retains its full declared count without the cache deduction. Printed Karama can now cancel the declared cache opportunity in Basic or Advanced; Advanced Bene Gesserit conversion retains its separate response. Cancellation leaves every cached card in place and skips both first- and last-position offers. The before-cache count is frozen at declaration, so spending a cancellation card cannot recount live hand capacity, and the independent completed-Black-Market deduction remains. Five direct engine smoke scenarios cover first/last, printed/BG cancellation and countered conversion; no broad suite was run for this change. 1. **Canceled compulsory lot and an exhausted cache — resolved 7 October 2026 by user ruling; connected 8 October.** When no cache lot can run, the ordinary pool retains its full declared count without the cache deduction. Printed Karama can now cancel the declared cache opportunity in Basic or Advanced; Advanced Bene Gesserit conversion retains its separate response. Cancellation leaves every cached card in place and skips both first- and last-position offers. The before-cache count is frozen at declaration, so spending a cancellation card cannot recount live hand capacity, and the independent completed-Black-Market deduction remains. Five direct engine smoke scenarios cover first/last, printed/BG cancellation and countered conversion; no broad suite was run for this change.
2. **Black Market count wording.** Its specific rule conditions reduction on a sale. The embedded Ixian answer refers more broadly to cards put up for auction. The more specific sale condition supports no reduction for an unsold/canceled Black Market offer. Record that composition explicitly; do not decrement merely on declaration. 2. **Black Market count wording.** Its specific rule conditions reduction on a sale. The embedded Ixian answer refers more broadly to cards put up for auction. The more specific sale condition supports no reduction for an unsold/canceled Black Market offer. Record that composition explicitly; do not decrement merely on declaration.
3. **Self-purchase of a Black Market card.** The generic alternative methods include Richese, but Black Market says Richese receives all payments and already owns the card. No retrieved paragraph specifies seller exclusion, a self-payment loop, an Emperor payment, or whether self-retention is a sale reducing the normal pool. Do not borrow the cache self-payment exception without a ruling. 3. **Self-purchase of a Black Market card — source unresolved, provisional policy connected 18 October.** The generic methods include Richese, while Black Market already has an owned seller card and no explicit self-payment/count answer. Under choose-recommended, the visibly labelled prototype inherits native Emperor/Bank payment for positive paid retention, keeps the same card without another slot, and counts one sale. Zero/unbid remains unsold. This is the recorded product interpretation above, not a retrieved ruling or free self-refund.
4. **Ixian advanced Technology on special lots.** I20 p.9 broadly applies before bidding on a card and before Atreides inspection. R2's explicit restriction concerns the _ally's post-purchase replacement_, not Technology. Consequently that FAQ cannot justify disabling Technology on special lots. No retrieved Richese paragraph explains an exchanged cache lot's new public face, or the custody of a substituted Black Market lot if nobody bids. This is a genuine interaction to resolve before certifying the combined implementation. 4. **Ixian advanced Technology on special lots — superseded 8 October.** The authorized supplied revision, physical p34 Ixians D, expressly excludes Richese auctions. Cache/Black Market lots therefore open directly and retain the Technology use for ordinary lots. The older publisher-only uncertainty below is historical evidence, not a current replacement-custody permission.
5. **Economic interruptions during Silent.** Printed simultaneous physical offers do not specify asynchronous network reservation or what to do when a later effect changes funds. Internal reservations are necessary to reproduce simultaneous commitment, but a policy allowing players to revise already revealed information or silently reducing bids would be an extra rule. Use immutable hidden submissions and prevent double-spending without exposing reserved amounts. 5. **Economic interruptions during Silent.** Printed simultaneous physical offers do not specify asynchronous network reservation or what to do when a later effect changes funds. Internal reservations are necessary to reproduce simultaneous commitment, but a policy allowing players to revise already revealed information or silently reducing bids would be an extra rule. Use immutable hidden submissions and prevent double-spending without exposing reserved amounts.
6. **Separate Richese income cancellation.** The R2 table specifies preventing the sale/auction. It does not expressly create an additional post-sale cancel-income window. Reusing Emperor's income response for a sale paid to Richese would invent that window and potentially misroute money. General Karama composition may require further review; do not assume two independent chances from one listed power. 6. **Separate Richese income cancellation.** The R2 table specifies preventing the sale/auction. It does not expressly create an additional post-sale cancel-income window. Reusing Emperor's income response for a sale paid to Richese would invent that window and potentially misroute money. General Karama composition may require further review; do not assume two independent chances from one listed power.
7. **Mandatory last lot after a normal all-pass ending.** Mandatory cache auction plus its last-position FAQ supports running it after the ordinary pool ends. This is a defensible composition, not a directly answered all-pass example. Store this boundary explicitly and test it. 7. **Mandatory last lot after a normal all-pass ending.** Mandatory cache auction plus its last-position FAQ supports running it after the ordinary pool ends. This is a defensible composition, not a directly answered all-pass example. Store this boundary explicitly and test it.
@@ -196,3 +250,13 @@ three focused test files were deleted; `tests/richese-engine-review.test.ts`
now asserts the direct lot opening. Historical absence claims about ordinary now asserts the direct lot opening. Historical absence claims about ordinary
Richese technology cards above are superseded by their later runtime contracts, Richese technology cards above are superseded by their later runtime contracts,
including [Juice of Sapho](JUICE_OF_SAPHO_RUNTIME.md). including [Juice of Sapho](JUICE_OF_SAPHO_RUNTIME.md).
## Paid-retention frozen verification
The affected seventeen-file union151/151, focused full-hand1/1 and repaired
old-exclusion1/1 pass, with parent-owned types/lint and app build. The former
native exclusion assertion inside the Emperor source test is deleted; actual
free-acquisition corruption boundaries remain. Seven meaningful native
regressions cover the new methods/full-hand/bare/payment/source behavior.
Existing build warnings remain. No recovery/whole-database/seat campaign or
AI calibration/deployed acceptance is claimed.
+24 -4
View File
@@ -2,6 +2,25 @@
Updated 18 October 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority. Updated 18 October 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority.
## Provisional paid Black Market retention — 18 October 2026
The supplied revision physical36/printed34, Richese A/C, gives the shared
Richese payment rule and Black Market methods but does not explicitly settle
positive seller participation or sale counting. Under the 14 October
choose-recommended direction, provisionally permit a positive seller win as
**paid retention**: keep the same held card, pay the actual original bid to
Emperor/Bank, and count one completed sale. No self-refund or new acquisition;
full hands need no extra slot. Zero/unbid retention remains free and unsold,
including the original Once Around all-other-pass shortcut.
Native counters, actual allied escrow, Bureaucrat/Banker and original phase
continuation remain. Already-held retention provides neither the cache
acquisition nor seller-income invoice required by the separate Richese veto
and Emperor free-seller acquisition sources; those frames are not invented.
This is an explicit provisional product interpretation, not publisher/user
clarification of the remaining source ambiguity.
[Source, controls and scope](RICHESE_AUCTION_RULES.md#provisional-paid-black-market-retention--18-october-2026).
## Recommended mixed Planetologist advisor gather — 18 October 2026 ## Recommended mixed Planetologist advisor gather — 18 October 2026
The supplied authorized revision, physical28/logical26 and physical17/logical15, The supplied authorized revision, physical28/logical26 and physical17/logical15,
@@ -245,9 +264,10 @@ Original Richese cache/Advanced Black Market now use uniform buyer confirmation
after original Richese Betrayal. Self-discard precludes acquisition/Nexus spend; after original Richese Betrayal. Self-discard precludes acquisition/Nexus spend;
bank diversion preserves provenance but receives nothing on zero-payment Use. bank diversion preserves provenance but receives nothing on zero-payment Use.
Original stock, hand limits, bonus and successor continue. Basic/Advanced cache, Original stock, hand limits, bonus and successor continue. Basic/Advanced cache,
legal self-cache and Advanced Black Market are covered; self-Black remains legal self-cache and other-buyer Advanced Black Market are covered. Already-held
prohibited. The distinct original Ambassador path follows the current recommendation; Black Market retention supplies no acquisition/free-seller source; its native
other real purchase sources and full combinations retain separate contracts. paid path follows the separate 18 October policy above. The distinct original
Ambassador path and other real purchase sources retain separate contracts.
Visibly provisional source composition selected under decision priority, not Visibly provisional source composition selected under decision priority, not
publisher clarification or a separately answered numerical ruling. publisher clarification or a separately answered numerical ruling.
[Canonical policy/proof](NEXUS_EMPEROR_RULES.md#recommended-richese-seller-unpaid-purchase--14-october-2026). [Canonical policy/proof](NEXUS_EMPEROR_RULES.md#recommended-richese-seller-unpaid-purchase--14-october-2026).
@@ -720,7 +740,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| Nexus lifecycle | Twelve physical cards, phase draw/replacement and alliance forfeiture; native Cunning, absent-faction Secret Ally and Betrayal have separate effects. No blanket Karama immunity. | [Common source contract](NEXUS_CARD_RULES.md), [lifecycle runtime](NEXUS_CARD_RUNTIME.md) | | Nexus lifecycle | Twelve physical cards, phase draw/replacement and alliance forfeiture; native Cunning, absent-faction Secret Ally and Betrayal have separate effects. No blanket Karama immunity. | [Common source contract](NEXUS_CARD_RULES.md), [lifecycle runtime](NEXUS_CARD_RUNTIME.md) |
| Nexus faction effects | Preserve physical custody, original faction identity and durable effect receipts; use each printed family instead of borrowing another faction's rules by analogy. | [Atreides](NEXUS_ATREIDES_RULES.md), [Harkonnen](NEXUS_HARKONNEN_RULES.md), [BG](NEXUS_BENE_GESSERIT_RULES.md), [Emperor](NEXUS_EMPEROR_RULES.md), [Fremen](NEXUS_FREMEN_RULES.md), [Guild](NEXUS_GUILD_RULES.md) | | Nexus faction effects | Preserve physical custody, original faction identity and durable effect receipts; use each printed family instead of borrowing another faction's rules by analogy. | [Atreides](NEXUS_ATREIDES_RULES.md), [Harkonnen](NEXUS_HARKONNEN_RULES.md), [BG](NEXUS_BENE_GESSERIT_RULES.md), [Emperor](NEXUS_EMPEROR_RULES.md), [Fremen](NEXUS_FREMEN_RULES.md), [Guild](NEXUS_GUILD_RULES.md) |
| Expansion Nexus families | Source audit coverage does not imply every panel is playable; consult later runtime records in the status log. | [Ixian Cunning](NEXUS_IXIAN_CUNNING_RULES.md), [Ixian replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Tleilaxu](NEXUS_TLEILAXU_RULES.md), [CHOAM](NEXUS_CHOAM_RULES.md), [Richese](NEXUS_RICHESE_RULES.md), [Ecaz](NEXUS_ECAZ_RULES.md), [Moritani](NEXUS_MORITANI_RULES.md) | | Expansion Nexus families | Source audit coverage does not imply every panel is playable; consult later runtime records in the status log. | [Ixian Cunning](NEXUS_IXIAN_CUNNING_RULES.md), [Ixian replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Tleilaxu](NEXUS_TLEILAXU_RULES.md), [CHOAM](NEXUS_CHOAM_RULES.md), [Richese](NEXUS_RICHESE_RULES.md), [Ecaz](NEXUS_ECAZ_RULES.md), [Moritani](NEXUS_MORITANI_RULES.md) |
| Advanced Richese ally contribution | The authorized supplied Advanced Richese Bidding clause routes Richese's contribution to its ally's cache/Black Market purchase to Emperor or bank, even when Emperor buys. Preserve the original donor leg separately from buyer-to-Richese credit; cancellation redirects only the Emperor leg without paying twice. Basic retains R2's earlier receipt. Native Banker counts only final bank legs; exhausted-cache counts, positive Black Market self-bidding and Technology custody remain unresolved. | [Source distinction and recipient contract](RICHESE_AUCTION_RULES.md#authorized-advanced-contribution-recipients--4-october-2026) | | Advanced Richese ally contribution | The authorized supplied Advanced Richese Bidding clause routes Richese's contribution to its ally's cache/Black Market purchase to Emperor or Bank, even when Emperor buys. Preserve that donor leg separately from buyer-to-Richese credit; cancellation redirects only original Emperor income. Basic retains its earlier receipt. Bureaucrat follows the actual separate receipts and normal Banker counts final payer Bank legs. Positive seller retention follows the explicit 18 October provisional policy above; ordinary cache counting and the supplied exclusion of special-lot Ixian Technology remain separate. | [Recipient contract](RICHESE_AUCTION_RULES.md#authorized-advanced-contribution-recipients--4-october-2026), [paid-retention policy](RICHESE_AUCTION_RULES.md#provisional-paid-black-market-retention--18-october-2026) |
| Richese Nexus Betrayal auctions | Pause at a valid original prepayment quote. Public self-cache veto discards the exact card without debit/acquisition; another buyer's positive cache/Black Market invoice keeps price/contributions/delivery and earned Harkonnen bonus while overriding the recipient to bank. All publicly possible unallied other-faction held-Nexus seats acknowledge uniformly; private identity controls only own Use. This neutral protocol is an application choice, not publisher timing. Normal hidden-family self-purchases, special-Karama veto and unsupported overlays remain excluded; no native Karama exception certifies them. Bounded engine/SQL/bot/phone scenarios are verified locally; broader verification remains Partial and public starts/full Nexus gates stay closed. | [Printed face, profile, API and saved continuation](NEXUS_RICHESE_RULES.md#bounded-betrayal-auction-contract--30-september-2026), [common privacy protocol](NEXUS_CARD_RULES.md#richese-betrayal-prepayment-protocol), [auction source boundaries](RICHESE_AUCTION_RULES.md) | | Richese Nexus Betrayal auctions | Pause at a valid original prepayment quote. Public self-cache veto discards the exact card without debit/acquisition; another buyer's positive cache/Black Market invoice keeps price/contributions/delivery and earned Harkonnen bonus while overriding the recipient to bank. All publicly possible unallied other-faction held-Nexus seats acknowledge uniformly; private identity controls only own Use. This neutral protocol is an application choice, not publisher timing. Normal hidden-family self-purchases, special-Karama veto and unsupported overlays remain excluded; no native Karama exception certifies them. Bounded engine/SQL/bot/phone scenarios are verified locally; broader verification remains Partial and public starts/full Nexus gates stay closed. | [Printed face, profile, API and saved continuation](NEXUS_RICHESE_RULES.md#bounded-betrayal-auction-contract--30-september-2026), [common privacy protocol](NEXUS_CARD_RULES.md#richese-betrayal-prepayment-protocol), [auction source boundaries](RICHESE_AUCTION_RULES.md) |
| Guild Nexus Betrayal shipment payment | Replace one original funded positive fee in full, including own pre-funded payment and the printed full occupied-Junction override; preserve original payer/contributions, consume already-debited donor escrow once and deliver the original physical shipment once. Native applicable stop/rate responses precede the bounded gate; zero/stopped shipments supply no award or Nexus cost. Public possible held-Nexus seats acknowledge uniformly; private Use changes no public price/source disclosure. Fresh classic/native-Guild/Nexus with optional genuinely seeded Homeworlds only; unsupported native Cunning/Richese shipment overlays block before card/parent commitment, and restored transport independently requires the native Guild grant. Bounded engine/SQL/bot/CLI/phone custody and refresh are locally verified; no new priority/immunity, ordinary occupation/rounding ruling or extra shipment. Broad/public/live gates remain closed. | [Printed source and bounded contract](NEXUS_GUILD_RULES.md#nexus-guild-betrayal), [common protocol](NEXUS_CARD_RULES.md#guild-betrayal-funded-payment-protocol), [internal checklist](../game/reference.ts) | | Guild Nexus Betrayal shipment payment | Replace one original funded positive fee in full, including own pre-funded payment and the printed full occupied-Junction override; preserve original payer/contributions, consume already-debited donor escrow once and deliver the original physical shipment once. Native applicable stop/rate responses precede the bounded gate; zero/stopped shipments supply no award or Nexus cost. Public possible held-Nexus seats acknowledge uniformly; private Use changes no public price/source disclosure. Fresh classic/native-Guild/Nexus with optional genuinely seeded Homeworlds only; unsupported native Cunning/Richese shipment overlays block before card/parent commitment, and restored transport independently requires the native Guild grant. Bounded engine/SQL/bot/CLI/phone custody and refresh are locally verified; no new priority/immunity, ordinary occupation/rounding ruling or extra shipment. Broad/public/live gates remain closed. | [Printed source and bounded contract](NEXUS_GUILD_RULES.md#nexus-guild-betrayal), [common protocol](NEXUS_CARD_RULES.md#guild-betrayal-funded-payment-protocol), [internal checklist](../game/reference.ts) |
| Ixian Nexus Secret Ally replacement | Fresh classic/Tleilaxu/Richese profiles, Ixians absent, unallied buyer and Nexus alone. Normal purchases, positive Richese cache/Advanced Black Market sales and genuine Advanced special-Karama cache acquisition during Bidding bind actual target/payment and original continuation. A direct price3 receipt outranks unrelated rows/income, preserving active player, once-use and suspended controls without an auction. Outside Bidding it adds no offer. Only unpaid bids remain reserved; delivered sale commitments are not charged twice. Cache uses the visible provisional independent-Nexus interpretation; native ally exclusion and BG/Harkonnen source rules remain. Other modules and full acceptance stay separate. | [Current contract](NEXUS_IXIAN_REPLACEMENT_RULES.md#bounded-local-runtime-contract), [actual direct receipt, funding fix and proof](NEXUS_IXIAN_REPLACEMENT_RULES.md#direct-richese-special-purchase-during-bidding--16-october-2026) | | Ixian Nexus Secret Ally replacement | Fresh classic/Tleilaxu/Richese profiles, Ixians absent, unallied buyer and Nexus alone. Normal purchases, positive Richese cache/Advanced Black Market sales and genuine Advanced special-Karama cache acquisition during Bidding bind actual target/payment and original continuation. A direct price3 receipt outranks unrelated rows/income, preserving active player, once-use and suspended controls without an auction. Outside Bidding it adds no offer. Only unpaid bids remain reserved; delivered sale commitments are not charged twice. Cache uses the visible provisional independent-Nexus interpretation; native ally exclusion and BG/Harkonnen source rules remain. Other modules and full acceptance stay separate. | [Current contract](NEXUS_IXIAN_REPLACEMENT_RULES.md#bounded-local-runtime-contract), [actual direct receipt, funding fix and proof](NEXUS_IXIAN_REPLACEMENT_RULES.md#direct-richese-special-purchase-during-bidding--16-october-2026) |
+3 -2
View File
@@ -274,8 +274,9 @@ function calculate(
'The paid Richese lot has a stale declaration round.', 'The paid Richese lot has a stale declaration round.',
); );
requireAuction( requireAuction(
lot.source !== 'blackMarket' || (input.advanced && s.seller !== s.winner), lot.source !== 'blackMarket' || (input.advanced &&
'Black Market requires an Advanced sale to another buyer; self-purchase awaits a payment and sale-counting ruling.', (s.seller !== s.winner || !s.free && s.amount > 0)),
'Black Market requires Advanced play; paid seller retention needs a positive actual price.',
); );
requireAuction( requireAuction(
!s.free || ( !s.free || (
+27 -24
View File
@@ -440,6 +440,7 @@ import {
} from './richese-funding'; } from './richese-funding';
import { import {
quoteRicheseSettlement, quoteRicheseSettlement,
richeseBidFitsHand,
requireRicheseDeclarationCache, requireRicheseDeclarationCache,
RicheseSettlementError, RicheseSettlementError,
type RicheseSettlementQuote, type RicheseSettlementQuote,
@@ -12337,6 +12338,8 @@ function richeseBetrayalIntegrity(g: Game): void {
} }
} }
function beginRicheseBetrayal(g: Game, quote: Extract<RicheseSettlementQuote,{kind:'sold'}>): boolean { function beginRicheseBetrayal(g: Game, quote: Extract<RicheseSettlementQuote,{kind:'sold'}>): 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; if (!g.richeseBetrayalPreview) return false;
richeseBetrayalSourcePreflight(g,quote); richeseBetrayalSourcePreflight(g,quote);
const required = richeseBetrayalResponders(g); const required = richeseBetrayalResponders(g);
@@ -12464,6 +12467,8 @@ function nexusEmperorSellerOffer(g: Game, id: string) {
return { ...quote, blocked, canUse: !blocked, policy: EMPEROR_SELLER_POLICY }; return { ...quote, blocked, canUse: !blocked, policy: EMPEROR_SELLER_POLICY };
} }
function beginNexusEmperorSellerPurchase(g: Game, quote: Extract<RicheseSettlementQuote, {kind:'sold'}>, bank: boolean): boolean { function beginNexusEmperorSellerPurchase(g: Game, quote: Extract<RicheseSettlementQuote, {kind:'sold'}>, 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; if (quote.amount <= 0 || !emperorNexusSellerPurchaseModeSupported(g)) return false;
requireRule(!g.pendingNexusEmperorSellerPurchase && !g.pendingRicheseBetrayal && requireRule(!g.pendingNexusEmperorSellerPurchase && !g.pendingRicheseBetrayal &&
!g.response && !g.decision && !g.truthtrance && !g.phaseOpening && !g.response && !g.decision && !g.truthtrance && !g.phaseOpening &&
@@ -12517,10 +12522,13 @@ function settleRicheseSoldLot(g: Game, quote: Extract<RicheseSettlementQuote,{ki
: [{payer:owner.id,amount:richeseContribution,recipient:emperor ? 'player' : 'bank'}]) : undefined; : [{payer:owner.id,amount:richeseContribution,recipient:emperor ? 'player' : 'bank'}]) : undefined;
if (!free) payWithAlly(g, winner, quote.amount, quote.allyPayment); if (!free) payWithAlly(g, winner, quote.amount, quote.allyPayment);
g.richeseFunding = {}; g.richeseFunding = {};
if (lot.source === 'cache') const retained = lot.source === 'blackMarket' && winner.id === owner.id;
g.richeseCache = g.richeseCache!.filter((c) => c.id !== card.id); if (!retained) {
else owner.hand = owner.hand.filter((c) => c.id !== card.id); if (lot.source === 'cache')
winner.hand.push(card); 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.currentAuctionSale = {
...(g.homeworldOccupationPreview ? { occupiedEvent: lot.event, ...(g.homeworldOccupationPreview ? { occupiedEvent: lot.event,
occupiedContributions: free ? [] : [{ payer: winner.id, amount: quote.ownPayment }, occupiedContributions: free ? [] : [{ payer: winner.id, amount: quote.ownPayment },
@@ -12538,7 +12546,7 @@ function settleRicheseSoldLot(g: Game, quote: Extract<RicheseSettlementQuote,{ki
if (bank || !emperor) awardBankerIncome(g, incomePayment); if (bank || !emperor) awardBankerIncome(g, incomePayment);
log( log(
g, g,
`${winner.name} bought ${lot.source === 'cache' ? card.name : 'the concealed Black Market card'} for ${quote.amount} spice.${free ? ' Emperor Nexus retained the proven price; no seller or bank payment occurred.' : ' The funded bid was paid automatically.'}`, `${winner.name} ${retained ? 'retained their own Black Market card under provisional paid self-purchase' : `bought ${lot.source === 'cache' ? card.name : 'the concealed Black Market card'}`} for ${quote.amount} spice.${free ? ' Emperor Nexus retained the proven price; no seller or bank payment occurred.' : ' The funded bid was paid automatically.'}`,
{ faction: winner.faction, name: 'Auction payment' }, { faction: winner.faction, name: 'Auction payment' },
); );
const ixians = byFaction(g, 'ixians'); const ixians = byFaction(g, 'ixians');
@@ -12591,7 +12599,10 @@ function beginRicheseLot(
source, source,
owner: owner.id, owner: owner.id,
method: action.method as RicheseAuction['method'], method: action.method as RicheseAuction['method'],
eligible: auctionEligible(g), eligible: g.order.filter(id => {
const bidder = getPlayer(g, id);
return richeseBidFitsHand(source, owner.id, id, bidder.hand.length, handLimit(bidder));
}),
order, order,
tieOrder: g.order, tieOrder: g.order,
}); });
@@ -15141,17 +15152,16 @@ function finishAutomaticDecision(g: Game): boolean {
return true; return true;
} }
if (g.phase === 3 && !decision && lot && !lot.outcome) { if (g.phase === 3 && !decision && lot && !lot.outcome) {
const active = lot.active ? getPlayer(g, lot.active) : null;
const full = const full =
lot.method === 'silent' lot.method === 'silent'
? lot.eligible.find( ? lot.eligible.find(id => {
(id) => const bidder = getPlayer(g, id);
!Object.hasOwn(lot.sealed, id) && return !Object.hasOwn(lot.sealed, id) &&
getPlayer(g, id).hand.length >= handLimit(getPlayer(g, id)), !richeseBidFitsHand(lot.source, lot.owner, id, bidder.hand.length, handLimit(bidder));
) })
: lot.active && : active && !richeseBidFitsHand(lot.source, lot.owner, active.id, active.hand.length, handLimit(active))
getPlayer(g, lot.active).hand.length >= ? active.id
handLimit(getPlayer(g, lot.active))
? lot.active
: null; : null;
if (full) { if (full) {
g.richeseAuction = submitRicheseBid( g.richeseAuction = submitRicheseBid(
@@ -29995,7 +30005,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
: lot.bidder === bidder.id; : lot.bidder === bidder.id;
if (hasWinningCommitment) if (hasWinningCommitment)
requireRule( 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.', 'Leave room for the card in your committed auction.',
); );
} }
@@ -32833,14 +32843,7 @@ function applyActionInner(
g.phase === 3 && lot && !lot.outcome, g.phase === 3 && lot && !lot.outcome,
'There is no open Richese auction.', 'There is no open Richese auction.',
); );
requireRule(p.hand.length < handLimit(p), 'Your hand is full.'); requireRule(richeseBidFitsHand(lot.source, lot.owner, p.id, 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.',
);
const amount = const amount =
action.amount === null action.amount === null
? null ? null
+13 -9
View File
@@ -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.', '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.', '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.', '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.', '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.', '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.', '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: [ related: [
@@ -440,13 +440,13 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation', area: 'Implementation',
status: 'Partial', status: 'Partial',
detail: 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', area: 'Player controls',
status: 'Partial', status: 'Partial',
detail: 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', area: 'AI',
@@ -464,8 +464,12 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Verification', area: 'Verification',
status: 'Partial', status: 'Partial',
detail: 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: [ 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-cards.test.ts',
'tests/richese-card-presentation.test.ts', 'tests/richese-card-presentation.test.ts',
'tests/richese-engine.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 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.', '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.', '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.', '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.', '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.', '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.', '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.', '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.', '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.', '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.', '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 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.', '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'], related: ['leader-skills', 'richese-cards', 'richese-no-field', 'spice-banker-income', 'implementation-checklist'],
checklist: [ checklist: [
+11 -5
View File
@@ -42,6 +42,16 @@ export type RicheseSettlementQuote =
allyPayment: number; 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( function requireSettlement(
condition: unknown, condition: unknown,
message: string, message: string,
@@ -106,13 +116,9 @@ export function quoteRicheseSettlement(
const card = reservedCard(input); const card = reservedCard(input);
validHand(winner); validHand(winner);
requireSettlement( 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.', '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; const { funding } = input;
requireSettlement( requireSettlement(
funding && funding.amount === lot.outcome.amount, funding && funding.amount === lot.outcome.amount,
+5 -1
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine'; import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog'; import type { FactionId } from '../game/catalog';
import type { LeaderSkillId } from '../game/leader-skill-cards'; import type { LeaderSkillId } from '../game/leader-skill-cards';
import type { RicheseAuctionMethod } from '../game/richese-auction';
import { createIndependentCardModulesFixture } from './fixture-independent-card-modules'; import { createIndependentCardModulesFixture } from './fixture-independent-card-modules';
import { homeworldEcazClean, nextHomeworldEcazStep } from './fixture-homeworld-ecaz-occupy'; import { homeworldEcazClean, nextHomeworldEcazStep } from './fixture-homeworld-ecaz-occupy';
@@ -14,6 +15,8 @@ export type RicheseBureaucratOptions = {
owner?: 'beneGesserit' | 'atreides' | 'emperor' | 'richese'; owner?: 'beneGesserit' | 'atreides' | 'emperor' | 'richese';
emperor?: boolean; emperor?: boolean;
counter?: boolean; counter?: boolean;
method?: RicheseAuctionMethod;
direction?: 'clockwise' | 'counterclockwise';
}; };
export type RicheseBureaucratStep = { actor: string; action: Action }; export type RicheseBureaucratStep = { actor: string; action: Action };
export function richeseBureaucratPlayer(game: Game, id: string) { export function richeseBureaucratPlayer(game: Game, id: string) {
@@ -89,7 +92,8 @@ export function createRicheseBureaucratFixture(options: RicheseBureaucratOptions
const choose = (values: Record<string, unknown>) => act(donor, const choose = (values: Record<string, unknown>) => act(donor,
{ type: 'decision', event: game.richeseBidding!.event, ...values } as Action); { type: 'decision', event: game.richeseBidding!.event, ...values } as Action);
if (advanced) { 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 }); else choose({ decline: true });
} }
if (source === 'cache') { if (source === 'cache') {
@@ -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<Record<FactionId, LeaderSkillId>> = {
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<string, unknown>) => 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;
}
+98
View File
@@ -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<typeof createRicheseSelfPurchaseFixture>;
/** 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;
}
@@ -132,7 +132,6 @@ void test('personal-price, exact source, full hand and physical singleton corrup
const { g, input } = original('blackMarket'); const { g, input } = original('blackMarket');
g.advanced = false; g.advanced = false;
assert.throws(() => createEmperorNexusSellerPurchase(g, input), EmperorNexusPurchaseError); 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', () => { void test('private card entitlement and alliances cannot authorize retention, even with enough visible spice', () => {
-15
View File
@@ -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', () => { void test('unfunded bidders pass, sealed bidders act once, and hidden opponent state cannot affect the bid', () => {
for (const difficulty of DIFFICULTIES) { for (const difficulty of DIFFICULTIES) {
+1 -8
View File
@@ -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(viewGame(g, 'e').richeseAuction!.cardId, null);
assert.equal(g.players[0].hand[0].id, card.id); 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 before = fixture();
const k = before.deck.find((c) => c.effect === 'karama')!; const k = before.deck.find((c) => c.effect === 'karama')!;
before.players[2].hand = [k]; 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.players[2].hand.some((c) => c.id === k.id), false);
assert.equal(canceled.discard.filter((c) => c.id === k.id).length, 1); 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'); 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', () => { void test('an outbid ally-funded player whose hand fills is automatically skipped without reserving losing credit', () => {
+216
View File
@@ -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));
});
+165
View File
@@ -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);
});
@@ -144,31 +144,6 @@ void test('an open lot performs no settlement or future availability checks', ()
assert.deepEqual(value, before); 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', () => { void test('server-supplied faction hand limits preserve last-slot purchases and reject full winners', () => {
for (const limit of [4, 5, 8]) { for (const limit of [4, 5, 8]) {